/** * 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 ?? '' }, }; }