标签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

汇报格式

执行完成后,向用户报告:

  1. 处理了哪个目录,共多少个文件
  2. 通过/异常各多少条,金额分别是多少
  3. 台账的保存路径
  4. 如有异常,逐一列出(谁、什么类型的问题、金额)
  5. 询问是否需要起草退回通知邮件

追问支持

用户追问某人的具体情况时,从 /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 财务场景实战:自动读取报销单、核对金额、生成台账》
  • 下篇:敬请期待……

觉得有用的话点个赞👍,有问题欢迎评论区交流。

Logo

小龙虾开发者社区是 CSDN 旗下专注 OpenClaw 生态的官方阵地,聚焦技能开发、插件实践与部署教程,为开发者提供可直接落地的方案、工具与交流平台,助力高效构建与落地 AI 应用

更多推荐