DriftLedger/src/domain/ocr.ts
fengmengqi f6437b83fe feat: 初始化完整应用框架
- 切换到 expo-router 文件路由,删除 App.tsx
- 新增 5 个 Expo 原生插件:ppocr (OCR), accessibility (账单抓取),
  notification-listener, screenshot-monitor, sms-receiver
- 实现核心领域逻辑:billPipeline (账单流水), dedup (去重),
  transferRecognizer (转账识别), ruleEngine + categories (双轨制分类),
  budgets, creditCards, recurring, sync, ocrProcessor
- 增强 ledger.ts:支持 balance assertion, option, pad/note 指令,
  posting 级 metadata, cost/price 解析
- 新增完整 UI:tabs (首页/报表/设置), 交易详情, 预算, 日历热力图,
  分类管理, 信用卡, 定期交易, 规则管理
- 实现 Zustand 状态管理:ledgerStore, importStore, settingsStore,
  metadataStore, automationStore + 持久化
- 新增 AI 功能:chatAssistant, monthlySummary, voiceInput
- 实现多端同步:gitSync, webdavSync, icloudSync
- 新增主题系统 (tokens/presets) 和 i18n (zh/en)
- 添加 30+ 单元测试覆盖核心逻辑
2026-07-15 10:01:31 +08:00

159 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* OCR 自动记账 - Layer 1 规则匹配与账单解析plan.md「3.3 Layer 1」+「3.x BillParser」
*
* 参考 AutoAccounting 的 RuleMatcher微信支付/支付宝/银行卡正则)+ BillParser。
* 本模块为纯函数,不依赖原生,可单测;输入是 OCR 识别文本,输出是 ImportedEvent。
*/
import type { Direction, ImportedEvent } from './types';
/** 规则匹配结果。 */
export interface OcrRuleMatch {
matched: boolean;
event: ImportedEvent | null;
/** 命中的规则名(微信/支付宝/银行卡/未知)。 */
ruleName: string;
}
interface OcrRule {
name: string;
/** 主匹配模式(判断是否为该类账单)。 */
pattern: RegExp;
/** 字段提取器(金额/商户/时间/卡号)。 */
extractors: {
amount?: RegExp;
merchant?: RegExp;
time?: RegExp;
card?: RegExp;
direction?: RegExp;
};
/** 渠道名。 */
channel: string;
}
// 参考 AutoAccounting RuleMatcher 的正则模式
const RULES: OcrRule[] = [
{
name: '微信支付',
pattern: /微信支付/,
extractors: {
amount: /(?:金额|支付金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/,
merchant: /(?:商户|收款方|对方)\s*[:]?\s*([^\s,,。]+)/,
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
direction: /(收入|支出|收款|付款|退款|转账|消费)/,
},
channel: 'WeChat',
},
{
name: '支付宝',
pattern: /支付宝|alipay/i,
extractors: {
amount: /(?:金额|支付金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/,
merchant: /(?:收款方|对方|商户)\s*[:]?\s*([^\s,,。]+)/,
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
direction: /(收入|支出|收款|付款|退款|转账|消费)/,
},
channel: 'Alipay',
},
{
name: '银行卡',
pattern: /尾号\d{4}|信用卡|储蓄卡|借记卡/,
extractors: {
amount: /(?:消费|支出|金额|¥|¥)\s*(-?\d+(?:\.\d+)?)/,
merchant: /(?:商户|对方|于)\s*[:]?\s*([^\s,,。]+)/,
time: /(\d{4}[-/]\d{1,2}[-/]\d{1,2}[\s]+\d{1,2}:\d{2}(?::\d{2})?)/,
card: /尾号\s*(\d{4})/,
direction: /(消费|支出|收入|转入|转出|退款|转账)/,
},
channel: 'Bank',
},
];
function parseDirection(text: string, defaultDir: Direction = 'expense'): Direction {
// 优先级:退款 > 手续费 > 转账 > 支出 > 收入
// 注意:避免「收款方」中的「收款」被误判为收入,需检查完整词
if (/退款|退回/.test(text)) return 'refund';
if (/手续费|利息/.test(text)) return 'fee';
if (/支出|付款|消费|转出/.test(text)) return 'expense';
if (/转账|转入/.test(text)) return 'transfer';
if (/收入|到账|入账/.test(text)) return 'income';
// 「收款方」是商户标识,不判为收入;真正的收款是「向您收款」或独立「收款」
if (/收款方/.test(text)) return defaultDir; // 收款方是商户,保持默认
if (/收款/.test(text) && !/收款方/.test(text)) return 'income';
return defaultDir;
}
function extract(pattern: RegExp | undefined, text: string): string {
if (!pattern) return '';
return pattern.exec(text)?.[1] ?? '';
}
/**
* Layer 1 规则匹配plan.md「3.3」)。
* 快速、无成本:对 OCR 文本跑预定义正则,命中即提取。
*/
export function matchOcrRule(text: string, packageName?: string): OcrRuleMatch {
for (const rule of RULES) {
if (!rule.pattern.test(text)) continue;
const amountRaw = extract(rule.extractors.amount, text);
if (!amountRaw) continue; // 无金额不算账单
const merchant = extract(rule.extractors.merchant, text);
const time = extract(rule.extractors.time, text);
const card = extract(rule.extractors.card, text);
const dirText = extract(rule.extractors.direction, text) || text;
// 方向解析用完整文本(避免「收款方」中的「收款」被误判)
const direction = parseDirection(text);
const amount = direction === 'expense' ? `-${Math.abs(parseFloat(amountRaw))}` : String(Math.abs(parseFloat(amountRaw)));
const event: ImportedEvent = {
id: `ocr:${rule.channel}:${time || Date.now()}:${amountRaw}`,
occurredAt: time ? normalizeTime(time) : new Date().toISOString().slice(0, 16),
amount,
currency: 'CNY',
direction,
channel: card ? `${rule.channel}:${card}` : rule.channel,
counterparty: merchant,
memo: card ? `银行卡尾号${card}` : rule.name,
raw: { text, packageName: packageName ?? '' },
};
return { matched: true, event, ruleName: rule.name };
}
return { matched: false, event: null, ruleName: '未匹配' };
}
/** 规范化时间格式(支持 2026-07-13 14:30 / 2026/7/13 14:30。 */
function normalizeTime(time: string): string {
return time.replace(/\//g, '-').replace(/-(\d)-/g, '-0$1-').replace(/-(\d) /, '-0$1 ').trim();
}
/**
* BillParserplan.md「3.x」解析 OCR 全文本为账单事件。
* 比 RuleMatcher 更宽松——尝试从任意文本中提取金额与方向。
*/
export function parseOcrBill(text: string, packageName?: string): ImportedEvent | null {
// 1. 先尝试规则匹配
const ruleMatch = matchOcrRule(text, packageName);
if (ruleMatch.matched && ruleMatch.event) return ruleMatch.event;
// 2. 退化:提取首个金额 + 推断方向
const amountMatch = text.match(/(?:¥|¥|金额|支付金额)\s*(\d+(?:\.\d+)?)/) ?? text.match(/(\d+(?:\.\d+)?)\s*(?:元)/);
if (!amountMatch) return null;
const amount = parseFloat(amountMatch[1]);
const direction = parseDirection(text);
return {
id: `ocr:fallback:${Date.now()}:${amountMatch[1]}`,
occurredAt: new Date().toISOString().slice(0, 16),
amount: direction === 'expense' ? `-${amount}` : String(amount),
currency: 'CNY',
direction,
channel: packageName ?? 'Unknown',
counterparty: '',
memo: 'OCR 退化解析',
raw: { text, packageName: packageName ?? '' },
};
}