mirror of
https://git.openapi.site/https://github.com/desirecore/market.git
synced 2026-09-05 19:23:51 +08:00
feat(invoice-organizer): 新增发票整理助手 Agent / add Invoice Organizer agent (#117)
新增官方 inline Agent「发票整理助手」(`invoice-organizer`)。
Adds an official inline Agent, **Invoice Organizer**, that turns
invoices scattered across
mailboxes and local folders into a reconcilable, reusable ledger.
## 它做什么 / What it does
七步固定流程,每一步幂等:**接入检查 → 收集 → 解析 → 去重 → 归档 → 台账 → 报告**。
- **收集**:从已接入的邮箱(Gmail / Outlook / IMAP)找出候选发票邮件,附件用 `MailOperations` 的
`save_to` 直接落盘,base64 不进模型上下文
- **解析**:OFD(三个结构化来源分别处理)、PDF 文字层、扫描件走视觉;每个字段带 `extractedBy` 与置信度
- **去重**:发票号码为主键,跨格式识别同一张票(PDF 与照片、重复下载);近重复不自动合并
- **归档**:`归档/年/月/YYYYMMDD_销售方_金额_发票号码.ext`,原件一个不动
- **台账**:xlsx 四张表(明细 / 月度汇总 / 按销售方汇总 / 异常),发票号码强制文本格式;
装不上 openpyxl 时降级为 UTF-8 BOM CSV
- **报告**:Markdown 月度报告,收尾固定报五个计数(范围 / 候选 / 成功 / 待复核 / 失败)
- **自动化**:邮件规则 `agent_handle` + `ManageSchedule` 定时出账
## 明确的能力边界 / Explicit boundaries
- **不做发票真伪查验**,也不暗示做过——只做形式校验与勾稽校验,给出官方查验平台入口让用户自己核验
- **不删除、不移动、不转发用户的邮件**
- **票面内容不外流**:不外发、不代发、不上传第三方接口或在线查验站点
- **不做汇率换算**;不承诺「已找全」,只报「在给定范围内找到 N 封候选、成功解析 M 张」
## 结构 / Structure
```
agents/invoice-organizer/
├── agent.json persona.md principles.md LICENSE
├── USAGE.zh-CN.md USAGE.en-US.md
├── catalog-metadata.v1.json # availability: listing-only(可安装化见下)
├── assets/avatar.webp
└── skills/
├── invoice-workflow/ # 七步总纲、目录布局、落盘顺序、去重主键、幂等
├── invoice-extract/ # 三种载体的解析细则、置信度分档、特殊票据
│ └── references/票面文本形态.md
├── invoice-ledger/ # 台账结构、人民币约定(覆盖 xlsx 技能的默认口径)
│ └── references/月度报告模板.md
└── invoice-automation/ # 邮件规则与定时调度的参数模板与排查
```
`manifest.json` 的 `stats.totalAgents` 3 → 4。
## 事实性核对 / Fact-checking
技能里引用的**每一个**端点 / 工具名 / 参数名 / 返回字段都对着 DesireCore 主仓库实现逐条核对过,
并经过一轮对抗式 review。review 抓到的、已修正的主要事实错误:
- OFD 一节原本只覆盖 2020 年式样;已改为按**三个来源**分述
(内嵌附件 / 2024 数电票的 `Tags/CustomTag.xml` 标引 / `DocInfo/CustomDatas`),
键名从 `Buyer/BuyerName` 改为真实的点号路径 `Buyer.BuyerName`,置信度按来源分档
- **`DocInfo/CustomDatas` 的「合计金额」是不含税金额**,误当 `totalAmount` 会让每张 2024
数电票少记税额
——已写成硬规则
- Gmail 本地缓存搜索的 `q` **实际只按主题过滤**(正文过滤在主题收窄之后才跑),
原文写成「搜主题与正文」会导致静默漏邮件
- 「轮询只覆盖收件箱」只对 IMAP 成立,Gmail / Outlook 是整个邮箱
- `POST /rules/{id}/test` 走另一份内联实现、**没有 `matches_regex` 分支**,
不能用它验证正则规则
- 邮件列表项里 Gmail / IMAP **是带** `attachments[]` 的,只有 Outlook 不带;
`labelIds` 是 Gmail 专有
## 真机验证 / Verified on a live instance
在 dev 实例上以一句话指令处理 31 个混合文件(数电票 / 旧版票 / OFD / 扫描件 / 行程单,
外加重复、近重复、作废、零额与 4 个非发票负样本):
- 23 张归档,字段与夹具 ground truth **逐条吻合**
- 4 个非发票**全部正确拒绝**并写明理由(施工许可证 / 对账单 / 技术服务合同 / 邮件通知)
- 4 个跨格式重复(3 张扫描件 + 1 次重复下载)**全部靠发票号码主键识破**,隔离而非删除
- 近重复正确未合并;作废票归档但不计入合计;低置信度定额发票标为待复核
- 台账 xlsx 四张表、报告含五个计数、`SendUserMessage` 带附件交付
- `待整理/` 31 个原件一个未动
## 校验 / Validation
```
validate_catalog_metadata.py --require-complete 0 error, agents=4, sidecars=74 exit=0
validate-i18n.py 0 error exit=0
translate.py --check exit=0
gen-collection-children.py --check exit=0
```
129 条 warning 全部来自 `skills/*` 的存量条目,改前改后一字不差,`invoice-organizer` 零命中。
另核实:`agent.json` 过 `marketAgentSchema`(详情页)与收窄后过
`agentConfigSchema`(安装),
persona / principles 的 6 个 canonical key 用平台真实解析器全部解得出,全树敏感信息扫描通过
(所有公司名 / 税号 / 银行账号均为 `示例`/`示范`/`虚构`/`样例` 前缀的合成值)。
## 后续 / Follow-up
本 PR 为 `availability: listing-only`。可安装化需要把 `agent.json#contentSource` 与
sidecar 的 `provenance.content` **逐字一致地** pin 到本 PR 的合并 commit,
并补 `governance.compliance` 与 `timestamps.reviewedAt`——那是紧接着的第二个 PR。
This commit is contained in:
232
agents/invoice-organizer/skills/invoice-automation/SKILL.md
Normal file
232
agents/invoice-organizer/skills/invoice-automation/SKILL.md
Normal file
@@ -0,0 +1,232 @@
|
||||
---
|
||||
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`。
|
||||
237
agents/invoice-organizer/skills/invoice-extract/SKILL.md
Normal file
237
agents/invoice-organizer/skills/invoice-extract/SKILL.md
Normal file
@@ -0,0 +1,237 @@
|
||||
---
|
||||
name: invoice-extract
|
||||
description: >-
|
||||
把单份票据文件解析成结构化字段:PDF(数电票 / 旧版增值税票 / 铁路电子客票 / 航空行程单)、
|
||||
OFD(优先读内嵌国标结构化 XML)、扫描件图片(视觉识别),含字段抽取规则、置信度评分、
|
||||
勾稽校验与失败处理。Use when 需要读出某个发票文件里的发票号码 / 开票日期 / 销售方 / 价税合计等字段,
|
||||
或解析报错、抽出来的字段不对、遇到加密 PDF / 扫描件 / OFD 时。
|
||||
Also covers invoice field extraction, OCR fallback and confidence scoring.
|
||||
version: 1.0.0
|
||||
type: procedural
|
||||
risk_level: low
|
||||
status: enabled
|
||||
tags:
|
||||
- invoice
|
||||
- extraction
|
||||
- pdf
|
||||
- ofd
|
||||
metadata:
|
||||
category: extraction
|
||||
i18n:
|
||||
default_locale: en-US
|
||||
source_locale: zh-CN
|
||||
locales:
|
||||
- zh-CN
|
||||
- en-US
|
||||
zh-CN:
|
||||
name: 发票解析
|
||||
short_desc: PDF / OFD / 扫描件的字段抽取规则与置信度评分
|
||||
en-US:
|
||||
name: Invoice Extraction
|
||||
short_desc: Field extraction rules and confidence scoring for PDF, OFD and scans
|
||||
requires:
|
||||
tools:
|
||||
- Read
|
||||
- FileDigest
|
||||
---
|
||||
|
||||
# 发票解析
|
||||
|
||||
## L0
|
||||
|
||||
三条路径,按可信度从高到低:**OFD 内嵌结构化数据 → PDF/OFD 文本层启发式抽取 → 视觉识别**。
|
||||
|
||||
走哪条由文件本身决定,不由你选。OFD 的结构化数据有三个来源、可信度并不一样,取值前先认出处。
|
||||
抽不齐必填字段就隔离,绝不用「常见格式」补全。
|
||||
|
||||
## L1
|
||||
|
||||
### 路径选择
|
||||
|
||||
| 文件 | 怎么做 | `extractedBy` | 置信度基线 |
|
||||
| --- | --- | --- | --- |
|
||||
| `.ofd` 有「内嵌附件」来源 | `Read` 它,取标注「来源: 内嵌附件」的那一段 | `ofd-xml` | **1.0** |
|
||||
| `.ofd` 只有「发票标引」来源 | 同上,取标注「来源: …CustomTag.xml」的那一段,**缺的字段回页文本补** | `ofd-xml` | 0.9 |
|
||||
| `.ofd` 只有 `DocInfo/CustomDatas` | 当线索用,**必须**与页文本交叉核对(尤其价税合计) | `text-layer` | 0.85 |
|
||||
| `.ofd` 三个来源都没有 | 用 `Read` 给出的逐页文本走文本层规则 | `text-layer` | 0.85 起 |
|
||||
| `.pdf` 有可用文字层 | `Read` 直接抽文本 | `text-layer` | 0.9 起 |
|
||||
| `.pdf` 无文字层,或文字层抽出的内容不足以判定 | 无文字层时 `Read` 自动渲染成图;有垃圾文字层时显式 `pdf_mode:"render"` + `pages` | `vision` | 0.7 起 |
|
||||
| `.jpg` / `.png` / `.webp` | 直接 `Read` 图片路径,你自己看 | `vision` | 0.7 起 |
|
||||
|
||||
不需要 Python,不需要安装任何东西,`Read` 一个工具全包。
|
||||
|
||||
**图片千万不要用 `UnderstandImage`**——那个工具只吃 HTTP URL,本地文件路径喂不进去。本地图片就是 `Read{file_path}`。
|
||||
|
||||
### OFD:三个结构化数据来源,先认出处再取值
|
||||
|
||||
OFD 是中国电子发票的两大法定载体之一。`Read` 一个 `.ofd` 返回三段:**文档概览**(页数、电子签章、内嵌附件清单、内嵌标引清单、文档自带元数据)、**内嵌结构化数据**、**逐页文本**。
|
||||
|
||||
结构化数据有**三个互不等价的来源**,`Read` 的输出里每一段都写明了出处。**先看出处,再决定怎么用**——三者的字段完整度和可信度差得很远,混为一谈会算错税额。
|
||||
|
||||
| 来源 | 在输出里长什么样 | 年代 | 有什么 | 缺什么 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **① 内嵌附件** | `## 内嵌结构化数据: … — 来源: 内嵌附件 Doc_0/Attachs/…` | 2020 年式样 | 字段最全:发票代码、号码、买卖双方名称/税号/地址电话/开户行、金额税额、逐行商品明细 | — |
|
||||
| **② 发票标引** | `## 内嵌结构化数据: 发票标引 (TypeID=…) — 来源: …CustomTag.xml,值由 ObjectRef 指向的页面文字对象解引用得到` | **2024 数电票 / 全电发票**(当前主流) | 发票号码、开票日期、买卖双方名称与税号、不含税金额、税额、价税合计、开票人、货物名称、税率、单价、数量、备注 | **发票代码、地址电话、开户行、多行明细的逐行结构**——只能回逐页文本找 |
|
||||
| **③ 文档自带元数据** | 只出现在 `## 文档概览` 的 `- 文档自带元数据 (DocInfo/CustomDatas):` 这一条 bullet 里,**从来不是** `## 内嵌结构化数据` 段 | 数电票常见 | 实测只有 6 个字段:发票号码、买卖方税号、合计金额、合计税额、开票日期 | **没有价税合计,也没有买卖方名称** |
|
||||
|
||||
三者可能同时出现、可能只有一两个、也可能一个都没有(那就只剩逐页文本)。
|
||||
|
||||
**键名是点号路径。** 展平器剥掉命名空间前缀、丢掉根元素、用 `.` 连接层级、同名兄弟加 `[序号]`。真实的键长这样:
|
||||
|
||||
```
|
||||
InvoiceNo = 24112000000000010001
|
||||
Buyer.BuyerName = 示例数字科技(北京)有限公司
|
||||
Seller.SellerTaxID = 91110105MA00P8L2RU
|
||||
TaxInclusiveTotalAmount = ¥4449.00
|
||||
GoodsInfos.GoodsInfo[1].Item = 餐费
|
||||
```
|
||||
|
||||
`Buyer/BuyerName`、`GoodsInfos/GoodsInfo[]` 这类斜杠写法**在输出里一个都不存在**,别按它匹配。来源 ① 与 ② 走同一套展平,键名同构,所以下表两种来源通用:
|
||||
|
||||
| 键 | 记录字段 | 哪些来源有 |
|
||||
| --- | --- | --- |
|
||||
| `InvoiceNo` | `invoiceNumber` | ①② |
|
||||
| `InvoiceCode` | `invoiceCode` | **只有 ①**(数电票本就没有发票代码,为空是正常的) |
|
||||
| `IssueDate` | `invoiceDate`(原文 `2024年05月28日` 中文格式,转成 `YYYY-MM-DD`) | ①② |
|
||||
| `InvoiceCheckCode` | `checkCode` | 只有 ① |
|
||||
| `Buyer.BuyerName` · `Buyer.BuyerTaxID` | `buyerName` · `buyerTaxId` | ①② |
|
||||
| `Seller.SellerName` · `Seller.SellerTaxID` | `sellerName` · `sellerTaxId` | ①② |
|
||||
| `TaxInclusiveTotalAmount` | `totalAmount`(价税合计) | ①② |
|
||||
| `TaxExclusiveTotalAmount` | `amountExcludingTax` | ①② |
|
||||
| `TaxTotalAmount` | `taxAmount` | ①② |
|
||||
| `GoodsInfos.GoodsInfo[n].{Item,Specification,MeasurementDimension,Price,Quantity,Amount,TaxScheme,TaxAmount}` | `items[]` | **只有 ①**;② 只有零散的 `Item` / `Price` / `Quantity`,凑不出逐行结构 |
|
||||
|
||||
#### 三条硬规则,每条都对应一种会把钱算错的写法
|
||||
|
||||
1. **`totalAmount` 只认 `TaxInclusiveTotalAmount`。** 标引式(来源 ②)的金额常带 `¥` 前缀——票面上「¥」和「4449.00」是两个页面文字对象,解引用后被按顺序拼成 `¥4449.00`。写进记录前把货币符号与千分位逗号剥掉,只留纯数字。
|
||||
|
||||
2. **绝不把 `DocInfo/CustomDatas` 的「合计金额」当 `totalAmount`。** 那是**不含税金额**。实测一张 2024 数电票:CustomDatas 写 `合计金额: 4197.17`、`合计税额: 251.83`,而同一张票的**价税合计是 ¥4449.00**——照抄就是每张票少记一个税额,还带着高置信度混进合计。CustomDatas 里压根没有价税合计这一项。正确对应:`合计金额` → `amountExcludingTax`,`合计税额` → `taxAmount`;`totalAmount` 去标引段取 `TaxInclusiveTotalAmount`,或回页文本取 `价税合计(大写)… (小写)¥xxxx`。两个都拿不到就按必填字段缺失隔离,不要拿合计金额顶替。
|
||||
|
||||
3. **只有来源 ① 配得上「别再从版面文字里猜字段」。** 来源 ② 拿得到四个必填字段,所以票不会被隔离——它的代价是**静默残缺**:缺的发票代码与逐行明细都是选填字段,于是 `invoiceCode` 永远为空、`items[]` 永远是空数组,而台账上看不出任何异常,用户也不会收到任何提示。来源 ③ 更彻底,连价税合计和买卖方名称都没有;标引链路断掉(`Read` 会在「解析诊断」里报出来)而只剩它时,必填字段是真的抽不齐。**② 与 ③ 都必须继续读逐页文本把缺的字段补上**,别在结构化段落上就收工。
|
||||
|
||||
#### 置信度按来源分档
|
||||
|
||||
| 来源 | `extractedBy` | 基线 | 理由 |
|
||||
| --- | --- | --- | --- |
|
||||
| ① 内嵌附件 | `ofd-xml` | **1.0** | 值内联在开票系统写出的国标 XML 里,不是 OCR,也不是版面猜测 |
|
||||
| ② 发票标引 | `ofd-xml` | **0.9** | 字段归属可信,但**值是页面文字对象解引用来的**,与版面文本同源 |
|
||||
| ③ CustomDatas | `text-layer` | **0.85**,且必须与页文本交叉核对 | 只有 6 个字段,且「合计金额」的语义极易读错(见上面第 2 条) |
|
||||
| 三个来源都没有 | `text-layer` | 0.85 起 | 走下面的版面文本规则 |
|
||||
|
||||
来源 ② 的明细尤其不可盲信:实测 `单位 / 数量 / 单价` 三列会被挤成一格(`1 4197.16981132075`),且长词会断到下一行。窄列的值必须与页文本对过才写进 `items[]`;对不上就留空并降置信度,不要把挤在一起的串硬拆。合计三项(不含税金额 / 税额 / 价税合计)在实测里是可信的。
|
||||
|
||||
#### 三个模式参数
|
||||
|
||||
- 某个值在展平时被截断(回执提示「字段数超过上限」)→ `ofd_mode:"attachments"` 拿原文补那一个字段,不要凭截断值入账。该模式对来源 ② 同样给出解引用后的取值与标引原文
|
||||
- 结构化数据太大把页文本挤没了 → `ofd_mode:"text"` 只看版面文字(注意该模式**不解析标引**,来源 ② 会一起消失)
|
||||
- 只想读某几页 → `pages`,语法与 PDF 相同
|
||||
|
||||
`Signature`(base64 签章)和 `TaxControlCode`(密码区乱码)没有语义,**不要**为了拿它们去调 `attachments` 模式,也不要往上下文里倒——记一句「含签名,N 字节」就够了。
|
||||
|
||||
OFD **不会**被渲染成图片,也不做签章有效性校验(只报告是否含签章)。所以纯图形的 OFD 没有视觉兜底:三个结构化来源全缺、页文本又为空时直接隔离,写清「OFD 无结构化数据且无可读页文本」。
|
||||
|
||||
**回落到版面文本时,OFD 的页文本比 PDF 好用**:模板层的固定标签已经与页内的值合并到同一行,`标签:值` 成对出现,表格列用 Tab 分隔——不会出现 PDF 那种竖排字段名被逐字拆行的形态。下面那套「忽略单字行」的规则是给 PDF 的,读 OFD 页文本时不必套用。
|
||||
|
||||
### PDF / 版面文本:抽取规则
|
||||
|
||||
具体到每种票的文本形态,读 `${SKILL_DIR}/references/票面文本形态.md`。那份文件是按真实票面版式构造的夹具在 `Read` 下的实测输出,不是示意图(票据本身是合成的,字段值为虚构值)。开工前**先读它**,尤其在遇到不认识的票种时。
|
||||
|
||||
跨票种通用的六条:
|
||||
|
||||
1. **先归一化空白再匹配。** 票面字段名里有全角排版留下的空格(`名 称:`、`校 验 码:`),把连续空白折叠成一个空格再匹配,别把空格写死进模式。
|
||||
2. **忽略单字行。** 竖排字段(`购/买/方/信/息`、`销/售/方/信/息`、`备/注`)会被逐字拆成单行。这是正常的,整段跳过。
|
||||
3. **买卖方靠顺序,不靠标签——但顺序必须自校验。** 数电票里两方的字段名一模一样,**先出现的是购买方,后出现的是销售方**(旧版票是购买方在上、销售方在下,中间隔着密码区)。这条规则依赖开票系统的版面布局,不是国标保证的:一旦某家系统的排版相反,每张票的买卖方都会静默对调,而勾稽校验一条都不会报错。所以**取到两方之后必须回头验一次**:preflight 已经问过用户的报销主体抬头,如果「后出现的一方」等于报销主体、而「先出现的一方」不是,说明这份票的顺序是反的——按抬头把两方归位,并在收尾里单独报告「检测到该开票系统买卖方顺序与常规相反」。两方都不等于报销主体时(代开、个人抬头、集团内其他主体)不动顺序,按现有的「抬头不符」流程标出来交用户判断。
|
||||
4. **丢掉密码区。** 旧版票 `密码区` 之后的 4 行是乱码,进上下文只会干扰。
|
||||
5. **金额取小写。** `价税合计(大写) 壹仟玖佰伍拾玖元玖角捌分 (小写)¥1959.98` —— 取 `(小写)` 后面的数字。大写金额用来做校验,不用来当值。
|
||||
6. **税率不一定是百分比。** `免税` / `不征税` / `***` 都会出现,原样记录,不要强行转成 0。
|
||||
|
||||
### ⚠️ 文本里会有 NUL 字节
|
||||
|
||||
PDF 文本抽取的结果里常出现 `\x00`(未映射字形),位置多在 `价税合计(大写)` 与中文大写之间。
|
||||
|
||||
后果:这样的文件 `file` 会判成 `data`,**裸 `grep` 会当二进制处理并静默返回空结果**——你会以为「没找到关键词」,其实是根本没搜。
|
||||
|
||||
对策:
|
||||
|
||||
- 检索发票文本一律用 `grep -a`,或者干脆用 `Read` 读回来自己判断
|
||||
- 不要依赖 shell 的 locale(很多环境里 `LANG` / `LC_ALL` 是空的,`grep 中文` 同样会静默失效)
|
||||
- 写进记录之前把 `\x00` 与其它控制字符剥掉
|
||||
|
||||
### 扫描件与图片
|
||||
|
||||
`Read` 遇到**没有文字层**的 PDF 页会自动渲染成图交给你看,不需要额外参数。
|
||||
|
||||
- **有文字层但抽出来是垃圾的扫描件**(劣质 OCR 层,现实中很常见:乱码、字序错乱、只有零星几个字)不会触发自动渲染——`Read` 认为这页有文字,就只给你那堆垃圾。判据不是「有没有文字层」,而是**「抽出来的内容够不够判定」**:文字层存在但拼不出发票号码或价税合计时,显式 `pdf_mode:"render"` 加 `pages` 重读一次,走视觉路径,`extractedBy` 记 `vision`。
|
||||
- **想看有文字层那一页上的印章 / 配图 / 版式**:同样要显式 `pdf_mode:"render"` 加明确的 `pages`(如 `"1"`、`"1,3-5"`),否则默认只抽文字,看不到图形。
|
||||
- **多页扫描件**:不带 `pages` 连续调用即可,`Read` 会自动接着上次的位置读;每读完一批用一两句话记下关键数字,图会被自动退场只留占位。
|
||||
- 视觉识别出的字段一律 `confidence ≤ 0.85`;金额、发票号码这两个字段视觉识别的错误代价最高,逐字复核一遍再写。
|
||||
- 图片模糊、倾斜、只有半张票 → 不要硬猜,隔离并在 `.reason.txt` 里写清「图片质量不足以识别 <哪几个字段>」。
|
||||
|
||||
### 置信度评分
|
||||
|
||||
从路径基线出发,逐项扣分:
|
||||
|
||||
| 情况 | 调整 |
|
||||
| --- | --- |
|
||||
| OFD 内嵌附件(来源 ①) | 1.0,不再扣分 |
|
||||
| OFD 发票标引(来源 ②) | 0.9 起;标引没覆盖到、靠页文本补的字段按页文本口径扣分 |
|
||||
| OFD `DocInfo/CustomDatas`(来源 ③) | 0.85 起;与页文本核对不上的字段 −0.3 并标 `checkFailed` |
|
||||
| `金额 + 税额 == 价税合计`(差 ≤ 0.01) | +0.05(上限 1.0) |
|
||||
| 勾稽对不上 | −0.3,并在记录里标 `checkFailed` |
|
||||
| 大写金额与小写金额不一致 | −0.3 |
|
||||
| 必填字段靠视觉识别得到 | 每项 −0.05 |
|
||||
| 销售方名称被截断或含明显乱码 | −0.2 |
|
||||
| 开票日期不在用户给定的时间范围内 | 不扣分,但要在收尾里单独列出来 |
|
||||
|
||||
**低于 0.8 的字段在台账里标「待复核」**,并进收尾清单。整条记录的 `confidence` 取所有必填字段里的最小值。
|
||||
|
||||
### 特殊票据
|
||||
|
||||
| 票种 | 关键差异 |
|
||||
| --- | --- |
|
||||
| 铁路电子客票(新版) | 走数电票版式,正常处理 |
|
||||
| 铁路电子客票报销凭证(旧版) | 没有 `发票号码:` 标签;抬头下的 21 位电子客票号当 `invoiceNumber`,`confidence` 上限 0.9 |
|
||||
| 航空运输电子客票行程单 | 没有发票号码,用 `电子客票号码`;`invoiceDate` 取**填开日期**不是航班日期;`印刷序号` 进 `checkCode` |
|
||||
| 出租车 / 网约车 | 网约车通常是标准数电票,正常处理;车牌与里程在备注里。**卷式与机打的出租车票、通行费票、部分定额票票面上只写「金额」「合计」,从不出现「价税合计」四个字**——按 `invoice-workflow` 的判定阶梯第 1 条,合计项认「价税合计 / 合计金额 /(金额+税额)」中的任意一种,别因为找不到「价税合计」就把它判成非发票 |
|
||||
| 定额发票 | 只有代码 + 号码 + 面额,没有明细也没有税额;走兜底主键 |
|
||||
| 作废票 | 文本里出现 `作废` / `已作废` → `isVoid: true`。**文本里没有不代表没作废**,作废戳可能是纯图形 |
|
||||
| 免税 / 不征税 | `taxRate` 原样记 `免税`;`taxAmount == 0` 且 `amountExcludingTax == totalAmount` 是正常的 |
|
||||
| 外币结算 | 票面仍是人民币,原币与汇率写在备注里。照抄备注,**不做换算**,`currency` 保持票面币种(也就是 `CNY`)——所以台账里不会出现非 CNY 的记录,外币信息只存在于备注 |
|
||||
|
||||
### 解析失败的处理
|
||||
|
||||
失败不是异常,是常态的一部分。四种失败态各自的处置:
|
||||
|
||||
| 失败态 | 现象 | 处置 |
|
||||
| --- | --- | --- |
|
||||
| 加密带口令 | `Read` 返回 `PDF 文件无法解析: <底层报错>` + 换行 + `可能原因: 文件损坏、加密或非标准格式`(半角冒号,中间夹着底层报错原文)。**加密没有被单独识别,这是一条通用兜底**——同一句话也会用于损坏和非标准格式,别只凭它就断定是加密 | 隔离。若底层报错里出现 `password` 之类字样,在收尾里告诉用户这张票带口令,口令通常写在**邮件正文**里(常见为发票号码后 6 位或手机号后 6 位),请他自己解密后重新放进 `_inbox/`。**不要**尝试猜口令 |
|
||||
| 文件损坏 / 空文件 | 同一句兜底报错 | 隔离,`.reason.txt` 写「文件损坏或为空,字节数 N」,并把底层报错原文一并抄进去(那是唯一能区分这三种失败的线索) |
|
||||
| 能读但内容为空 | 抽出来只有几个字符 | 隔离,写「文本层为空,可能是纯图形 PDF 但渲染也未产出可读内容」 |
|
||||
| 判定为非发票 | 命中负向关键词或缺关键标签 | 隔离,写清是哪一条判据命中的 |
|
||||
|
||||
隔离 = 把文件移到 `_quarantine/`,写一个同名 `.reason.txt`,然后**继续处理下一份**。一份失败不能中断整批。
|
||||
|
||||
## L2
|
||||
|
||||
### 形式校验清单
|
||||
|
||||
抽完字段后逐条走一遍,任何一条不过都要在记录里留痕:
|
||||
|
||||
- `金额 + 税额 = 价税合计`(浮点比较留 0.01 容差)
|
||||
- 大写金额与小写金额一致(大写解析可以只做数量级校验,不必逐字)
|
||||
- 明细行 `数量 × 单价 = 金额`(多行时求和)
|
||||
- 数电票发票号码 20 位纯数字;旧版发票代码 12 位、号码 8 位
|
||||
- 开票日期是合法日期且不在未来
|
||||
- 税号 18 位(老式 15 位纳税人识别号也合法,不要判错)
|
||||
|
||||
校验只降置信度、只留标记,**不修改抽出来的值**。票面本身写错的情况真实存在,改数据比留标记危险得多。
|
||||
|
||||
### 批量解析的顺序
|
||||
|
||||
一批文件的处理顺序:先 `FileDigest` 一次性算完所有哈希(一次最多 100 个路径),去掉命中缓存的,再逐个解析。
|
||||
|
||||
先算哈希的理由:重复下载在发票场景里非常常见(同一封邮件转发多次、用户手工又存了一份),先去重能省掉大部分解析开销。
|
||||
|
||||
### 什么时候值得回看原图
|
||||
|
||||
台账做完后用户质疑某个数字时,用记录里的 `archivedPath` 加 `pdf_mode:"render"` 和该页页码重读一次,肉眼核对再答。不要凭 `.index/raw/<sha256>.json` 里的缓存回答「我当时读到的是这个」——用户问的是票面写的是什么。
|
||||
@@ -0,0 +1,245 @@
|
||||
# 各类票据的真实文本层形态
|
||||
|
||||
下面每一段,都是把**按真实票面版式构造的夹具**用 `Read` 读一遍后实测拿到的输出。版式、换行、
|
||||
空白折叠、竖排拆行、NUL 字节都来自实际运行结果,不是示意图;但票据本身是合成的,人名、公司名、
|
||||
税号均为虚构值(`invoice-extract` 里 OFD 一节引用的样本才是第三方真实票据)。
|
||||
写抽取规则时对照这里的形态,不要凭想象拼正则。
|
||||
|
||||
⚠️ **有一条不能当成普遍事实**:下面反复出现的「买卖方靠出现顺序区分」,在这些夹具上成立,
|
||||
但顺序取决于**生成这份 PDF 的开票系统的坐标布局**,不是国标保证的。真碰上顺序相反的开票系统,
|
||||
每张票的买卖方都会静默对调,而勾稽校验、金额校验一条都不会报错。
|
||||
`invoice-extract` 的通用规则第 3 条给了自校验办法(拿用户的报销主体抬头回验顺序),务必照做。
|
||||
|
||||
---
|
||||
|
||||
## 1. 数电票(电子发票,2023 年后主流)
|
||||
|
||||
```
|
||||
电子发票(增值税专用发票)
|
||||
(全国统一电子发票服务平台)
|
||||
发票号码:24312000000000020002
|
||||
开票日期:2024年04月08日
|
||||
购
|
||||
买
|
||||
方
|
||||
信
|
||||
息
|
||||
销
|
||||
售
|
||||
方
|
||||
信
|
||||
息
|
||||
名 称:示例数字科技(北京)有限公司
|
||||
统一社会信用代码/纳税人识别号:
|
||||
91110108MA01X2Y3QK
|
||||
名 称:示范酒店管理有限公司
|
||||
统一社会信用代码/纳税人识别号:
|
||||
91310104MA1FL9K3T6
|
||||
项目名称 规格型号 单位 数量 单价 金额 税率/征收率 税额
|
||||
*住宿服务*住宿费 标准间 晚 4 462.26 1849.04 6% 110.94
|
||||
合 计 ¥1849.04 ¥110.94
|
||||
价税合计(大写) 壹仟玖佰伍拾玖元玖角捌分 (小写)¥1959.98
|
||||
备
|
||||
注
|
||||
出差住宿 4 晚
|
||||
开票人:陈静
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- 抬头行区分票种:`电子发票(增值税专用发票)` = 数电专票,`电子发票(普通发票)` = 数电普票。
|
||||
- **只有 20 位发票号码,没有发票代码。** 别去找 `发票代码:`。
|
||||
- 竖排字段名被逐字拆成单行(`购/买/方/信/息`、`销/售/方/信/息`、`备/注`)。这是版式文档竖排文本的必然结果,不是错误——按「单个汉字独占一行」的模式整段忽略即可。
|
||||
- **买卖方靠出现顺序区分。** 两方各有一次 `名 称:` + `统一社会信用代码/纳税人识别号:`,**先出现的是购买方,后出现的是销售方**。字段名完全一样,只靠前缀分不出来。**这个顺序必须用报销主体抬头回验一次**(见 `invoice-extract` 通用规则第 3 条)——顺序相反的开票系统会让两方静默对调,没有任何校验能发现。
|
||||
- 税号往往换行在字段名的下一行,不在同一行。
|
||||
- `名 称` 中间有一个空格(全角排版所致)。匹配时把空白折叠掉再比对,别写死 `名 称:`。
|
||||
|
||||
---
|
||||
|
||||
## 2. 旧版增值税电子普通发票(2023 年前)
|
||||
|
||||
```
|
||||
浙江增值税电子普通发票
|
||||
(此发票为电子发票,与增值税普通发票具有同等法律效力)
|
||||
发票代码:033002100211
|
||||
发票号码:41250933
|
||||
开票日期:2023年03月29日
|
||||
校 验 码:55019 27384 60172 39948
|
||||
购
|
||||
买
|
||||
方
|
||||
名 称:测试云图信息技术(上海)有限公司
|
||||
纳税人识别号:91310115MA1K88N7XQ
|
||||
地 址、电 话:北京市海淀区示例路 1 号 010-88880001
|
||||
开户行及账号:示例银行北京分行 110060000012345678
|
||||
密码区
|
||||
0<7/*>3-25+8/91<<->406*2/57
|
||||
>>1*4-08/6<32+95*17-0/4>>86
|
||||
5/2*<91+3-70*4<6/8>21*05-39
|
||||
4-8>05*/13<27+6*90/4-<851>2
|
||||
货物或应税劳务、服务名称 规格型号 单位 数量 单价 金额 税率 税额
|
||||
*会议服务*会议服务费 场 1 12264.15 12264.15 6% 735.85
|
||||
合 计 ¥12264.15 ¥735.85
|
||||
价税合计(大写) 壹万叁仟元整 (小写)¥13000.00
|
||||
销
|
||||
售
|
||||
方
|
||||
名 称:虚构会议服务有限公司
|
||||
纳税人识别号:91330106MA2AB5D8JC
|
||||
地 址、电 话:北京市朝阳区样例大街 88 号 010-66660002
|
||||
开户行及账号:示例银行朝阳支行 110060000087654321
|
||||
备注
|
||||
2023 春季技术交流会
|
||||
收款人:李娜 复核:孙涛 开票人:郑昊 销售方:(章)
|
||||
```
|
||||
|
||||
与数电票的差别:
|
||||
|
||||
- 12 位 `发票代码:` + 8 位 `发票号码:`,两者一起才是主键。
|
||||
- 字段名是 `纳税人识别号`,不是 `统一社会信用代码/纳税人识别号`。
|
||||
- 有 `校 验 码:`(4 组 5 位数字)。
|
||||
- **购买方在上、销售方在下**,中间隔着密码区与明细表——不是数电票那种交替排列。
|
||||
- `密码区` 下面 4 行是无语义乱码,**抽取时整段丢弃**。
|
||||
- 明细表头是 `货物或应税劳务、服务名称`。
|
||||
- 抬头行常带省份前缀(`浙江增值税电子普通发票`)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 铁路电子客票报销凭证
|
||||
|
||||
两种形态,都会遇到:
|
||||
|
||||
**(a)新版:走数电票版式**
|
||||
|
||||
```
|
||||
电子发票(铁路电子客票)
|
||||
(全国统一电子发票服务平台)
|
||||
发票号码:24112000000000800080
|
||||
开票日期:2024年09月02日
|
||||
…(购买方 / 销售方与数电票同构)…
|
||||
*运输服务*旅客运输服务 二等座 张 1 507.34 507.34 9% 45.66
|
||||
合 计 ¥507.34 ¥45.66
|
||||
价税合计(大写) 伍佰伍拾叁元整 (小写)¥553.00
|
||||
电子客票号 E123456789;乘车人 李明;身份证 1101**********0018
|
||||
车 次:G103 席别:二等座
|
||||
行 程:北京南 ⇒ 上海虹桥
|
||||
发车时间:2024-09-02 08:00
|
||||
```
|
||||
|
||||
**(b)旧版:独立版式,没有 `发票号码:` 标签**
|
||||
|
||||
```
|
||||
铁路电子客票报销凭证
|
||||
220310010012345678901 开票日期:2023-05-11
|
||||
上海虹桥 G7331 杭州东
|
||||
2023-05-11 14:23开 05车 12A 一等座
|
||||
票价:¥90.00 金额 82.57 税率 9% 税额 7.43
|
||||
1101**********0018 李明
|
||||
购买方名称:示例数字科技(北京)有限公司
|
||||
统一社会信用代码:91110108MA01X2Y3QK
|
||||
报销凭证 仅供报销使用 报销凭证,非退票凭证;限乘当日当次车
|
||||
销售方:示例铁路运输集团有限公司 统一社会信用代码:91110000MA02RL7X3B
|
||||
价税合计(大写)玖拾元整 (小写)¥90.00
|
||||
```
|
||||
|
||||
旧版的坑:抬头行下面那串 21 位数字是**电子客票号**,不是发票号码,但它是这张凭证唯一的稳定标识——`invoiceNumber` 就填它,并在 `invoiceType` 里写明是铁路电子客票,`confidence` 上限给 0.9。开票日期在同一行的 `开票日期:` 后面。
|
||||
|
||||
---
|
||||
|
||||
## 4. 航空运输电子客票行程单
|
||||
|
||||
```
|
||||
航空运输电子客票行程单
|
||||
ITINERARY / RECEIPT OF e-TICKET FOR AIR TRANSPORT
|
||||
印刷序号:03987654321
|
||||
旅客姓名 李明 有效身份证件号码 1101**********0018
|
||||
电子客票号码 999-2345678901 验证码 8Q2K4M
|
||||
承运人 示例航空 ZZ 航班号 ZZ5237
|
||||
自 FROM 北京/首都T2 至 TO 广州/白云T1
|
||||
日期 DATE 2024-10-08 时间 TIME 09:45
|
||||
座位等级 经济舱 Y 客票级别 Y
|
||||
票价 ¥1247.71 民航发展基金 ¥50.00
|
||||
燃油附加费/税额 ¥112.29 其他税费 ¥0.00
|
||||
合计金额 ¥1410.00 填开日期 2024-09-25
|
||||
销售单位代号 08-1234567 GP 单号 GP03987654321
|
||||
购买方名称:示例数字科技(北京)有限公司
|
||||
购买方统一社会信用代码/纳税人识别号:91110108MA01X2Y3QK
|
||||
销售方:示例航空股份有限公司 纳税人识别号:91110000MA02QK8N5D
|
||||
价税合计(大写)壹仟肆佰壹拾元整 (小写)¥1410.00
|
||||
```
|
||||
|
||||
要点:
|
||||
|
||||
- **没有发票号码**。用 `电子客票号码`(`999-2345678901`)当 `invoiceNumber`,`印刷序号` 记进 `checkCode`。
|
||||
- `invoiceDate` 取 `填开日期`(这里是 `2024-09-25`),**不是** `日期 DATE`(那是航班日期)。这两个日期跨月时会把票记到错误的月份。
|
||||
- `合计金额` 就是价税合计;`票价 + 民航发展基金 + 燃油附加费/税额 + 其他税费` 应当等于它,可以拿来做勾稽校验。
|
||||
- 这类行程单常常没有单独的税额行,`taxAmount` 留空而不是填 0。
|
||||
|
||||
---
|
||||
|
||||
## 5. 作废票
|
||||
|
||||
作废标记出现在文本层,两处:
|
||||
|
||||
```
|
||||
备
|
||||
注
|
||||
开票信息有误,已作废
|
||||
开票人:陈静
|
||||
作废
|
||||
```
|
||||
|
||||
命中 `作废` 或 `已作废` → `isVoid: true`。
|
||||
|
||||
**文本里没有不代表没作废**——真实世界的作废戳也可能是纯图形。所以只要 `isVoid` 判定依赖的是「文本里没找到」,就不要把它当强结论;台账里作废票单独一栏,不计入合计。
|
||||
|
||||
---
|
||||
|
||||
## 6. 免税 / 不征税
|
||||
|
||||
税率列不是百分比:
|
||||
|
||||
```
|
||||
*非学历教育服务*职业技能培训费 人次 12 480.00 5760.00 免税 0.00
|
||||
合 计 ¥5760.00 ¥0.00
|
||||
```
|
||||
|
||||
`taxRate` 原样记 `免税`(也可能是 `不征税`、`***`、`0%`),不要强行转成数字 0。`amountExcludingTax == totalAmount` 且 `taxAmount == 0` 是正常的。
|
||||
|
||||
---
|
||||
|
||||
## 7. 外币结算
|
||||
|
||||
票面仍是人民币,原币信息在备注里:
|
||||
|
||||
```
|
||||
*技术服务*境外技术服务费 项 1 13396.23 13396.23 6% 803.77
|
||||
合 计 ¥13396.23 ¥803.77
|
||||
价税合计(大写) 壹万肆仟贰佰元整 (小写)¥14200.00
|
||||
备
|
||||
注
|
||||
原币金额 USD 2,000.00;结算汇率 7.1000;折合人民币 14200.00
|
||||
开票人:何雪 结算币种:USD
|
||||
```
|
||||
|
||||
`totalAmount` 记 `14200.00`,`currency` 记 `CNY`(票面币种)。原币信息照抄进备注字段,**不要**自己做汇率换算,也不要把 `currency` 改成 `USD`。
|
||||
|
||||
---
|
||||
|
||||
## 8. 负样本:对账单
|
||||
|
||||
```
|
||||
供应商往来对账单
|
||||
账期:2024 年 9 月 1 日 — 2024 年 9 月 30 日
|
||||
供应方:样例办公用品销售有限公司
|
||||
需方:示例数字科技(北京)有限公司
|
||||
日期 关联发票号码 业务摘要 金额(元)
|
||||
2024-09-03 24112000000000040004 办公用品采购 2338.05
|
||||
2024-09-11 24322000000000050005 运输服务 6000.00
|
||||
本期合计 29658.05
|
||||
说明:本对账单仅用于双方核对往来款项,不是发票,不得作为报销与抵扣凭证。
|
||||
```
|
||||
|
||||
**这份文件里有 20 位数字串,而且不止一个。** 只按数字模式匹配一定误判成发票(还会一次误判出好几张)。判据必须是**带标签的**发票号码(`发票号码:` 紧跟数字)加上 `价税合计` 同时出现——两个条件这份文件一个都不满足。
|
||||
|
||||
合同、报价单、邮件正文导出的 PDF 同理。
|
||||
181
agents/invoice-organizer/skills/invoice-ledger/SKILL.md
Normal file
181
agents/invoice-organizer/skills/invoice-ledger/SKILL.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
name: invoice-ledger
|
||||
description: >-
|
||||
发票台账与月度报告的产出规范:工作表结构、列定义、人民币金额与日期格式约定、
|
||||
汇总口径(作废票与待复核票怎么算)、xlsx 优先与 CSV 降级路径、报告模板。
|
||||
Use when 需要生成 / 重建 / 更新发票台账(「出一份台账」「导成 Excel」「这个月一共多少钱」
|
||||
「生成月度报告」),或台账数字对不上、要确认某个金额是怎么汇总出来的时候。
|
||||
Also covers invoice ledger spreadsheet layout, RMB currency formatting and monthly expense reports.
|
||||
version: 1.0.0
|
||||
type: procedural
|
||||
risk_level: low
|
||||
status: enabled
|
||||
tags:
|
||||
- invoice
|
||||
- ledger
|
||||
- spreadsheet
|
||||
- report
|
||||
metadata:
|
||||
category: reporting
|
||||
i18n:
|
||||
default_locale: en-US
|
||||
source_locale: zh-CN
|
||||
locales:
|
||||
- zh-CN
|
||||
- en-US
|
||||
zh-CN:
|
||||
name: 发票台账与报告
|
||||
short_desc: 台账工作表结构、人民币金额口径与月度报告模板
|
||||
en-US:
|
||||
name: Invoice Ledger and Report
|
||||
short_desc: Ledger sheet layout, RMB amount conventions and monthly report template
|
||||
requires:
|
||||
tools:
|
||||
- Read
|
||||
- Write
|
||||
---
|
||||
|
||||
# 发票台账与报告
|
||||
|
||||
## L0
|
||||
|
||||
台账每次**全量重建**,数据源永远是 `.index/ledger.json`,不是重新解析文件、也不是在旧台账上追加。
|
||||
|
||||
优先出 `.xlsx`;依赖装不上就出**带 UTF-8 BOM 的 CSV**,并明确告诉用户降级了。
|
||||
|
||||
## L1
|
||||
|
||||
### 重建前先备份
|
||||
|
||||
现有台账另存为 `台账.bak.xlsx` / `台账.bak.csv`,再写新的。备份只留最近一份,不做多代。
|
||||
|
||||
### 输出格式:先 xlsx,装不上就 CSV
|
||||
|
||||
```bash
|
||||
python3 -c "import openpyxl; import pandas" 2>/dev/null || echo "MISSING"
|
||||
```
|
||||
|
||||
- **不是 MISSING** → 加载市场技能 `xlsx`,按下面的工作表结构生成 `.xlsx`(多工作表、金额格式、冻结首行)
|
||||
- **MISSING** → 直接 `Write` 一份 CSV,**不要**引导用户去装 Python 依赖打断流程。在收尾里说一句:「Excel 生成依赖(openpyxl / pandas)不可用,已改出 CSV;装上后可以让我重出 xlsx。」
|
||||
|
||||
CSV 有两条硬要求:
|
||||
|
||||
1. **文件头必须写 UTF-8 BOM(``)**,否则 Excel 打开中文全是乱码
|
||||
2. 字段里出现逗号、引号或换行时按 RFC 4180 用双引号包裹并把内部引号翻倍
|
||||
|
||||
CSV 只出「明细」一张表,汇总数字放进月度报告的 Markdown 里。
|
||||
|
||||
用 `Write` 落盘,不要用 Bash 重定向——`Write` 会产生「本轮修改了哪些文件」卡片,用户能直接点开。
|
||||
|
||||
### 工作表结构(xlsx)
|
||||
|
||||
四张表,顺序固定:
|
||||
|
||||
**① 明细** —— 一行一张发票,按开票日期升序
|
||||
|
||||
| 列 | 来源字段 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 开票日期 | `invoiceDate` | `YYYY-MM-DD` |
|
||||
| 发票类型 | `invoiceType` | 数电普票 / 数电专票 / 增值税普通发票 / 铁路电子客票 / 航空行程单 / … |
|
||||
| 发票号码 | `invoiceNumber` | **文本格式**,见下面的「数字被当成科学计数法」 |
|
||||
| 发票代码 | `invoiceCode` | 数电票为空 |
|
||||
| 销售方 | `sellerName` | |
|
||||
| 销售方税号 | `sellerTaxId` | |
|
||||
| 购买方 | `buyerName` | |
|
||||
| 项目 | `items[].name` 拼接 | 多行时用 `;` 连接,超过 60 字截断 |
|
||||
| 金额 | `amountExcludingTax` | 不含税 |
|
||||
| 税率 | `taxRate` | 原样,可能是 `6%` / `免税` / `***` |
|
||||
| 税额 | `taxAmount` | |
|
||||
| 价税合计 | `totalAmount` | |
|
||||
| 币种 | `currency` | 默认 `CNY`;外币结算票的票面仍是人民币,这一列照样是 `CNY`,原币信息在备注里 |
|
||||
| 状态 | 派生 | `正常` / `作废` / `待复核` / `疑似重复` |
|
||||
| 置信度 | `confidence` | 两位小数 |
|
||||
| 归档路径 | `archivedPath` | 绝对路径 |
|
||||
| 来源邮件 | `sourceEmailSubject` | |
|
||||
| 收件时间 | `sourceReceivedAt` | |
|
||||
|
||||
**② 月度汇总** —— 一行一个月份
|
||||
|
||||
| 列 | 口径 |
|
||||
| --- | --- |
|
||||
| 月份 | `YYYY-MM`,按开票日期归属 |
|
||||
| 张数 | 计入合计的发票张数 |
|
||||
| 金额合计 | `amountExcludingTax` 之和 |
|
||||
| 税额合计 | `taxAmount` 之和 |
|
||||
| 价税合计 | `totalAmount` 之和 |
|
||||
| 作废张数 | 单列,不计入上面任何合计 |
|
||||
| 待复核张数 | 单列,**计入**合计但要能一眼看到 |
|
||||
|
||||
**③ 按销售方汇总** —— 一行一个销售方,按价税合计降序。列:销售方 / 税号 / 张数 / 价税合计 / 占比。
|
||||
|
||||
**④ 异常** —— 需要人处理的都在这里,一行一条
|
||||
|
||||
| 列 | 说明 |
|
||||
| --- | --- |
|
||||
| 类型 | `解析失败` / `疑似重复` / `勾稽不符` / `抬头不符` / `低置信度` / `作废` / `范围外日期` |
|
||||
| 文件 | 归档路径或 `_quarantine/` 路径 |
|
||||
| 说明 | 一句话讲清卡在哪 |
|
||||
| 建议动作 | 具体到用户该做什么 |
|
||||
|
||||
### 人民币金额与日期约定(**覆盖 `xlsx` 技能的默认约定**)
|
||||
|
||||
市场技能 `xlsx` 的财务模型章节写的是美式约定,直接照做会做出一张不像中国发票台账的表。以下五条**以本节为准**:
|
||||
|
||||
| 事项 | `xlsx` 技能的默认 | 本台账采用 |
|
||||
| --- | --- | --- |
|
||||
| 金额格式 | `$#,##0` | `¥#,##0.00` —— 发票金额精确到分,**不能省略两位小数** |
|
||||
| 负数 | `($#,##0)` 括号式 | `-¥#,##0.00` 负号式 |
|
||||
| 零值 | 显示成 `-` | 显示成 `¥0.00` —— 零金额发票是真实存在的票据,隐藏它等于丢账 |
|
||||
| 表头单位 | `Revenue ($mm)` | 不加单位后缀;币种由「币种」列承载 |
|
||||
| 日期 | 本地化格式 | 一律 `YYYY-MM-DD` 文本 |
|
||||
|
||||
完整数字格式串:`¥#,##0.00;-¥#,##0.00;¥0.00`
|
||||
|
||||
### 数字被当成科学计数法
|
||||
|
||||
20 位的数电票发票号码、18 位税号写进 Excel 会被识别成数字并显示成 `2.4312E+19`,再也复原不回去。
|
||||
|
||||
- xlsx:这两列显式设成文本格式(openpyxl 里 `cell.number_format = '@'` 并写字符串)
|
||||
- CSV:写成 `="24312000000000020002"` 或在导入说明里让用户把该列指定为文本
|
||||
|
||||
税率列同理——`6%` 是文本,别让它变成 `0.06`。
|
||||
|
||||
### 汇总口径(唯一定义,别临时发明)
|
||||
|
||||
- **作废票不计入任何合计**,单独统计张数
|
||||
- **待复核票计入合计**,但在「月度汇总」里单列张数,并在异常表里逐条列出
|
||||
- **疑似重复的两张都计入合计**(在用户确认之前不能替他删掉一张),并在异常表里成对列出
|
||||
- **外币结算票按票面的人民币金额正常计入合计**。这类票**票面本身就是人民币计价**,`currency` 记 `CNY`(见 `invoice-extract`),原币金额与折算汇率只照抄进备注、不做换算——所以台账里正常情况下不存在非 CNY 的记录,不要写一条「只统计 `currency == 'CNY'`」的过滤当作外币处理办法,那条件永远全真。真的出现票面以非人民币计价的记录时,**不并进任何合计**,在异常表里列成「非人民币计价,需人工处理」,并在报告里单列一段
|
||||
- **月份按开票日期归属**,不按收件日期。跨年时特别注意:12 月开的票 1 月才收到,仍然算 12 月
|
||||
- 占比按价税合计算,保留一位小数
|
||||
|
||||
### 月度报告
|
||||
|
||||
模板见 `${SKILL_DIR}/references/月度报告模板.md`,写到 `报告/<YYYY-MM>.md`。
|
||||
|
||||
用户要 PDF/DOCX 时用 `ExportDocument` 从这份 Markdown 转(该工具不能直接出 xlsx)。要把台账文件交给用户时用 `SendUserMessage` 带附件(最多 10 个文件、每个 ≤10MB)。
|
||||
|
||||
## L2
|
||||
|
||||
### 生成 xlsx 时的注意事项
|
||||
|
||||
- 冻结每张表的首行;明细表首行加粗、浅底色
|
||||
- 「置信度」列低于 0.8 的单元格标橙色底,让待复核项在表里一眼可见
|
||||
- 不写 Excel 公式。台账的数字来自 `ledger.json`,公式只会引入 `#REF!` 风险且没有任何收益——需要用户自己再算时他可以在 Excel 里加
|
||||
- 列宽按内容估算,销售方和项目两列给足宽度
|
||||
- 生成完 `Read` 一次确认文件确实写出来了、行数与 `ledger.json` 的记录数一致
|
||||
|
||||
### 台账数字对不上时的排查顺序
|
||||
|
||||
1. 先看 `ledger.json` 的记录条数和台账明细行数是否相等——不等说明重建过程丢了记录
|
||||
2. 再看月度汇总的张数之和是否等于明细行数减去作废张数
|
||||
3. 再看单张票的 `金额 + 税额` 是否等于 `价税合计`(不等的应该已经进了异常表)
|
||||
4. 最后才怀疑抽取错误,用 `invoice-extract` 的「回看原图」办法核对票面
|
||||
|
||||
**永远不要为了让数字对上去手工改 `ledger.json` 里的值。** 对不上本身就是要报给用户的结论。
|
||||
|
||||
### 只重建不重新收集
|
||||
|
||||
用户说「重新出一遍台账」时,不要重跑收集和解析——直接读 `ledger.json` 重建。整个过程不碰邮箱,通常几秒完成。
|
||||
|
||||
只有用户明确说「重新扫一遍邮箱」时才回到 `invoice-workflow` 的第 2 步。
|
||||
@@ -0,0 +1,92 @@
|
||||
# 月度报告模板
|
||||
|
||||
写到 `报告/<YYYY-MM>.md`。占位符用 `<>` 标出,全部替换后不应该留下任何尖括号。
|
||||
|
||||
数字全部来自 `.index/ledger.json`,不要现算一遍别的口径——报告里的合计必须和台账「月度汇总」那一行逐字一致。
|
||||
|
||||
---
|
||||
|
||||
```markdown
|
||||
# 发票台账月度报告 · <YYYY-MM>
|
||||
|
||||
生成时间:<YYYY-MM-DD HH:mm>
|
||||
统计口径:按**开票日期**归属月份;作废票不计入合计;外币票单列。
|
||||
|
||||
## 一句话
|
||||
|
||||
<YYYY 年 M 月>共入账 <N> 张发票,价税合计 ¥<X>;另有 <n> 张待复核、<m> 张作废。
|
||||
|
||||
## 汇总
|
||||
|
||||
| 指标 | 数值 |
|
||||
| --- | --- |
|
||||
| 入账张数 | <N> |
|
||||
| 金额合计(不含税) | ¥<X> |
|
||||
| 税额合计 | ¥<X> |
|
||||
| **价税合计** | **¥<X>** |
|
||||
| 待复核张数 | <n> |
|
||||
| 作废张数 | <m> |
|
||||
| 外币票张数 | <k> |
|
||||
|
||||
## 支出构成
|
||||
|
||||
| 销售方 | 张数 | 价税合计 | 占比 |
|
||||
| --- | --- | --- | --- |
|
||||
| <销售方> | <n> | ¥<X> | <p>% |
|
||||
|
||||
(只列前 10 名,其余合并成「其他」一行。)
|
||||
|
||||
按发票类型:
|
||||
|
||||
| 类型 | 张数 | 价税合计 |
|
||||
| --- | --- | --- |
|
||||
| <数电普票 / 数电专票 / 铁路电子客票 / …> | <n> | ¥<X> |
|
||||
|
||||
## 需要你处理的
|
||||
|
||||
<按异常类型分组;一条都没有时写「本月无需处理项。」并删掉下面的小节。>
|
||||
|
||||
### 疑似重复(<n> 组)
|
||||
|
||||
- <发票号码 A> 与 <发票号码 B>:<销售方>,同为 ¥<X>,开票日期同为 <日期>,号码仅末位不同。
|
||||
→ 请确认是两张不同的票,还是同一张被重复开具/下载。
|
||||
|
||||
### 解析失败(<n> 份)
|
||||
|
||||
- `<文件名>`:<原因>。
|
||||
→ <具体建议,例如:该 PDF 带口令,口令通常写在邮件正文里(常见为发票号码后 6 位或手机号后 6 位),解密后放回 `_inbox/` 我再处理。>
|
||||
|
||||
### 待复核字段(<n> 张)
|
||||
|
||||
- <发票号码>(<销售方>,¥<X>):<哪个字段>置信度 <c>,来源 <text-layer / vision>。
|
||||
→ 请对照 `<归档路径>` 核对。
|
||||
|
||||
### 抬头不符(<n> 张)
|
||||
|
||||
- <发票号码>(<销售方>,¥<X>):购买方为「<票面抬头>」,与报销主体「<用户抬头>」不一致。
|
||||
→ 代开、个人抬头、集团内其他主体都可能是正常情形,请确认是否保留。
|
||||
|
||||
## 数据来源
|
||||
|
||||
- 收集范围:<起始日期> 至 <结束日期>,邮箱 <provider>:<email>(多个时逐行列出)
|
||||
- 候选邮件 <A> 封,下载附件 <B> 个,成功解析 <C> 张,去重后 <D> 张,隔离 <E> 份
|
||||
- 台账文件:`<台账绝对路径>`
|
||||
- 归档目录:`<归档目录绝对路径>`
|
||||
|
||||
## 说明
|
||||
|
||||
本报告只做形式校验(字段齐全、金额勾稽、票面自洽),**不做发票真伪查验**。
|
||||
需要验真请到国家税务总局全国增值税发票查验平台 https://inv-veri.chinatax.gov.cn/ ,
|
||||
用发票号码与开票日期自行核验。
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 写作要求
|
||||
|
||||
- 「一句话」一定写在最前面,用户十有八九只看这一句
|
||||
- 「需要你处理的」里每一条都要有 `→` 开头的具体动作,不要只描述现象
|
||||
- 数字带币种符号;张数不带
|
||||
- 一条异常都没有时不要留空标题,整节删掉换成一句「本月无需处理项。」
|
||||
- 不要在报告里放图片(Markdown 里的相对图片引用在很多渲染位置不显示)
|
||||
- 不要写「已核实」「确认无误」这类暗示做过真伪查验的说法
|
||||
360
agents/invoice-organizer/skills/invoice-workflow/SKILL.md
Normal file
360
agents/invoice-organizer/skills/invoice-workflow/SKILL.md
Normal file
@@ -0,0 +1,360 @@
|
||||
---
|
||||
name: invoice-workflow
|
||||
description: >-
|
||||
发票整理总纲:七步工作流、工作目录布局、状态索引结构、去重主键与幂等判据、能力边界。
|
||||
Use when 用户要整理 / 收集 / 归档发票或报销票据(「整理一下上个月的发票」「把邮箱里的发票收一下」
|
||||
「这张发票记过了吗」「重新出一遍台账」),或任何发票任务开工前需要确认目录与状态文件位置时。
|
||||
Also covers invoice collection, expense receipt intake, VAT invoice archiving and dedupe.
|
||||
解析细则见 invoice-extract;台账与报告格式见 invoice-ledger;邮件规则与定时任务见 invoice-automation。
|
||||
version: 1.0.0
|
||||
type: procedural
|
||||
risk_level: low
|
||||
status: enabled
|
||||
tags:
|
||||
- invoice
|
||||
- workflow
|
||||
- archive
|
||||
metadata:
|
||||
category: workflow
|
||||
i18n:
|
||||
default_locale: en-US
|
||||
source_locale: zh-CN
|
||||
locales:
|
||||
- zh-CN
|
||||
- en-US
|
||||
zh-CN:
|
||||
name: 发票整理总纲
|
||||
short_desc: 七步工作流、目录布局、状态索引与幂等判据
|
||||
en-US:
|
||||
name: Invoice Workflow
|
||||
short_desc: Seven-step pipeline, directory layout, state index and idempotency rules
|
||||
requires:
|
||||
tools:
|
||||
- MailOperations
|
||||
- Read
|
||||
- Write
|
||||
- FileDigest
|
||||
---
|
||||
|
||||
# 发票整理总纲
|
||||
|
||||
## L0
|
||||
|
||||
七步,顺序固定,每一步都幂等:**接入检查 → 收集 → 解析 → 去重 → 归档 → 台账 → 报告**。
|
||||
|
||||
状态只写工作目录里的三个索引文件。任何一步中断,重跑都从索引恢复,不会重复入账。
|
||||
|
||||
单份发票内部的落盘顺序是固定的(`ledger.json` 必须先于删除原件、`emails.json` 必须最后写),
|
||||
见下面的「单份发票的落盘顺序」——写反了会永久丢记录,且没有任何一次重跑能发现。
|
||||
|
||||
## L1
|
||||
|
||||
### 第 1 步 · 接入检查(Preflight)
|
||||
|
||||
只在会话里第一次做发票任务时跑一遍,之后复用结论,除非出错。
|
||||
|
||||
1. **邮箱**:`MailOperations{path:'/api/accounts-with-settings'}` 拿到全部已接入账户(跨 provider 的总表,同时带账户设置;只要账户列表也可用 `/api/accounts`)。一个账户都没有 → 停下,告诉用户先在 DesireCore 的邮箱界面完成一次授权,**不要**去猜端点或试别的路子。
|
||||
2. **工作目录**:`ManageWorkDirs{action:'list'}`。已安装的 Agent 会自动拿到一个默认工作区,但那个路径用户在文件管理器里几乎找不到。**主动建议用户加一个看得见的目录**:
|
||||
|
||||
```
|
||||
ManageWorkDirs{action:'add', path:'<用户家目录>/Documents/发票', label:'发票'}
|
||||
ManageWorkDirs{action:'set_primary', path:'<同上>'}
|
||||
```
|
||||
|
||||
已登记的工作目录会出现在「文件工作台」里,用户能直接点开台账。这一步会弹审批卡,属正常。
|
||||
3. **报销主体**:问一次用户的报销抬头(购买方名称),记进记忆条目。此后每张票都比对,用于标记「抬头不符」。
|
||||
4. **解析能力**:不需要额外自检。PDF / OFD / 图片都由 `Read` 直接处理,不需要 Python、不需要安装任何东西。只有生成 `.xlsx` 台账才可能需要额外依赖,见 `invoice-ledger`。
|
||||
|
||||
Preflight 的结论用一段话说给用户听:找到几个邮箱、产物会落在哪个目录。
|
||||
|
||||
### 第 2 步 · 收集(Intake)
|
||||
|
||||
目标:把候选邮件的附件落到 `_inbox/`,并把邮件来源写进 `emails.json`。
|
||||
|
||||
**先确定范围**,永远不要在没有范围的情况下全量拉取。范围三要素:时间区间、可选的发件人、可选的关键词。用户说「上个月」时按当前日期换算成明确起止日期,说给他听再开工。
|
||||
|
||||
**按 provider 选检索通道**(三家能力差别很大,别用同一套写法):
|
||||
|
||||
| provider | 通道 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| Gmail | `POST /api/gmail/messages/fetch?email=..&query=..` | `query` **透传 Gmail 原生搜索语法**,最强的一条通道。带 `pageToken` 游标翻页,`limit` 控制单页条数 |
|
||||
| Gmail(已缓存) | `GET /api/gmail/search?email=..&hasAttachment=true&dateFrom=..&dateTo=..` | 本地缓存搜索,不是 Gmail 搜索。**`q` 实际只按主题过滤**(见下),发件人用独立的 `from` 参数 |
|
||||
| Outlook / IMAP | `GET /api/{outlook,imap}/messages?email=..&offset=..&limit=..&folder=..` | **没有服务端搜索**,只有这三个过滤参数。拉本地缓存后由你自己按主题/发件人/日期过滤 |
|
||||
| Outlook / IMAP(补拉) | `POST /api/{outlook,imap}/messages/fetch?email=..&folder=..` | 缓存不够新或要拉 INBOX 以外的文件夹时先补拉 |
|
||||
|
||||
Gmail 原生查询串的实用写法:
|
||||
|
||||
```
|
||||
has:attachment (发票 OR invoice OR 電子發票 OR 行程单 OR 报销) after:2024/08/01 before:2024/09/01
|
||||
```
|
||||
|
||||
**⚠️ 缓存搜索的 `q` 搜不到正文里的关键词。** 实现上 `q` 先在索引层按**主题**筛一遍,之后那次
|
||||
「正文搜索」只在**已经被主题筛剩下的候选集**上再跑一次——所以**正文命中、主题不命中的邮件永远出不来**。
|
||||
|
||||
发票场景里这条最要命:大量开票平台的主题是「您有一份新的电子凭证」「XX 平台通知」「账单已生成」,
|
||||
「发票」两个字只在正文里。把 `q=发票` 当成搜过正文,就会**静默漏掉这一整批**,而收尾只会说
|
||||
「在给定范围内找到 N 封候选」,用户根本无从发现少了什么。
|
||||
|
||||
要真的覆盖正文关键词:
|
||||
|
||||
- **Gmail**:用 `POST /api/gmail/messages/fetch` 的原生 `query`(Gmail 服务端搜索确实搜正文),
|
||||
不要用缓存搜索的 `q`
|
||||
- **Outlook / IMAP**:没有服务端搜索,只能把范围内的邮件拉回本地,自己逐封扫 `bodyPreview` 与
|
||||
`body.content`。别指望任何一个查询参数替你做这件事
|
||||
- 只用主题筛时,**必须在收尾里说明「本次只按主题匹配关键词」**,别让用户以为搜过正文
|
||||
|
||||
**轮询范围三家不一样,别一律说「只覆盖收件箱」:**
|
||||
|
||||
| provider | 增量轮询实际覆盖 |
|
||||
| --- | --- |
|
||||
| Gmail | `history.list(historyTypes:['messageAdded'])`,**不带 label 过滤 → 整个邮箱** |
|
||||
| Outlook | `/me/messages/delta`,**整个邮箱**(只有 delta 不可用时的降级轮询才只拉 inbox) |
|
||||
| IMAP | **硬编码 `INBOX`**,其它文件夹一概看不到 |
|
||||
|
||||
所以只有 **IMAP** 用户把发票归到别的文件夹时才必须显式
|
||||
`POST /api/imap/messages/fetch?folder=<名字>` 补拉;Gmail / Outlook 不受这条限制。
|
||||
补拉本身**不重放规则**(见 `invoice-automation`)。
|
||||
|
||||
**列表项的字段,三家不一样。** 三家共有的是
|
||||
`{id, subject, from, toRecipients, receivedDateTime, bodyPreview, hasAttachments}`——
|
||||
字段名是 `toRecipients` / `receivedDateTime` / `bodyPreview`,不是 `to` / `date` / `snippet`;
|
||||
`from` 是**对象** `{name?, address}` 而不是字符串,拼发件人时取 `from.address`。
|
||||
|
||||
> **本节的字段说明覆盖市场内置技能 `mail-operations` 里的那一份。** 那份写的是
|
||||
> `to` / `date` / `snippet` / `labels` / `mimeType`,与本平台的实际返回不一致。
|
||||
> 两份同时出现在上下文里时,**以这里为准**。
|
||||
|
||||
差异在这两处:
|
||||
|
||||
| provider | 列表里有没有 `attachments[]` | 标签字段 |
|
||||
| --- | --- | --- |
|
||||
| Gmail | **有**(列表内部逐封用 `format:'full'` 重取,附件元数据已经填好) | `labelIds`(**Gmail 专有**,可选字段) |
|
||||
| IMAP | **有**(列表本来就是逐封拉回源码解析出来的) | 两个都没有 |
|
||||
| Outlook | **没有**(列表的 `$select` 不含 attachments) | `categories`(不是 `labelIds`) |
|
||||
|
||||
所以**只有 Outlook** 需要对每封 `hasAttachments: true` 的邮件再取一次详情;Gmail / IMAP 直接用
|
||||
列表里的 `attachments[]`,省掉一整轮往返。取详情的端点:
|
||||
|
||||
| provider | 详情 |
|
||||
| --- | --- |
|
||||
| Gmail | `GET /api/gmail/messages/{id}?email=..` |
|
||||
| Outlook | `GET /api/outlook/message?id=..&email=..`(注意是单数 `message`,且 id 走查询参数) |
|
||||
| IMAP | `GET /api/imap/messages/{uid}?email=..&folder=..` |
|
||||
|
||||
附件元数据是 `{id, filename, contentType, size}`——是 `contentType`,不是 `mimeType`。
|
||||
|
||||
**下载附件必须带 `save_to`:**
|
||||
|
||||
```
|
||||
MailOperations{
|
||||
path: '/api/gmail/messages/<messageId>/attachment',
|
||||
method: 'POST',
|
||||
body: { email: '<账户>', attachmentId: '<附件 id>' },
|
||||
save_to: '<工作目录>/发票/_inbox/<原文件名>'
|
||||
}
|
||||
```
|
||||
|
||||
不带 `save_to` 时**工具会直接拒绝这次调用**并给出可操作的错误(大意:响应含大块 base64,
|
||||
请用同样的 path/method/body 加上 `save_to` 重来)——不是截断、不是降级,是一次白跑。判据是
|
||||
「路径命中三个附件端点之一 + 响应 2xx + `result.data` 超过 4096 字符」,发票附件普遍 100KB–2MB,
|
||||
必然命中。带上 `save_to` 之后回执直接给绝对路径、字节数和 SHA-256,base64 一个字节都不进上下文。
|
||||
|
||||
各 provider 的下载入参:
|
||||
|
||||
| provider | path | body |
|
||||
| --- | --- | --- |
|
||||
| Gmail | `POST /api/gmail/messages/{messageId}/attachment` | `{email, attachmentId}` |
|
||||
| Outlook | `POST /api/outlook/attachment` | `{email, messageId, attachmentId}` |
|
||||
| IMAP | `POST /api/imap/attachment` | `{email, messageId, attachmentId, folder}` |
|
||||
|
||||
IMAP 的两个坑:`messageId` 用 `"imap:<uid>"` 形式(列表返回的 `id` 就长这样,直接抄)——
|
||||
服务端做的是 `parseInt(messageId.replace('imap:',''), 10)`,裸 UID 其实也接受,但**别自己拼**,
|
||||
用列表给的原值最省事;`attachmentId` 是**数组下标的字符串**(`"0"`、`"1"`),不是文件名。
|
||||
|
||||
**过滤掉内联图片。** Gmail 把签名档里的图片也算成附件。按扩展名(保留 `.pdf` / `.ofd` / `.jpg` / `.jpeg` / `.png`)加大小(小于 20KB 的图片基本都是签名档)先筛一遍,省下大量无谓下载。
|
||||
|
||||
**邮件 id 不在这一步写。** `emails.json` 一旦记下某个 id,收集阶段就永远跳过这封邮件——
|
||||
所以它必须等到**这封邮件的发票记录已经落进 `ledger.json`** 之后才写,见下面的「单份发票的落盘顺序」
|
||||
第 8 步。在下载完就写,中间任何一次崩溃都会让这封邮件的票永久失踪。
|
||||
|
||||
### 第 3 步 · 解析(Extract)
|
||||
|
||||
对 `_inbox/` 里每个文件:
|
||||
|
||||
1. `FileDigest{paths:[...]}` 取 SHA-256(一次可以传最多 100 个路径,批量算比逐个快得多)
|
||||
2. 哈希命中 `.index/files.json` → 直接复用上次结果,**不重复解析**
|
||||
3. 未命中 → 加载 `invoice-extract` 技能,按里面的规则解析,把原始解析输出写到 `.index/raw/<sha256>.json`,并把 `<sha256> → 记录` 写进 `files.json`
|
||||
|
||||
解析结果必须包含 `extractedBy`(`ofd-xml` / `text-layer` / `vision`)与 `confidence`(0–1)。
|
||||
|
||||
### 第 4 步 · 去重(Dedupe)
|
||||
|
||||
**去重主键,按优先级:**
|
||||
|
||||
1. 数电票:`invoiceNumber`(20 位,本身唯一)
|
||||
2. 旧版票:`invoiceCode + invoiceNumber`
|
||||
3. 票面没有 `发票号码:` 标签、但有等价的唯一编号(铁路旧版报销凭证的 21 位电子客票号、
|
||||
航空行程单的电子客票号码):按 `invoice-extract` 的约定把它填进 `invoiceNumber`,走第 1 条
|
||||
4. 确实找不到任何唯一编号(少数手写票、部分定额票):兜底键
|
||||
`sellerTaxId + invoiceDate + totalAmount`。此时 `invoiceNumber` 留空,**不隔离**,
|
||||
但整条记录 `confidence` 上限 0.7 并标「待复核」——兜底键撞车的概率远高于发票号码,
|
||||
同一天、同一家、同一金额的两张真票会被它误判成一张
|
||||
5. 文件 `sha256` 完全相同 → 同一个文件被下载了两次,直接跳过
|
||||
|
||||
**疑似重复不自动合并。** 发票号码只差一位、其余字段全同 → 记进结果里的 `suspectedDuplicates`,收尾时列给用户,让他判断是「真的开了两张」还是「抄错了一位」。
|
||||
|
||||
### 第 5 步 · 归档(Archive)
|
||||
|
||||
按开票日期归到 `归档/<年>/<月>/`,文件名固定格式:
|
||||
|
||||
```
|
||||
<YYYYMMDD>_<销售方名称>_<价税合计>_<发票号码>.<原扩展名>
|
||||
```
|
||||
|
||||
例:`20240815_示范酒店管理有限公司_1959.98_24312000000000020002.pdf`
|
||||
|
||||
销售方名称里的 `/ \ : * ? " < > |` 替换成 `_`,超过 40 个字符截断。目标已存在且 SHA-256 一致 → 静默跳过;哈希不同 → 保留两份(第二份加 `_2` 后缀)并在收尾里提示用户。
|
||||
|
||||
归档是**复制**,不是移动:复制到一半崩溃时 `_inbox/` 里的原件还在,重跑能接上。
|
||||
只有在 `ledger.json` 里这条记录的 `archivedPath` 已经回填并落盘之后,才允许删掉 `_inbox/` 里的原件
|
||||
(顺序见下面的「单份发票的落盘顺序」)。解析失败的移到 `_quarantine/` 并写同名 `.reason.txt`。
|
||||
|
||||
### 第 6 步 · 台账(Ledger)
|
||||
|
||||
加载 `invoice-ledger`。台账**每次全量重建**,数据源是 `.index/ledger.json` 而不是重新解析文件。重建前先把现有台账另存为 `台账.bak.<扩展名>`。
|
||||
|
||||
### 第 7 步 · 报告(Report)
|
||||
|
||||
加载 `invoice-ledger` 里的报告模板,写 `报告/<YYYY-MM>.md`。用户要 PDF 时用 `ExportDocument` 从这份 Markdown 转。
|
||||
|
||||
### 工作目录布局
|
||||
|
||||
```
|
||||
<primary 工作目录>/发票/
|
||||
├── 台账.xlsx(或 台账.csv)
|
||||
├── 报告/2024-08.md
|
||||
├── 归档/2024/08/20240815_示范酒店管理有限公司_1959.98_24312000000000020002.pdf
|
||||
├── _inbox/ 刚下载、尚未处理
|
||||
├── _quarantine/ 解析失败或判定为非发票(+ 同名 .reason.txt)
|
||||
└── .index/
|
||||
├── ledger.json 发票主键 → 记录(主索引)
|
||||
├── emails.json 已处理邮件 id
|
||||
├── files.json 文件 sha256 → 解析结果
|
||||
└── raw/<sha256>.json 单文件原始解析输出
|
||||
```
|
||||
|
||||
产物必须落在**已登记的工作目录**里。Agent 的 AgentFS 私有目录不在文件工作台的可见范围内,别把台账放那儿。
|
||||
|
||||
用 `Write` 落盘,不要用 Bash 重定向——`Write` 会产生「本轮修改了哪些文件」卡片,用户能直接点开台账;Bash 写的文件不会。
|
||||
|
||||
### 单份发票的落盘顺序(硬规则,不许调换)
|
||||
|
||||
`ledger.json` 是唯一事实源——但这句话只有在**它先于任何删除动作落盘**时才成立。
|
||||
每份文件固定按这个顺序走,每一步都各自落盘一次:
|
||||
|
||||
```
|
||||
1. 解析 ← 此时还没有任何持久化
|
||||
2. 写 .index/raw/<sha256>.json ← 原始解析输出
|
||||
3. 写 .index/files.json ← sha256 → 解析结果
|
||||
4. 写 .index/ledger.json ← 记录入账,archivedPath 先留空占位
|
||||
5. 复制到 归档/<年>/<月>/<规范文件名> ← 复制,不是移动
|
||||
6. 回填 ledger.json 的 archivedPath ← 再落盘一次
|
||||
7. 删除 _inbox/ 里的原件 ← 到这一步才允许删
|
||||
8. 写 emails.json 里这封邮件的 id ← 这封邮件的 ledger 记录已落盘才写
|
||||
```
|
||||
|
||||
两条次序写反了会**永久丢记录**,而且没有任何一次重跑能发现:
|
||||
|
||||
- **第 4 步必须早于第 7 步。** 「归档完了、原件删了、ledger 还没写」的窗口里崩溃 → 文件孤零零躺在
|
||||
`归档/2024/08/` 里,`_inbox/` 是空的,`ledger.json` 里没有它,之后每一次重跑都不会再看它一眼。
|
||||
- **第 8 步必须晚于第 4 步。** 邮件 id 一进 `emails.json`,收集阶段就永远跳过这封邮件;此时若
|
||||
ledger 里还没有对应记录,这张票就再也没有第二次入账的机会。
|
||||
|
||||
一封邮件里有多个附件时,逐个附件走完 1–7,全部走完才走第 8 步。
|
||||
|
||||
### 发票记录字段
|
||||
|
||||
**必填**:`invoiceDate`(`YYYY-MM-DD`)、`sellerName`、`totalAmount`(价税合计),
|
||||
外加**一个唯一票据编号**——优先 `invoiceNumber`;没有 `发票号码:` 标签的票种按 `invoice-extract`
|
||||
的约定取替代编号填进 `invoiceNumber`(铁路旧版报销凭证取 21 位电子客票号、航空行程单取电子客票号码)。
|
||||
三者齐全但确实找不到任何唯一编号时,按去重主键第 4 条走兜底键入账并标「待复核」,
|
||||
**不因为「没有发票号码」就隔离**——隔离的判据是「日期 / 销售方 / 价税合计里有抽不到的」。
|
||||
|
||||
**选填**:`invoiceCode`、`invoiceType`、`buyerName`、`buyerTaxId`、`sellerTaxId`、`amountExcludingTax`、`taxAmount`、`taxRate`、`items[]`、`checkCode`、`currency`(默认 `CNY`)、`isVoid`
|
||||
|
||||
**溯源(必填)**:`sourceEmailId`、`sourceEmailSubject`、`sourceFrom`、`sourceReceivedAt`、`sourceAttachmentName`、`fileSha256`、`archivedPath`、`format`(`pdf`/`ofd`/`image`)、`extractedBy`、`confidence`、`extractedAt`
|
||||
|
||||
抽不到的选填字段就留空(`null`),不要用空字符串冒充「有值但为空」,更不要按常见格式补全。
|
||||
|
||||
### 幂等与恢复
|
||||
|
||||
- 索引文件是唯一事实源。每完成一批就落盘一次,不要攒到最后统一写——中途出错时已完成的部分要能保住。
|
||||
- 重跑时先读三个索引,只处理索引里没有的邮件 / 文件。
|
||||
- 台账可以从 `ledger.json` 完整重建。反方向只能**部分**重建:归档目录能还原发票主体字段
|
||||
(文件名里就带日期、销售方、价税合计、发票号码),但溯源字段(来自哪封邮件)恢复不了。
|
||||
所以**永远不要**只改台账不改索引,也不要把归档目录当成 ledger 的等价备份。
|
||||
- 状态**不写记忆条目**。记忆检索是关键词打分且有硬 token 预算,几百张发票必然漏检,定时任务路径还根本不带检索 query。记忆条目只放稳定偏好:报销主体抬头、科目映射、月度出账日、某个供应商单独归类。
|
||||
|
||||
### 判定:这是不是发票
|
||||
|
||||
按顺序,命中即停:
|
||||
|
||||
1. 文本层 / OFD 结构化数据里**同时**出现带标签的发票号码(`发票号码:` 或 OFD 的 `InvoiceNo`)
|
||||
与**一个合计项**——`价税合计` 或 `合计金额` 或(`金额` 与 `税额` 成对出现)→ **是**
|
||||
2. 只命中票据关键词(`铁路电子客票报销凭证` / `航空运输电子客票行程单` / `定额发票` /
|
||||
`出租车` / `网约车` / `通行费` / `客运`)→ **是**,按对应子类型处理
|
||||
3. 出现 `对账单` / `合同` / `报价单` / `账单` 且没有带标签的发票号码 → **否**
|
||||
4. 没有文本层,**或文本层抽出的内容不足以判定**(乱码、字序错乱、只剩零星几个字)→
|
||||
走视觉识别(见 `invoice-extract`),拿到视觉结果再回到第 1 条判定
|
||||
5. 仍不确定 → `_quarantine/`,写清原因,**绝不猜**
|
||||
|
||||
第 1 条的合计项**不能只认 `价税合计` 四个字**:卷式与机打的出租车票、通行费票、部分定额票
|
||||
票面上只写「金额」「合计」,从来不出现「价税合计」(**现在主流的网约车发票不在此列**,那是标准数电票,
|
||||
带完整的价税合计)。只认那一种写法,这批纸质票会一条条掉到第 5 条被隔离,而对外文案承诺支持它们。
|
||||
|
||||
第 4 条**不能只挂在「没有文本层」上**:现实里大量扫描件带着一层劣质 OCR 文字,`Read` 认为这页有文字
|
||||
就不会自动渲染成图,于是永远走不到视觉路径。判据是「抽出来的内容够不够判定」,不是「有没有文字层」。
|
||||
|
||||
第 3 条不能省:对账单正文里会列一整列「关联发票号码」,只按 20 位数字模式匹配一定会误判。
|
||||
判据是**带标签的**发票号码加上一个合计项。
|
||||
|
||||
### 能力边界
|
||||
|
||||
- 不做真伪查验(无官方通道)。只做形式校验:必填字段齐全、`金额 + 税额 = 价税合计`、大小写金额一致、开票日期落在范围内。
|
||||
- 不做记账凭证、不做纳税申报、不做进项抵扣判断。
|
||||
- 不删除、不移动、不转发用户的邮件。
|
||||
- 不做汇率换算。外币结算票的票面仍以人民币计价,`currency` 记 `CNY`,原币金额与汇率照抄进备注。
|
||||
- 收尾只承诺「在给定范围内找到 N 封候选、成功解析 M 张」,不承诺「已全部找到」。
|
||||
|
||||
## L2
|
||||
|
||||
### 增量模式:只处理一封邮件
|
||||
|
||||
邮件规则触发时(见 `invoice-automation`),你拿到的是单封邮件的元数据,没有附件清单。此时跳过第 1 步,从第 2 步的「取详情」开始,只跑这一封,第 6/7 步按需——通常增量只更新 `ledger.json` 与归档,台账等到定时任务或用户主动要求时再重建。
|
||||
|
||||
增量处理完给用户一条短消息即可:`已入账:<销售方> ¥<金额>(<发票号码>),归档到 <路径>`。
|
||||
|
||||
### 多邮箱
|
||||
|
||||
`accounts-with-settings` 返回多个账户时,默认全都扫,收集阶段按账户循环,`emails.json` 的 key 用 `<provider>:<email>:<mailId>` 避免不同账户的 id 撞车。用户明确指定某个邮箱时只扫那个。
|
||||
|
||||
### 多工作目录
|
||||
|
||||
产物固定放 primary 工作目录。用户有多个工作目录(比如按公司主体分)时,让他明确指定这次整理放哪个,不要自己挑,也不要在多个目录里各存一份。
|
||||
|
||||
### 中断恢复
|
||||
|
||||
上一轮跑到一半被中止时,下面三条**每次整理都各扫一遍**,不要等用户报错才查:
|
||||
|
||||
1. **`_inbox/` 残留**:里面是已下载、未解析或解析到一半的文件。直接重跑即可——收集阶段跳过
|
||||
`emails.json` 里已记的邮件,解析阶段把残留重新走一遍「单份发票的落盘顺序」。
|
||||
2. **归档目录反查**:遍历 `归档/**`,把**不在 `ledger.json` 里**的文件挑出来重新入账。
|
||||
这是「落盘顺序」第 4–7 步之间崩溃时唯一的出路——那种文件既不在 `_inbox/`、又不在索引里,
|
||||
不主动扫就永远没人发现。文件名里带着开票日期、销售方、价税合计、发票号码,`FileDigest`
|
||||
再算一次 sha256 就能补齐 `fileSha256` 与 `archivedPath`;溯源字段恢复不了,留空并在收尾里注明。
|
||||
3. **`ledger.json` 里 `archivedPath` 为空的记录**(第 4 步落了盘、第 5/6 步没走完):按
|
||||
`fileSha256` 去 `_inbox/` 和归档目录找回文件,找到就补 `archivedPath`,找不到就在异常表里
|
||||
列成「已入账但原件丢失」。
|
||||
|
||||
如果 `.index/` 整个丢了但归档目录还在:整份索引按第 2 条的办法从归档目录重建,同样只能恢复
|
||||
票面字段,溯源字段留空并在报告里注明。
|
||||
Reference in New Issue
Block a user