---
name: invoice-automation
description: >-
  发票自动化的两条通道及其正确参数：邮件规则（新发票邮件自动交给本 Agent 增量入账）与
  定时任务（每月自动重建台账并出报告）。含条件/动作取值、prompt 自包含要求、
  无人值守的审批取舍与常见坑。Use when 用户要「以后新发票自动入账」「每月 1 号自动出台账」
  「设个定时」「配个邮件规则」，或已配的自动化没触发需要排查时。
  Also covers mail rules, scheduled jobs and unattended invoice processing.
version: 1.0.0
type: procedural
risk_level: medium
status: enabled
tags:
  - invoice
  - automation
  - schedule
  - mail-rule
metadata:
  category: automation
  i18n:
    default_locale: en-US
    source_locale: zh-CN
    locales:
      - zh-CN
      - en-US
    zh-CN:
      name: 发票自动化
      short_desc: 邮件规则与定时任务的正确参数与常见坑
    en-US:
      name: Invoice Automation
      short_desc: Mail rules and scheduled jobs — correct parameters and known pitfalls
  requires:
    tools:
      - MailOperations
      - ManageSchedule
---

# 发票自动化

## L0

只有两条通道：**邮件规则**（新发票邮件到达时把这封邮件交给本 Agent 增量入账）和 **定时任务**（每月按时重建台账与报告）。

**心跳不能用来做这件事**——心跳执行时只注入 `HeartbeatRespond` 一个工具，读邮件、写文件、解析全都够不着。心跳只能当通知层。

配置任何一条之前先跟用户把「会发生什么、会不会打扰他」讲清楚，他同意了再动手。

## L1

### 通道一 · 邮件规则

新邮件命中条件时，把这封邮件交给本 Agent 处理一轮。

```
MailOperations{
  path: '/api/rules',
  method: 'POST',
  body: {
    name: '发票邮件自动入账',
    description: '带附件且主题或正文含发票关键词的邮件，交给发票整理助手增量入账',
    enabled: true,
    provider: 'gmail',                // 省略 provider + email 则为全局规则
    email: '<用户邮箱>',
    conditionLogic: 'and',
    conditions: [
      { field: 'has_attachment', operator: 'is_true',  value: '' },
      { field: 'subject',        operator: 'contains', value: '发票' }
    ],
    actions: [
      { type: 'agent_handle', value: 'invoice-organizer' }
    ],
    stopOnMatch: false
  }
}
```

**取值只能从下面这些里选，写错的会被静默判为不匹配：**

| 位置 | 合法取值 |
| --- | --- |
| `conditions[].field` | `from` / `to` / `subject` / `body` / `has_attachment` |
| `conditions[].operator` | `contains` / `not_contains` / `equals` / `not_equals` / `starts_with` / `ends_with` / `matches_regex` / `is_true` / `is_false` |
| `conditionLogic` | `and` / `or` |
| `actions[].type` | 见下表 |

正则操作符叫 **`matches_regex`**，不是 `regex`。`is_true` / `is_false` 只用于 `has_attachment`，`value` 传空串。

两个实现细节，写规则前必须知道：

- **`has_attachment` 配 `is_true` 之外的任何 operator，都会静默等价于 `is_false`。** 求值器在
  `has_attachment` 这一支直接短路成「`is_true` ? 有附件 : 没有附件」，写成 `contains` / `equals`
  一律落到「没有附件」那一侧，规则会精确地反着匹配，且不报任何错。
- **`matches_regex` 做不到大小写敏感。** 目标值先被整体转小写，正则又固定带 `i` 标志，所以模式里
  写大写字母只会让它匹配不到自己本来能匹配的东西。中文不受影响；要区分 `INVOICE` 与 `invoice`
  这类场景，规则层做不了，只能在 Agent 拿到邮件之后自己判断。

**动作里有一半是空实现，别教用户用：**

| 动作 | 状态 |
| --- | --- |
| `agent_handle` · `add_label` · `remove_label` · `mark_as_read` · `mark_as_unread` · `auto_reply` · `delete` | 真实现 |
| `move_to_folder` · `forward_to` · `star` · `archive` | **空实现**，只写一行日志，什么都不会发生 |

`delete` 虽然是真实现，但本 Agent 不碰用户的邮件——不要把它写进任何规则。

**一条规则要覆盖多种关键词**，用 `conditionLogic: 'or'` 加多个 `subject`/`body` 条件，或者用一个 `matches_regex`：

```
{ field: 'subject', operator: 'matches_regex', value: '发票|invoice|電子發票|行程单|报销' }
```

（匹配前字段值会被转成小写，中文不受影响。）

**触发后你会收到什么。** 你拿到的是这封邮件的元数据——发件人、主题、时间、邮箱账户、邮件 ID、正文摘要——**没有附件清单**。所以第一件事是回头取详情拿 `attachments[]`，然后按 `invoice-workflow` 的增量模式跑：

| provider | 取详情 |
| --- | --- |
| Gmail | `GET /api/gmail/messages/{mailId}?email=..` |
| Outlook | `GET /api/outlook/message?id={mailId}&email=..` |
| IMAP | `GET /api/imap/messages/{uid}?email=..&folder=..` |

**规则的触发范围跟着轮询走，三家不一样：**

| provider | 增量轮询实际覆盖 | 发票归到别的文件夹会怎样 |
| --- | --- | --- |
| Gmail | `history.list(historyTypes:['messageAdded'])`，**不带 label 过滤 → 整个邮箱** | 照样触发 |
| Outlook | `/me/messages/delta`，**整个邮箱**（只有 delta 不可用时的降级轮询才只拉 inbox） | 照样触发 |
| IMAP | **硬编码 `INBOX`** | **不会触发**，要如实告诉用户 |

所以「规则只覆盖收件箱」这句话**只对 IMAP 成立**，不要对 Gmail / Outlook 用户这么说。
IMAP 用户需要 `POST /api/imap/messages/fetch?folder=<名字>` 主动补拉，而**补拉不重放规则**——
补回来的邮件要在对话里让 Agent 走一遍增量入账。

规则的增删查改：`GET /api/rules`（可带 `?provider=..&email=..`）、`PUT /api/rules/{ruleId}`、`DELETE /api/rules/{ruleId}`、`POST /api/rules/{ruleId}/toggle`。配完以后自己 `GET` 一次确认写进去了，把规则 id 告诉用户。

### 通道二 · 定时任务

```
ManageSchedule{
  action: 'create',
  display_name: '每月发票台账',
  trigger_type: 'cron',
  trigger_value: '0 9 1 * *',
  description: '每月 1 号 9:00 重建上月台账并生成月度报告',
  prompt: '<自包含的完整指令，见下>'
}
```

`trigger_type` 取 `delay` / `at` / `interval` / `cron`：`delay` 与 `interval` 用 ISO Duration（`PT30M`、`P1D`），`at` 用带时区的 ISO DateTime，**`cron` 只接受 5 段**（分 时 日 月 周），写 6 段会被拒。

**`prompt` 必须自包含。** 定时任务到期时开的是一个**新会话，不继承当前对话的任何上下文**——它不知道工作目录在哪、不知道台账叫什么、不知道你们刚才聊过什么。prompt 里要写全：

```
加载技能 invoice-workflow 与 invoice-ledger。
工作目录：<绝对路径>/发票
读取 .index/ledger.json，取上一个自然月（按开票日期归属）的记录，
全量重建 台账.xlsx（依赖不可用时改出带 UTF-8 BOM 的 CSV），重建前先备份。
再按 invoice-ledger 的模板写 报告/<上月 YYYY-MM>.md。
完成后用 SendUserMessage 把台账文件和一句话摘要发给用户；
异常项（疑似重复 / 解析失败 / 待复核 / 抬头不符）逐条列在摘要里。
本次不扫描邮箱。
```

最后那句「本次不扫描邮箱」很重要——不写的话每月 1 号会顺带跑一次全量收集，既慢又可能重复打扰。要「先收再出账」就明确写进 prompt。

`create` / `update` / `delete` 一定会弹审批卡。`list` / `get` 不弹。定时任务创建时会固化一份权限快照，之后每次执行都与当时的授权求交——所以**在一个工具齐全的正常会话里创建**它，不要在能力受限的上下文里建。

### 无人值守的代价（必须如实说明）

`MailOperations` / `Write` / `ExportDocument` / `ManageSchedule` / `ManageWorkDirs` 都需要确认。默认的 `ai-approve` 模式下每张卡有 30 秒真人窗口——用户睡着时定时任务会卡在审批上。

想要真正的无人值守，用户需要把本 Agent 的执行审批模式改成 `allow-all`。**这一步必须由用户自己在界面上做**，你只负责讲清楚代价：之后本 Agent 的写文件与邮件调用都不再逐条询问。

另外：「总是允许」按钮对这些工具当前不生效，别让用户点了之后期待下次不弹。

### 建议的默认组合

用户说「以后自动帮我弄」时，默认给这一套，一次说清：

1. 邮件规则：带附件 + 主题/正文含发票关键词 → 交给本 Agent 增量入账（新票当天就进索引和归档）
2. 定时任务：`0 9 1 * *` → 每月 1 号重建上月台账 + 月度报告 + 发给用户
3. 提醒他：想完全不被打扰需要自己把审批模式调成 `allow-all`

不要默认加 `add_label` 或 `mark_as_read`——那会改动用户邮箱的可见状态，要单独问过。

## L2

### 排查：规则配了但没触发

按顺序查，不要跳步：

1. `GET /api/rules` 确认规则真的存在且 `enabled: true`
2. 看 `conditions` 的 `field` / `operator` 拼写——写错的取值不会报错，只会永远不匹配
3. **IMAP 账户**：确认那封邮件在 `INBOX`。别的文件夹要 `POST /api/imap/messages/fetch?folder=..`
   主动补拉，而补拉不会重放规则。Gmail / Outlook 不用查这一条（见上面的触发范围表）
4. 拿一封已知邮件跑 `POST /api/rules/execute`（body `{provider, email, mailId}`）看条件到底匹不匹配，
   或直接观察 `rule_failed` 广播。
   **不要用 `POST /api/rules/{ruleId}/test` 验证正则规则**——`/test` 走的是另一份内联的匹配实现，
   里面**根本没有 `matches_regex` 分支**，命中 default 恒返回 `matched: false`。一条工作正常的正则规则
   在 `/test` 里永远显示不匹配，照着它排查会把好规则判成坏规则。`/test` 只在纯 `contains` /
   `equals` 这类规则上可信
5. 确认邮件确实带附件——`has_attachment is_true` 判的是邮件级标志，Gmail 的内联签名图也算附件，
   所以这个条件比想象中宽；同时复查 operator **确实是 `is_true`**（其它 operator 会静默变成 `is_false`）

### 两个会让规则整条失效的错误码

`agent_handle` 动作失败时会广播 `rule_failed`。除了网络类错误，有两个码指向的是**配置**，
不修就永远不会触发，而且规则本身看起来完全正常（`enabled: true`、条件也对）：

| 错误码 | 什么时候出现 | 怎么修 |
| --- | --- | --- |
| `agent_handle_event_not_declared` | Agent 的 `agent.json` 开了 `webhooks.enabled`，却没有在 `webhooks.events` 里声明 `mail.received`。webhook 端点返回 400，**平台不会回退**到普通交办通道，规则直接失效 | 要么在 `webhooks.events` 里补上 `mail.received`（连同权限快照，见下面的「关于 webhook 事件」），要么把 `webhooks.enabled` 关掉走默认通道。**本 Agent 默认 `webhooks.enabled: false`，正是为了不踩这一条** |
| `mail_rule_agent_service_binding_mismatch` | 规则创建时绑定的 Agent Service 连接与当前不是同一个（换了实例、改了连接配置、切换过 Agent Service） | 存量规则会**全部**失效。把规则删掉重建一遍，重建后 `GET /api/rules` 确认 |

排查规则不触发时，这两个码比条件拼写更值得先看一眼——条件写错只是不匹配，这两个是整条通道断了。

### 排查：定时任务到点没动静

1. `ManageSchedule{action:'list', include_terminal:true}` 看状态。终态（`completed` / `failed` / `cancelled`）默认不显示，不加这个参数会以为任务消失了
2. `ManageSchedule{action:'get', schedule_id:'..'}` 看最近一次执行的结果
3. 卡在审批上是最常见的原因（见上面的「无人值守的代价」）
4. prompt 不自包含也很常见——新会话里 `<工作目录>` 之类的占位没被替换成真实路径，任务跑起来但找不到文件

### 关于 webhook 事件

除了上面那条交办通道，平台还有一条 `agent.json#webhooks` 的事件通道。本 Agent **刻意不预置**它：每个 webhook 事件都要带一份创建者的权限快照，而这份快照只能在用户自己的客户端里生成——市场条目里写一份空快照会让触发时所有工具都被剥掉，Agent 醒来却什么都做不了，比不配还糟。

用户如果自己在 agent.json 里配了 `webhooks.events["mail.received"]`，触发时 payload 的顶层键是 `provider` / `email` / `mailId` / `from` / `fromName` / `subject` / `bodyPreview` / `receivedAt` / `hasAttachments`，在 `prompt_template` 里用 `{{key}}` 引用（只支持顶层键，不支持嵌套路径），并且要把 `max_turns` 显式调大——默认只有 5，而「取详情 → 下附件 → 解析 → 写文件 → 更新索引 → 回执」轻松超过。

### 不要用心跳

工具说明里有一句「监控/巡检/定期检查变化应使用心跳系统」——对本 Agent 不成立。心跳执行时只注入 `HeartbeatRespond`，`MailOperations` / `Read` / `Write` 全都不可达，拿它做发票巡检只会得到一个什么也没做的回合。定期任务一律用 `ManageSchedule`。
