效率工具

用 n8n 与 Python 自动生成订单日报:CSV 汇总和结果复核

n8n 不只能连接在线应用,也可以处理本地文件。 这篇做一个订单日报:读 CSV → 解析 → Code 节点按城市汇总 → 生成 Markdown 报告写回磁盘。用 n8n execute 在命令行跑,方便放进 cron;再用 Python 独立重算一遍核对结果。

BINBHO · 图文教程

n8n 不只能连接在线应用,也可以处理本地文件。 这篇做一个订单日报:读 CSV → 解析 → Code 节点按城市汇总 → 生成 Markdown 报告写回磁盘。用 n8n execute 在命令行跑,方便放进 cron;再用 Python 独立重算一遍核对结果。

先看清成品

  • 在 box 上真实执行成功:8 条订单,7 条有效,合计 771 元,缺数量的 A1008 列入“需要人工核对”。
  • 遇到两个真实报错:读文件被拒 Access to the file is not allowed.;n8n 运行时 CLI 执行报 5679 端口被占用。都已解决。
  • Python 重算结果与报告一致,pytest 2 项通过。

合规提醒:n8n 用 Sustainable Use License(fair-code):自用或公司内部自托管可以免费用,把它作为服务卖给别人有限制,以官方 LICENSE.md 为准。本文只用官方 npm 包和官方 CLI。订单数据属于个人信息,只收必要字段,并告知用途。

运行说明:全部在 box 上真实运行(n8n 2.44.5,Node.js 24.22.1,2026-10-21 21:19–21:22,UTC+8)。box 不能用 Docker(没有守护进程权限),所以按官方 npm 方式安装。第一次用 Node 22 安装失败,npm 只报 Tracker "idealTree" already exists,看不到真正原因。npm view n8n engines 显示 2.44.5 要求 node >=24.0.0,而官方安装页写的是 20.19 到 24.x。换成官方 Node 24 后安装成功。

做完之后你会得到什么?

成果 说明
daily_report.json 工作流(6 个节点)
data/orders.csv 示例订单(我编的)
run.sh 导入并执行
test_report.py 用 Python 核对报告

需要准备哪些环境?

项目 说明
Node.js 24.x(以 npm view n8n engines 为准)
n8n npm install n8n(本文 2.44.5),或按官方文档用 Docker
数据目录 N8N_USER_FOLDER 指定;里面有加密密钥,删掉后已存凭据无法解密
Python 3.9 以上 + pytest,用来写测试

关键事实

事实 来源
N8N_RESTRICT_FILE_ACCESS_TO 限制 n8n 能访问的目录,多个目录用分号分隔 官方 安全环境变量
任务代理端口 N8N_RUNNERS_BROKER_PORT 默认 5679 官方 Task runners 环境变量
n8n execute --id=<ID> 在命令行执行一个工作流 官方 CLI 文档
Read/Write Files from Disk 节点读写 n8n 所在机器上的文件 官方节点文档

文件结构

82-n8n-B-src/
├── daily_report.json
├── data/
│   └── orders.csv
├── run.sh
└── test_report.py

第 1 步:示例数据

order_id,date,city,product,qty,amount
A1001,2026-10-19,上海,咖啡豆,2,136
A1002,2026-10-19,杭州,挂耳包,5,95
A1003,2026-10-20,上海,挂耳包,3,57
A1004,2026-10-20,北京,咖啡豆,1,68
A1005,2026-10-20,杭州,咖啡豆,2,136
A1006,2026-10-21,上海,滤杯,1,89
A1007,2026-10-21,北京,挂耳包,10,190
A1008,2026-10-21,上海,咖啡豆,,68

第 2 步:工作流 JSON

{
  "id": "dailyReport0001",
  "name": "订单日报(读 CSV → 汇总 → 写 Markdown)",
  "active": false,
  "settings": {"executionOrder": "v1"},
  "nodes": [
    {"id": "b1", "name": "手动触发", "type": "n8n-nodes-base.manualTrigger", "typeVersion": 1, "position": [0, 0], "parameters": {}},
    {"id": "b2", "name": "读取 CSV", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [200, 0],
     "parameters": {"operation": "read", "fileSelector": "/tmp/n8n-files/orders.csv", "options": {}}},
    {"id": "b3", "name": "解析 CSV", "type": "n8n-nodes-base.extractFromFile", "typeVersion": 1, "position": [400, 0],
     "parameters": {"operation": "csv", "options": {"headerRow": true}}},
    {"id": "b4", "name": "汇总", "type": "n8n-nodes-base.code", "typeVersion": 2, "position": [600, 0],
     "parameters": {"mode": "runOnceForAllItems", "jsCode": "const rows = $input.all().map(i => i.json);\nconst byCity = {};\nconst problems = [];\nlet total = 0;\nfor (const r of rows) {\n  const amount = Number(r.amount);\n  const qty = r.qty === '' || r.qty == null ? NaN : Number(r.qty);\n  if (!Number.isFinite(amount) || !Number.isInteger(qty)) { problems.push(`${r.order_id}: qty=${JSON.stringify(r.qty)} amount=${JSON.stringify(r.amount)}`); continue; }\n  byCity[r.city] = (byCity[r.city] || 0) + amount;\n  total += amount;\n}\nconst lines = ['# 订单日报', '', `有效订单 ${rows.length - problems.length} 条,合计 ${total} 元`, '', '| 城市 | 金额(元) |', '| :--- | ---: |'];\nfor (const [c, v] of Object.entries(byCity).sort((a, b) => b[1] - a[1])) lines.push(`| ${c} | ${v} |`);\nif (problems.length) { lines.push('', '## 需要人工核对', ...problems.map(p => `- ${p}`)); }\nreturn [{ json: { markdown: lines.join('\\n'), total, problems } }];"}},
    {"id": "b5", "name": "转成文件", "type": "n8n-nodes-base.convertToFile", "typeVersion": 1.1, "position": [800, 0],
     "parameters": {"operation": "toText", "sourceProperty": "markdown", "options": {"fileName": "report.md"}}},
    {"id": "b6", "name": "写入报告", "type": "n8n-nodes-base.readWriteFile", "typeVersion": 1, "position": [1000, 0],
     "parameters": {"operation": "write", "fileName": "/tmp/n8n-files/report.md", "options": {}}}
  ],
  "connections": {
    "手动触发": {"main": [[{"node": "读取 CSV", "type": "main", "index": 0}]]},
    "读取 CSV": {"main": [[{"node": "解析 CSV", "type": "main", "index": 0}]]},
    "解析 CSV": {"main": [[{"node": "汇总", "type": "main", "index": 0}]]},
    "汇总": {"main": [[{"node": "转成文件", "type": "main", "index": 0}]]},
    "转成文件": {"main": [[{"node": "写入报告", "type": "main", "index": 0}]]}
  }
}

第 3 步:运行脚本

#!/usr/bin/env bash
# 导入并用 CLI 执行一次“订单日报”工作流。
set -euo pipefail
export N8N_RESTRICT_FILE_ACCESS_TO=/tmp/n8n-files      # 只允许读写这个目录
mkdir -p /tmp/n8n-files && cp data/orders.csv /tmp/n8n-files/
npx n8n import:workflow --input=daily_report.json
# 如果 n8n start 正在运行,任务代理已占用 5679,CLI 执行要换一个端口
N8N_RUNNERS_BROKER_PORT=5680 npx n8n execute --id=dailyReport0001 > execution.json
cat /tmp/n8n-files/report.md

第 4 步:核对测试

"""用 Python 独立重算一遍,核对 n8n 写出的 report.md(需要先运行工作流)。"""
import csv, pathlib, re
import pytest

REPORT = pathlib.Path("/tmp/n8n-files/report.md")


def expected():
    by_city, total, bad = {}, 0, []
    for r in csv.DictReader(open("data/orders.csv", encoding="utf-8")):
        if not r["qty"].strip().isdigit():
            bad.append(r["order_id"]); continue
        by_city[r["city"]] = by_city.get(r["city"], 0) + int(r["amount"]); total += int(r["amount"])
    return by_city, total, bad


@pytest.mark.skipif(not REPORT.exists(), reason="先运行 n8n 工作流")
def test_report_matches_python():
    md = REPORT.read_text(encoding="utf-8")
    by_city, total, bad = expected()
    assert f"合计 {total} 元" in md
    for c, v in by_city.items():
        assert f"| {c} | {v} |" in md
    for o in bad:
        assert re.search(rf"- {o}:", md)


def test_expected_values():
    by_city, total, bad = expected()
    assert total == 771 and by_city == {"上海": 282, "杭州": 231, "北京": 258} and bad == ["A1008"]

第 5 步:运行

$ ./run.sh
Successfully imported 1 workflow.
$ tail -4 execution.json
  "storedAt": "db",
  "status": "success",
  "finished": true
}
$ cat /tmp/n8n-files/report.md
# 订单日报

有效订单 7 条,合计 771 元

| 城市 | 金额(元) |
| :--- | ---: |
| 上海 | 282 |
| 北京 | 258 |
| 杭州 | 231 |

## 需要人工核对
- A1008: qty=undefined amount="68"
$ python -m pytest -q
2 passed

怎么检查结果?

  • 手算:上海 136+57+89=282,北京 68+190=258,杭州 95+136=231,合计 771。
  • “需要人工核对”里的订单不计入合计,要找人补数据。
  • 放进 cron 时把 run.sh 的输出重定向到日志,execution.json 里 "status" 不是 success 就报警。

调试示例

1. 读文件被拒。 第一次执行:

Execution error:
====================================
Access to the file is not allowed.
NodeApiError: Access to the file is not allowed.
    at ExecuteContext.execute (.../ReadWriteFile/actions/read.operation.ts:213:10)

n8n 2.44.5 默认不允许 Read/Write Files 节点访问 /tmp/n8n-files。设置 N8N_RESTRICT_FILE_ACCESS_TO=/tmp/n8n-files 后成功。只放开这一个目录,不要设成 /。

2. CLI 执行和正在运行的 n8n 冲突。 A 篇的 n8n start 还开着,执行时报:

n8n Task Broker's port 5679 is already in use. Do you have another instance of n8n running already?

CLI 执行也会启动一个任务代理(运行 Code 节点用的)。加 N8N_RUNNERS_BROKER_PORT=5680 后正常。

3. 空字段是 undefined,不是空字符串。 A1008 的 qty 在 CSV 里是空的,报告写的是 qty=undefined。我的判断条件是 r.qty === '' || r.qty == null,两种情况都处理了;只写 === '' 的话,这条会变成 NaN 被当成“不是整数”,结果碰巧一样,但原因说错了。

常见问题

现象 原因 处理
Access to the file is not allowed. 目录不在允许范围 设置 N8N_RESTRICT_FILE_ACCESS_TO
port 5679 is already in use 另一个 n8n 在运行 换 N8N_RUNNERS_BROKER_PORT
中文乱码 CSV 不是 UTF-8 先转成 UTF-8
金额合计不对 有空值或非数字 看“需要人工核对”一节

模板规则

  • 只放开必要的目录。
  • 有问题的行单独列出,不静默丢弃。
  • 用另一种语言独立核对一次。
  • CLI 执行检查 status 字段。

项目资源

用途 地址
n8n npm 安装(官方) https://docs.n8n.io/deploy/host-n8n/install-options/install-with-npm
n8n CLI 命令(官方) https://docs.n8n.io/deploy/host-n8n/configure-n8n/use-the-command-line
n8n GitHub https://github.com/n8n-io/n8n
n8n 许可证 https://github.com/n8n-io/n8n/blob/master/LICENSE.md
n8n 安全环境变量(官方) https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/security
n8n Task runners 环境变量(官方) https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/task-runners
Read/Write Files from Disk(官方) https://docs.n8n.io/integrations/builtin/core-nodes/n8n-nodes-base.readwritefile
n8n Code 节点(官方) https://docs.n8n.io/build/code-in-n8n/using-the-code-node

先用 CLI 跑通、核对,再改成定时触发。