OpenClaw 财务场景实战:自动读取报销单、核对金额、生成台账
财务同学每个月底都有一道劫:邮箱里躺着几十张报销单,有 PDF 扫描件、有手机拍的图片、有 Excel 表格;每一张都要手动核对金额、确认票据类型、比对报销标准;核完再手工录入台账,生成月度汇总……这件事重复、枯燥、容易出错,耗掉的时间往往以天计。这篇文章要做的,就是用OpenClaw把上面这套流程的大部分环节自动化。思路是:给 OpenClaw 装三个 Skills,然后你只需要在 Telegr
标签:
OpenClaw财务自动化AI助手报销台账Skill开发办公效率
阅读时间:约 22 分钟
适合人群:财务人员 / 有轻量编程基础的业务同学 / 想用 AI 提效的团队
写在前面:一个真实的财务痛点
财务同学每个月底都有一道劫:
邮箱里躺着几十张报销单,有 PDF 扫描件、有手机拍的图片、有 Excel 表格;
每一张都要手动核对金额、确认票据类型、比对报销标准;
核完再手工录入台账,生成月度汇总……
这件事重复、枯燥、容易出错,耗掉的时间往往以天计。
这篇文章要做的,就是用 OpenClaw 把上面这套流程的大部分环节自动化。
思路是:给 OpenClaw 装三个 Skills,然后你只需要在 Telegram 里发一句话:
“帮我处理 /reports 目录下本月所有报销单,核对金额,生成台账”
剩下的事情,它来做。
一、OpenClaw 快速回顾
如果你还没读过上一篇(《当 OpenClaw 遇上 RAG》),这里用最短的篇幅介绍一下它:
OpenClaw 是一个跑在你自己电脑上的开源个人 AI 助手。
安装在本地,通过 WhatsApp / Telegram / Discord 等聊天软件交互。它能读写文件、执行脚本、控制浏览器,支持 Claude、GPT-4o 或本地模型作为大脑。最关键的是,它有一套叫做 Skills 的扩展机制——用一个 SKILL.md 文件就能教会它新本领。
今天我们要教给它三个财务技能:
| Skill | 功能 |
|---|---|
expense-parser |
读取报销单(PDF / 图片 / Excel),提取结构化数据 |
expense-checker |
按公司报销政策核对每一笔金额,标记异常 |
ledger-writer |
汇总核对结果,生成 Excel 格式台账 |
三个 Skill 协同工作,形成一条完整的财务自动化流水线。
二、整体流程设计
先把全局流程捋清楚,再逐步实现。
你在 Telegram 发消息:
"处理本月报销单,生成台账"
│
▼
OpenClaw 理解意图,按顺序调用三个 Skill
┌─────────────────────────────────────────┐
│ Step 1:expense-parser │
│ 扫描指定目录,逐一解析报销单文件 │
│ 输出:结构化 JSON(金额/类型/申请人…) │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ Step 2:expense-checker │
│ 对照报销政策逐条核查 │
│ 输出:每条记录 通过 / 超标 / 缺票据 │
└──────────────────┬──────────────────────┘
│
┌──────────────────▼──────────────────────┐
│ Step 3:ledger-writer │
│ 生成 Excel 台账(含汇总 + 明细 + 异常)│
│ 输出:台账文件路径 │
└─────────────────────────────────────────┘
│
▼
OpenClaw 回复你:
"✅ 处理完成!本月共 34 条报销,
通过 29 条,异常 5 条(超标 3、缺票 2)
台账已生成:~/finance/台账_2026_03.xlsx"
三、环境准备
3.1 安装 OpenClaw
curl -fsSL https://openclaw.ai/install.sh | bash
openclaw onboard
3.2 安装 Python 依赖
pip install openpyxl pymupdf pillow \
anthropic openai rich \
python-dateutil --break-system-packages
说明:
openpyxl:读写 Excel 文件pymupdf:解析 PDF(比 pdfplumber 快得多)pillow:处理图片格式报销单anthropic/openai:调用多模态模型识别图片中的金额python-dateutil:解析各种格式的日期字符串
3.3 创建工作目录
mkdir -p ~/finance/{reports,output,policy}
mkdir -p ~/.openclaw/skills/{expense-parser,expense-checker,ledger-writer,finance-workflow}
目录结构:
~/finance/
reports/ ← 把报销单文件丢这里
张三_差旅_0301.pdf
李四_餐饮_0305.xlsx
王五_交通_0310.jpg
...
output/ ← 台账输出目录
policy/
reimbursement_policy.json ← 报销政策配置
四、报销政策配置文件
在写代码之前,先把"核对标准"固化成一个配置文件,这样财务主管可以随时修改规则,不需要动代码。
// ~/finance/policy/reimbursement_policy.json
{
"version": "2026-Q1",
"description": "公司员工费用报销标准",
"categories": {
"餐饮": {
"label": "餐饮招待",
"per_person_limit": 100,
"requires_receipt": true,
"requires_attendee_list": true,
"note": "超过 3 人需附参会人员名单"
},
"交通": {
"label": "交通费",
"per_trip_limit": 500,
"requires_receipt": true,
"taxi_single_limit": 100,
"note": "单次打车超 100 元需注明事由"
},
"差旅": {
"label": "差旅费",
"daily_hotel_limit": 500,
"daily_subsidy": 100,
"requires_receipt": true,
"requires_travel_approval": true,
"note": "需提前审批,酒店上限 500 元/晚"
},
"办公": {
"label": "办公用品",
"single_limit": 1000,
"monthly_limit": 3000,
"requires_receipt": true,
"note": "单笔超 1000 元需部门负责人审批"
},
"培训": {
"label": "培训费",
"annual_limit": 10000,
"requires_receipt": true,
"requires_approval": true,
"note": "需提前申请培训计划"
}
},
"global_rules": {
"max_days_after_expense": 30,
"currency": "CNY",
"vat_invoice_required_above": 500
}
}
这个文件是整套系统的"大脑",所有核对规则都从这里读取。当政策变动时,只需改这个 JSON,不需要动任何代码。
五、Skill 1:expense-parser(报销单解析)
这是整个流程的第一关,也是技术难度最高的一环——报销单的格式千奇百怪,要从中提取出结构化数据。
5.1 核心解析脚本
# ~/.openclaw/skills/expense-parser/parse.py
import sys
import json
import re
from pathlib import Path
from datetime import datetime
import fitz # PyMuPDF
import base64
def parse_file(filepath: str) -> dict:
"""入口函数:识别文件类型并分发解析"""
path = Path(filepath)
suffix = path.suffix.lower()
if suffix == ".pdf":
return parse_pdf(filepath)
elif suffix in [".jpg", ".jpeg", ".png", ".heic"]:
return parse_image(filepath)
elif suffix in [".xlsx", ".xls"]:
return parse_excel(filepath)
else:
return {"error": f"不支持的文件类型:{suffix}", "file": path.name}
# ─── PDF 解析 ────────────────────────────────────────────
def parse_pdf(filepath: str) -> dict:
"""解析 PDF 报销单,优先文字提取,失败则转图片用视觉模型"""
doc = fitz.open(filepath)
full_text = "\n".join(page.get_text() for page in doc)
if len(full_text.strip()) > 50:
return extract_fields_from_text(full_text, Path(filepath).name)
else:
# 扫描件,转第一页为图片,交给视觉模型
page = doc[0]
pix = page.get_pixmap(dpi=150)
img_bytes = pix.tobytes("png")
return extract_fields_from_image_bytes(img_bytes, Path(filepath).name)
# ─── 图片解析(手机拍照报销单)──────────────────────────
def parse_image(filepath: str) -> dict:
with open(filepath, "rb") as f:
img_bytes = f.read()
return extract_fields_from_image_bytes(img_bytes, Path(filepath).name)
def extract_fields_from_image_bytes(img_bytes: bytes, filename: str) -> dict:
"""调用多模态 LLM 识别图片中的报销信息"""
import anthropic
client = anthropic.Anthropic()
img_b64 = base64.standard_b64encode(img_bytes).decode()
response = client.messages.create(
model="claude-opus-4-5",
max_tokens=1024,
messages=[{
"role": "user",
"content": [
{
"type": "image",
"source": {"type": "base64", "media_type": "image/png", "data": img_b64}
},
{
"type": "text",
"text": """请从这张报销单图片中提取以下字段,以 JSON 格式返回,字段不存在时值为 null:
{
"applicant": "申请人姓名",
"department": "部门",
"date": "报销日期 YYYY-MM-DD",
"category": "费用类型(餐饮/交通/差旅/办公/培训/其他)",
"amount": 金额数字(不含货币符号),
"description": "费用说明",
"has_receipt": true/false(是否附有发票或收据),
"attendees_count": 人数(餐饮类适用,其他为null),
"trip_destination": "出差目的地(差旅类适用)"
}
只返回 JSON,不要其他文字。"""
}
]
}]
)
raw = response.content[0].text.strip()
raw = re.sub(r"^```json|```$", "", raw, flags=re.MULTILINE).strip()
try:
result = json.loads(raw)
result["source_file"] = filename
result["parse_method"] = "vision"
return result
except json.JSONDecodeError:
return {"error": "视觉模型返回格式解析失败", "raw": raw, "file": filename}
# ─── Excel 解析 ──────────────────────────────────────────
def parse_excel(filepath: str) -> dict:
"""解析标准 Excel 格式报销单"""
import openpyxl
wb = openpyxl.load_workbook(filepath, data_only=True)
ws = wb.active
all_text = []
for row in ws.iter_rows(values_only=True):
row_text = " ".join(str(cell) for cell in row if cell is not None)
if row_text.strip():
all_text.append(row_text)
return extract_fields_from_text("\n".join(all_text), Path(filepath).name)
# ─── 文本字段提取(规则 + LLM 兜底)────────────────────
def extract_fields_from_text(text: str, filename: str) -> dict:
"""先用正则快速提取,再用 LLM 补全缺失字段"""
result = {"source_file": filename, "parse_method": "text"}
# 金额:匹配多种格式
amount_patterns = [
r"[¥¥]\s*([\d,]+\.?\d*)",
r"金额[::]\s*([\d,]+\.?\d*)",
r"合计[::]\s*([\d,]+\.?\d*)",
r"总计[::]\s*([\d,]+\.?\d*)",
r"([\d,]+\.?\d*)\s*元",
]
for pat in amount_patterns:
m = re.search(pat, text)
if m:
result["amount"] = float(m.group(1).replace(",", ""))
break
# 日期
date_patterns = [
r"(\d{4})[年\-/](\d{1,2})[月\-/](\d{1,2})",
r"(\d{4})(\d{2})(\d{2})",
]
for pat in date_patterns:
m = re.search(pat, text)
if m:
y, mo, d = m.group(1), m.group(2).zfill(2), m.group(3).zfill(2)
result["date"] = f"{y}-{mo}-{d}"
break
# 申请人
m = re.search(r"(?:申请人|报销人|姓名)[::\s]*([\u4e00-\u9fff]{2,4})", text)
if m:
result["applicant"] = m.group(1)
# 费用类型关键词匹配
category_keywords = {
"餐饮": ["餐", "饭", "食", "宴", "招待"],
"交通": ["交通", "打车", "滴滴", "地铁", "高铁", "机票", "油费"],
"差旅": ["差旅", "出差", "住宿", "酒店"],
"办公": ["办公", "文具", "耗材", "设备"],
"培训": ["培训", "课程", "学习", "会议"],
}
for cat, keywords in category_keywords.items():
if any(kw in text for kw in keywords):
result["category"] = cat
break
# 发票检测
result["has_receipt"] = any(w in text for w in ["发票", "收据", "票据", "增值税"])
# 用 LLM 补全缺失的关键字段
missing = [k for k in ["applicant", "amount", "category", "date"] if k not in result]
if missing:
result.update(_llm_fill_missing(text, result, missing))
return result
def _llm_fill_missing(text: str, partial: dict, missing: list) -> dict:
"""用 LLM 补全规则提取失败的字段"""
import anthropic
client = anthropic.Anthropic()
prompt = f"""以下是从报销单提取的部分信息:
{json.dumps(partial, ensure_ascii=False, indent=2)}
原始文本:
{text[:1500]}
请补全以下缺失字段,只返回 JSON 对象(仅包含缺失字段):{missing}
字段含义:applicant=申请人, amount=金额数字, category=费用类型, date=日期YYYY-MM-DD
找不到则值为 null。"""
resp = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=256,
messages=[{"role": "user", "content": prompt}]
)
raw = resp.content[0].text.strip()
raw = re.sub(r"^```json|```$", "", raw, flags=re.MULTILINE).strip()
try:
return json.loads(raw)
except Exception:
return {}
# ─── 批量处理入口 ─────────────────────────────────────────
def batch_parse(directory: str) -> list:
"""批量解析目录下所有报销单"""
supported = {".pdf", ".jpg", ".jpeg", ".png", ".heic", ".xlsx", ".xls"}
files = [f for f in Path(directory).iterdir()
if f.is_file() and f.suffix.lower() in supported]
results = []
for f in files:
print(f" 解析:{f.name}", file=sys.stderr, flush=True)
result = parse_file(str(f))
results.append(result)
return results
if __name__ == "__main__":
target = sys.argv[1] if len(sys.argv) > 1 else str(Path.home() / "finance/reports")
if Path(target).is_dir():
results = batch_parse(target)
else:
results = [parse_file(target)]
print(json.dumps(results, ensure_ascii=False, indent=2))
5.2 Skill 描述文件
---
name: expense-parser
description: 解析报销单文件,提取结构化数据。支持 PDF、图片(手机拍照)、Excel 格式。当用户要求处理报销单、读取报销文件、提取报销信息时调用此技能。
metadata: {"openclaw":{"emoji":"🧾","requires":{"bins":["python3"]}}}
---
# Expense Parser — 报销单解析
## 功能
从各类格式的报销单(PDF/图片/Excel)中提取:申请人、部门、日期、金额、费用类型、是否有发票等字段。
## 调用方式
**解析单个文件:**
```bash
python3 {baseDir}/parse.py /path/to/报销单.pdf
批量解析目录(输出 JSON 到 stdout):
python3 {baseDir}/parse.py /path/to/reports/
输出格式
返回 JSON 数组,每条记录包含:
applicant:申请人date:日期(YYYY-MM-DD)amount:金额(数字)category:费用类型has_receipt:是否有发票source_file:原始文件名
注意
图片/扫描件 PDF 会调用视觉模型识别,耗时约 3-5 秒/张。
---
## 六、Skill 2:expense-checker(金额核对)
有了结构化数据,下一步是对照政策逐条审核。这个 Skill 的关键是:**规则可配置,代码不硬编码**。
```python
# ~/.openclaw/skills/expense-checker/check.py
import sys
import json
from pathlib import Path
from datetime import datetime
from dateutil.parser import parse as parse_date
POLICY_PATH = Path.home() / "finance/policy/reimbursement_policy.json"
def load_policy() -> dict:
with open(POLICY_PATH, encoding="utf-8") as f:
return json.load(f)
def check_record(record: dict, policy: dict) -> dict:
"""对单条报销记录进行合规检查"""
issues = []
warnings = []
category = record.get("category", "其他")
amount = record.get("amount")
has_receipt = record.get("has_receipt", False)
expense_date = record.get("date")
cat_policy = policy["categories"].get(category, {})
global_rules = policy["global_rules"]
# ── 规则 1:金额上限检查 ─────────────────────────────
limit_key = {
"餐饮": "per_person_limit",
"交通": "per_trip_limit",
"差旅": "daily_hotel_limit",
"办公": "single_limit",
}.get(category)
if limit_key and limit_key in cat_policy and amount is not None:
limit = cat_policy[limit_key]
attendees = record.get("attendees_count") or 1
effective_limit = limit * attendees if category == "餐饮" else limit
if amount > effective_limit:
issues.append({
"type": "超出限额",
"detail": (
f"{category}费限额 ¥{effective_limit}({attendees}人 × ¥{limit}),"
f"实报 ¥{amount},超出 ¥{round(amount - effective_limit, 2)}"
if category == "餐饮"
else f"{category}费限额 ¥{limit},实报 ¥{amount},超出 ¥{round(amount - limit, 2)}"
),
"severity": "error"
})
# ── 规则 2:发票要求 ─────────────────────────────────
if cat_policy.get("requires_receipt") and not has_receipt:
issues.append({
"type": "缺少发票",
"detail": f"{category}报销须附发票或收据",
"severity": "error"
})
# ── 规则 3:增值税发票建议 ───────────────────────────
vat_threshold = global_rules.get("vat_invoice_required_above", 500)
if (amount or 0) > vat_threshold and has_receipt:
warnings.append({
"type": "建议附增值税发票",
"detail": f"金额超过 ¥{vat_threshold},建议附增值税专用发票便于公司抵扣",
"severity": "warning"
})
# ── 规则 4:报销时效性检查 ───────────────────────────
if expense_date:
try:
expense_dt = parse_date(expense_date)
days_elapsed = (datetime.now() - expense_dt).days
max_days = global_rules.get("max_days_after_expense", 30)
if days_elapsed > max_days:
issues.append({
"type": "超期报销",
"detail": f"报销单日期 {expense_date},距今 {days_elapsed} 天,超出 {max_days} 天时限",
"severity": "error"
})
except Exception:
warnings.append({
"type": "日期格式异常",
"detail": f"无法解析日期:{expense_date}",
"severity": "warning"
})
# ── 规则 5:差旅出行信息 ────────────────────────────
if category == "差旅" and not record.get("trip_destination"):
warnings.append({
"type": "缺少出差信息",
"detail": "差旅报销建议注明目的地及出差事由",
"severity": "warning"
})
# ── 规则 6:餐饮人员名单 ────────────────────────────
if category == "餐饮":
attendees = record.get("attendees_count") or 0
if attendees > 3 and cat_policy.get("requires_attendee_list"):
warnings.append({
"type": "建议附参会人员名单",
"detail": f"餐饮招待 {attendees} 人,超过 3 人建议附人员名单",
"severity": "warning"
})
status = "通过" if not issues else "异常"
return {
**record,
"check_status": status,
"issues": issues,
"warnings": warnings,
"checked_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
}
def batch_check(records: list) -> dict:
"""批量核查,返回汇总统计"""
policy = load_policy()
checked = [check_record(r, policy) for r in records]
passed = [r for r in checked if r["check_status"] == "通过"]
failed = [r for r in checked if r["check_status"] == "异常"]
total_amount = sum(r.get("amount") or 0 for r in checked)
passed_amount = sum(r.get("amount") or 0 for r in passed)
by_category = {}
for r in checked:
cat = r.get("category", "其他")
if cat not in by_category:
by_category[cat] = {"count": 0, "amount": 0, "issues": 0}
by_category[cat]["count"] += 1
by_category[cat]["amount"] += r.get("amount") or 0
if r["check_status"] == "异常":
by_category[cat]["issues"] += 1
return {
"policy_version": policy["version"],
"summary": {
"total": len(checked),
"passed": len(passed),
"failed": len(failed),
"total_amount": round(total_amount, 2),
"passed_amount": round(passed_amount, 2),
"pending_amount": round(total_amount - passed_amount, 2),
},
"by_category": by_category,
"records": checked,
}
if __name__ == "__main__":
raw_json = sys.stdin.read() if not sys.stdin.isatty() else "[]"
records = json.loads(raw_json)
result = batch_check(records)
print(json.dumps(result, ensure_ascii=False, indent=2))
SKILL.md
---
name: expense-checker
description: 按公司报销政策核对报销记录,标记超标、缺票据、超期等异常情况,输出审核结果。需要先用 expense-parser 解析报销单数据。
metadata: {"openclaw":{"emoji":"✅","requires":{"bins":["python3"]}}}
---
# Expense Checker — 报销合规核查
## 功能
对照 ~/finance/policy/reimbursement_policy.json 中的报销政策,逐条审核:
- 金额是否超出各类型限额
- 是否附有发票/收据
- 是否在报销时效内(默认 30 天)
- 餐饮是否超员未附名单
- 差旅是否有出行信息
## 调用方式
接收 expense-parser 的 JSON 输出(通过管道传入):
```bash
python3 {baseDir}/../expense-parser/parse.py ~/finance/reports/ | \
python3 {baseDir}/check.py
输出
返回包含 summary(汇总统计)和 records(逐条核查结果)的 JSON。
每条 record 新增字段:
check_status:通过 / 异常issues:错误列表(severity: error)warnings:警告列表(severity: warning)
---
## 七、Skill 3:ledger-writer(生成台账)
最后一步,把核查结果写入格式化的 Excel 台账。台账分三个 Sheet,设计思路来自财务同学的实际工作习惯:
- **汇总看板**:领导一眼扫过去就看出本月大概
- **明细台账**:全量记录,通过的绿色,异常的红色
- **异常清单**:单独列出问题记录,方便处理退回
```python
# ~/.openclaw/skills/ledger-writer/write_ledger.py
import sys
import json
from pathlib import Path
from datetime import datetime
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
OUTPUT_DIR = Path.home() / "finance/output"
# ─── 样式常量 ────────────────────────────────────────────
CLR_HEADER = "1F4E79"
CLR_PASSED = "E2EFDA"
CLR_FAILED = "FCE4D6"
CLR_WARNING = "FFF2CC"
CLR_SUMMARY = "DEEAF1"
def _border():
s = Side(style="thin", color="BFBFBF")
return Border(left=s, right=s, top=s, bottom=s)
def _header_cell(cell, bg=CLR_HEADER):
cell.font = Font(bold=True, color="FFFFFF", name="微软雅黑", size=10)
cell.fill = PatternFill("solid", fgColor=bg)
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
cell.border = _border()
# ─── 汇总 Sheet ──────────────────────────────────────────
def _write_summary(ws, summary: dict, by_category: dict, now: datetime):
for col, w in enumerate([22, 18, 18, 18], 1):
ws.column_dimensions[get_column_letter(col)].width = w
ws.merge_cells("A1:D1")
c = ws["A1"]
c.value = f"📊 {now.strftime('%Y年%m月')} 员工报销汇总台账"
c.font = Font(bold=True, size=14, name="微软雅黑", color="1F4E79")
c.alignment = Alignment(horizontal="center", vertical="center")
ws.row_dimensions[1].height = 38
ws["A2"] = f"生成时间:{now.strftime('%Y-%m-%d %H:%M')}"
ws["A2"].font = Font(color="999999", size=9, italic=True)
ws.row_dimensions[2].height = 16
# 指标卡
row = 4
for label, val, bg in [
("总报销笔数", f"{summary['total']} 笔", None),
("✅ 审核通过", f"{summary['passed']} 笔", CLR_PASSED),
("❌ 存在异常", f"{summary['failed']} 笔", CLR_FAILED),
("─── 金额 ───", "", None),
("申报总金额", f"¥{summary['total_amount']:,.2f}", None),
("通过金额", f"¥{summary['passed_amount']:,.2f}", CLR_PASSED),
("待处理金额", f"¥{summary['pending_amount']:,.2f}", CLR_FAILED),
]:
if label.startswith("─"):
row += 1
continue
ws.cell(row=row, column=1, value=label).font = Font(name="微软雅黑", size=10)
vc = ws.cell(row=row, column=2, value=val)
vc.font = Font(name="微软雅黑", size=11, bold=True)
if bg:
for col in (1, 2):
ws.cell(row=row, column=col).fill = PatternFill("solid", fgColor=bg)
row += 1
# 分类统计表
row += 1
for col, h in enumerate(["费用类型", "笔数", "合计金额(元)", "异常笔数"], 1):
_header_cell(ws.cell(row=row, column=col, value=h))
row += 1
for cat, s in by_category.items():
ws.cell(row=row, column=1, value=cat)
ws.cell(row=row, column=2, value=s["count"])
ws.cell(row=row, column=3, value=f"¥{s['amount']:,.2f}")
ec = ws.cell(row=row, column=4, value=s["issues"])
if s["issues"] > 0:
ec.fill = PatternFill("solid", fgColor=CLR_FAILED)
ec.font = Font(bold=True, color="C00000", name="微软雅黑")
for col in range(1, 5):
c = ws.cell(row=row, column=col)
c.border = _border()
c.alignment = Alignment(horizontal="center")
if not c.font or not c.font.bold:
c.font = Font(name="微软雅黑", size=10)
row += 1
# ─── 明细 Sheet ──────────────────────────────────────────
def _write_detail(ws, records: list):
headers = ["序号", "申请人", "部门", "日期", "费用类型",
"金额(元)", "附发票", "核查状态", "问题说明", "文件来源"]
widths = [6, 10, 12, 12, 10, 12, 8, 10, 45, 25]
for col, (h, w) in enumerate(zip(headers, widths), 1):
ws.column_dimensions[get_column_letter(col)].width = w
_header_cell(ws.cell(row=1, column=col, value=h))
ws.row_dimensions[1].height = 28
ws.freeze_panes = "A2"
for i, r in enumerate(records, 1):
issues_txt = ";".join(
x["detail"] for x in r.get("issues", []) + r.get("warnings", [])
) or "—"
row_data = [
i, r.get("applicant","—"), r.get("department","—"),
r.get("date","—"), r.get("category","—"),
r.get("amount"), "✓" if r.get("has_receipt") else "✗",
r.get("check_status","—"), issues_txt, r.get("source_file","—"),
]
rn = i + 1
for col, val in enumerate(row_data, 1):
c = ws.cell(row=rn, column=col, value=val)
c.border = _border()
c.font = Font(name="微软雅黑", size=9)
c.alignment = Alignment(vertical="center", wrap_text=(col == 9))
bg = CLR_PASSED if r.get("check_status") == "通过" else (
CLR_FAILED if r.get("check_status") == "异常" else None)
if bg:
for col in range(1, len(headers) + 1):
ws.cell(row=rn, column=col).fill = PatternFill("solid", fgColor=bg)
ws.row_dimensions[rn].height = 22
# 合计行
tr = len(records) + 2
ws.cell(row=tr, column=1, value="合计").font = Font(bold=True, name="微软雅黑")
total = sum(r.get("amount") or 0 for r in records)
tc = ws.cell(row=tr, column=6, value=total)
tc.font = Font(bold=True, name="微软雅黑", color="1F4E79")
tc.fill = PatternFill("solid", fgColor=CLR_SUMMARY)
tc.number_format = '#,##0.00'
# ─── 异常 Sheet ──────────────────────────────────────────
def _write_issues(ws, anomalies: list):
if not anomalies:
c = ws["A1"]
c.value = "✅ 本期无异常报销记录"
c.font = Font(color="70AD47", bold=True, size=12)
return
headers = ["申请人", "日期", "金额(元)", "费用类型", "异常类型", "异常详情", "文件来源"]
widths = [10, 12, 14, 10, 14, 55, 25]
for col, (h, w) in enumerate(zip(headers, widths), 1):
ws.column_dimensions[get_column_letter(col)].width = w
_header_cell(ws.cell(row=1, column=col, value=h), bg="C00000")
ws.row_dimensions[1].height = 28
ws.freeze_panes = "A2"
row = 2
for r in anomalies:
for issue in r.get("issues", []):
for col, val in enumerate([
r.get("applicant","—"), r.get("date","—"), r.get("amount"),
r.get("category","—"), issue["type"], issue["detail"],
r.get("source_file","—"),
], 1):
c = ws.cell(row=row, column=col, value=val)
c.border = _border()
c.font = Font(name="微软雅黑", size=9)
c.fill = PatternFill("solid", fgColor=CLR_FAILED)
c.alignment = Alignment(vertical="center", wrap_text=(col == 6))
ws.row_dimensions[row].height = 22
row += 1
# ─── 主函数 ──────────────────────────────────────────────
def write_ledger(data: dict, output_path: str = None) -> str:
records = data["records"]
summary = data["summary"]
by_cat = data.get("by_category", {})
now = datetime.now()
if output_path is None:
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
output_path = str(OUTPUT_DIR / f"报销台账_{now.strftime('%Y_%m')}.xlsx")
wb = openpyxl.Workbook()
ws1 = wb.active; ws1.title = "📊 汇总"
ws2 = wb.create_sheet("📋 明细台账")
ws3 = wb.create_sheet("⚠️ 异常清单")
_write_summary(ws1, summary, by_cat, now)
_write_detail(ws2, records)
_write_issues(ws3, [r for r in records if r.get("check_status") == "异常"])
wb.save(output_path)
return output_path
if __name__ == "__main__":
raw = sys.stdin.read() if not sys.stdin.isatty() else "{}"
data = json.loads(raw)
path = write_ledger(data)
s = data.get("summary", {})
print(f"✅ 台账已生成:{path}")
print(f" 通过 {s.get('passed',0)} 笔(¥{s.get('passed_amount',0):,.2f})"
f" / 异常 {s.get('failed',0)} 笔(¥{s.get('pending_amount',0):,.2f})")
八、串联三个 Skill:一句话触发全流程
三个 Skill 各就各位,现在需要让 OpenClaw 知道在什么情况下把它们串联起来。新建一个"主控 Skill"来描述这个组合工作流:
---
name: finance-workflow
description: 财务报销处理完整工作流:自动解析报销单、核对金额、生成台账。当用户说"处理报销"、"生成台账"、"核对报销单"等时调用此工作流。
metadata: {"openclaw":{"emoji":"💼"}}
---
# Finance Workflow — 财务报销全流程
## 触发词示例
- "处理本月报销单"
- "帮我核对 reports 里的文件,生成台账"
- "有多少报销单需要处理"
## 标准执行步骤
**Step 1:解析所有报销单**
```bash
python3 ~/.openclaw/skills/expense-parser/parse.py ~/finance/reports/ \
> /tmp/oc_parsed.json 2>&1
echo "解析完成:$(python3 -c "import json; d=json.load(open('/tmp/oc_parsed.json')); print(len(d),'条记录')")"
Step 2:合规核查
cat /tmp/oc_parsed.json | \
python3 ~/.openclaw/skills/expense-checker/check.py \
> /tmp/oc_checked.json
python3 -c "
import json; d=json.load(open('/tmp/oc_checked.json'))['summary']
print(f'核查完成:通过 {d[\"passed\"]} 条(¥{d[\"passed_amount\"]:,.2f}),异常 {d[\"failed\"]} 条')
"
Step 3:生成台账
cat /tmp/oc_checked.json | \
python3 ~/.openclaw/skills/ledger-writer/write_ledger.py
汇报格式
执行完成后,向用户报告:
- 处理了哪个目录,共多少个文件
- 通过/异常各多少条,金额分别是多少
- 台账的保存路径
- 如有异常,逐一列出(谁、什么类型的问题、金额)
- 询问是否需要起草退回通知邮件
追问支持
用户追问某人的具体情况时,从 /tmp/oc_checked.json 读取对应记录详情回答。
---
## 九、实际对话演示
完成配置后,打开 Telegram,你的 OpenClaw 就能这样工作了。
---
**场景一:月末例行处理**
> 🧑 **你(Telegram)**:帮我处理一下本月的报销,reports 目录下都是本月的
> 🦞 **OpenClaw**:好的,开始处理 ~/finance/reports/ 目录……
>
> 📄 正在解析(共 18 个文件)
> — PDF 文件 12 个(8 个可读文本,4 个扫描件需视觉识别)
> — Excel 4 个,图片 2 个
>
> ✅ 解析完成,共提取 18 条报销记录
>
> 🔍 按 2026-Q1 报销政策核查中……
>
> **核查结果:**
>
> | | 笔数 | 金额 |
> |---|---|---|
> | ✅ 通过 | 14 | ¥12,840 |
> | ❌ 异常 | 4 | ¥3,210 |
>
> **4 条异常明细:**
> 1. **张三**《差旅报销0315.pdf》— 酒店 ¥680/晚,超限额 ¥500,超出 ¥180
> 2. **李四**《餐饮招待0318.xlsx》— 缺少餐饮发票
> 3. **李四**《交通费0302.pdf》— 报销日期距今 38 天,超 30 天时限
> 4. **王五**《办公用品0320.jpg》— 单笔 ¥1,350 超限额 ¥1,000,需部门负责人审批
>
> 📊 台账已生成:~/finance/output/报销台账_2026_03.xlsx
> (含汇总看板、明细台账、异常清单三个 Sheet)
>
> 需要我帮你起草退回通知给这 4 位同事吗?
---
**场景二:追问异常细节**
> 🧑 **你**:张三差旅那个,具体情况说一下
> 🦞 **OpenClaw**:张三《差旅报销0315.pdf》详情:
>
> - 出差目的地:上海
> - 费用日期:2026-03-15
> - 酒店费用:¥680(公司差旅标准上限 ¥500/晚)
> - 超出金额:¥180
>
> **处理建议:** 有两个选择——
> ① 张三补充差异原因说明 + 部门负责人特批,可全额报销
> ② 或本人自付超标部分 ¥180,按 ¥500 报销
---
**场景三:起草退回邮件**
> 🧑 **你**:帮我给李四写一封退回邮件,两笔问题都说清楚
> 🦞 **OpenClaw**:
>
> **主题**:【财务退回】3月报销单需补充材料(餐饮招待 + 交通费)
>
> 李四,你好,
>
> 经财务审核,你本月提交的以下两笔报销存在问题,请补充材料后重新提交:
>
> **① 餐饮招待(2026-03-18,¥850)**
> 问题:未附餐饮发票或收据。请补附正规发票后重新提交。
>
> **② 交通费(2026-03-02,¥260)**
> 问题:报销单日期为 3月2日,距今已 38 天,超过公司 30 天报销时限。请附直属主管签字说明延迟原因,提交后由财务总监审批是否受理。
>
> 请在 **2026年4月5日前** 重新提交,逾期将无法受理本期报销。如有疑问请联系财务部。
>
> 此邮件需要发送吗?(我可以帮你直接发到李四的工作邮箱)
---
## 十、几个值得注意的细节
### 10.1 数据安全
财务数据高度敏感,这套方案的数据流向需要清楚:
- **文字报销单**(PDF 可读文本、Excel):全程本地处理,零外发
- **图片/扫描件**:会调用 Claude API,图片内容经过加密传输到 Anthropic
如果公司要求 **100% 本地**,可以换用 Ollama 的多模态模型处理图片识别:
```bash
ollama pull minicpm-v # 中文多模态模型,图片识别效果不错
然后修改 extract_fields_from_image_bytes 函数,改为调用本地 Ollama 的视觉接口。
10.2 定时自动处理
OpenClaw 支持 Cron 定时任务,设置每月最后一个工作日自动触发:
// ~/.openclaw/openclaw.json
{
"cron": [
{
"expression": "0 9 28-31 * 1-5",
"message": "今天是月末工作日,请自动处理 ~/finance/reports/ 下本月所有报销单,生成台账后告知我结果"
}
]
}
10.3 多人团队使用
如果是财务部门共用,把 OpenClaw 部署在团队服务器(Linux 云主机)上:
# 服务器部署示例(Hetzner / 腾讯云 CVM)
curl -fsSL https://openclaw.ai/install.sh | bash
openclaw onboard
# 连接公司 Telegram Bot,设置审批白名单
每位有权限的同事都能通过 Telegram 触发处理,台账统一存到服务器的共享目录。
10.4 异常记录追踪
如果希望记录每次处理的历史(不只是最新一次),可以让 ledger-writer 在输出文件名里加时间戳,同时把每次的汇总数据 append 到一个 history.jsonl 文件中,方便日后分析哪个部门报销异常率最高。
十一、完整项目结构
~/.openclaw/skills/
├── expense-parser/
│ ├── SKILL.md
│ └── parse.py
├── expense-checker/
│ ├── SKILL.md
│ └── check.py
├── ledger-writer/
│ ├── SKILL.md
│ └── write_ledger.py
└── finance-workflow/
└── SKILL.md
~/finance/
├── reports/ ← 把报销单文件放这里(PDF/图片/Excel 均可)
├── output/ ← 台账自动输出到这里
└── policy/
└── reimbursement_policy.json ← 报销政策,财务主管维护这个文件
三步启动:
# 1. 安装 Python 依赖
pip install openpyxl pymupdf pillow anthropic python-dateutil rich
# 2. 创建报销政策配置文件
mkdir -p ~/finance/policy
# 把上文的 reimbursement_policy.json 存入此目录
# 3. 把报销单文件放进 ~/finance/reports/
# 然后在 Telegram 发一句:
# "帮我处理本月报销单,生成台账"
总结
我们做了什么:
一套以前需要财务专员花费大半天的月末工作——读单 → 核对 → 录台账——现在被拆解成三个各司其职的 OpenClaw Skills,整个流程由一句聊天消息触发,几分钟内完成。
这套方案有三个设计原则值得拿出来说:
原则一:规则写在配置里,而不是硬编码在代码里。 报销政策会变,限额会调整,如果规则写死在代码里,每次政策变动都要改代码、重新部署。把规则写成 JSON 配置,财务主管打开文件改数字,下次处理时自动生效。
原则二:每个 Skill 只做一件事,接口是 stdin/stdout JSON。 解析归解析,核对归核对,生成台账归生成台账。三个 Skill 完全解耦,单独测试方便,串联使用灵活。哪天你想换一个更好的 PDF 解析库,只需要改 expense-parser,其他两个完全不用动。
原则三:AI 负责脏活,规则负责核心判断。 报销单格式识别是脏活(各种奇葩格式),让视觉模型去处理。但"这笔钱是否合规"是关键业务逻辑,必须是可预期的规则判断,不能让 AI 去"猜"——模型判断是否超标可能因为提示词措辞而产生偏差,写死的数字不会。
OpenClaw 真正改变的是工具的触发方式:你不需要打开某个专用系统,不需要记住在哪个菜单里,你只需要在你本来就一直开着的 Telegram 窗口里说一句话,然后该干的活它去干。
🔗 OpenClaw 官网:https://openclaw.ai
📦 Skills 市场:https://clawhub.ai
📖 官方文档:https://docs.openclaw.ai
💬 社区:https://discord.com/invite/clawd
本系列文章:
- 上篇:《当 OpenClaw 遇上 RAG:让 AI 基于你的企业知识库回答问题》
- 本篇:《OpenClaw 财务场景实战:自动读取报销单、核对金额、生成台账》
- 下篇:敬请期待……
觉得有用的话点个赞👍,有问题欢迎评论区交流。
更多推荐




所有评论(0)