- 切换到 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+ 单元测试覆盖核心逻辑
159 lines
5.9 KiB
TypeScript
159 lines
5.9 KiB
TypeScript
/**
|
||
* 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();
|
||
}
|
||
|
||
/**
|
||
* BillParser(plan.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 ?? '' },
|
||
};
|
||
}
|