Files
echo/src/services/notificationRuleService.js

190 lines
5.9 KiB
JavaScript
Raw Normal View History

2026-03-12 10:04:29 +08:00
import notificationRulesSource from '../config/notificationRules.js'
const fallbackRules = notificationRulesSource.map((rule) => ({
...rule,
merchantPattern: rule.merchantPattern?.pattern
? new RegExp(rule.merchantPattern.pattern, rule.merchantPattern.flags || '')
: null,
}))
const amountPatterns = [
/(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)/i,
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)/,
/(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)/,
/(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)/,
2025-11-27 10:44:27 +08:00
]
let cachedRules = [...fallbackRules]
let remoteRuleFetcher = async () => []
2026-03-12 10:04:29 +08:00
const buildEmptyTransaction = (notification, options = {}) => ({
id: options.id,
merchant: options.merchant || 'Unknown',
category: options.category || 'Uncategorized',
amount: 0,
date: new Date(options.date || notification?.createdAt || new Date().toISOString()).toISOString(),
note: notification?.text || '',
syncStatus: 'pending',
})
2025-11-27 10:44:27 +08:00
export const setRemoteRuleFetcher = (fetcher) => {
remoteRuleFetcher = typeof fetcher === 'function' ? fetcher : remoteRuleFetcher
}
const dedupeRules = (rules) => {
const bucket = new Map()
fallbackRules.forEach((rule) => bucket.set(rule.id, { ...rule }))
rules.forEach((rule) => {
const prev = bucket.get(rule.id) || {}
bucket.set(rule.id, { ...prev, ...rule })
})
return Array.from(bucket.values())
}
export const loadNotificationRules = async () => {
try {
const remote = await remoteRuleFetcher(cachedRules)
if (Array.isArray(remote) && remote.length > 0) {
cachedRules = dedupeRules(remote)
}
} catch (err) {
cachedRules = [...cachedRules]
2026-03-12 10:04:29 +08:00
console.warn('[rules] using cached fallback rules because remote sync failed:', err)
2025-11-27 10:44:27 +08:00
}
return cachedRules
}
export const getCachedNotificationRules = () => cachedRules
const extractAmount = (text = '') => {
2026-03-12 10:04:29 +08:00
const normalized = String(text).replace(/,/g, '').trim()
for (const pattern of amountPatterns) {
const match = normalized.match(pattern)
const value = match?.[1]
if (!value) continue
const parsed = Math.abs(Number.parseFloat(value))
if (Number.isFinite(parsed) && parsed > 0) {
return parsed
}
2025-11-27 10:44:27 +08:00
}
2026-03-12 10:04:29 +08:00
return 0
2025-11-27 10:44:27 +08:00
}
const looksLikeAmount = (raw = '') => {
const value = String(raw).trim()
if (!value) return false
2026-03-12 10:04:29 +08:00
return /^[-\d.,]+\s*(?:元|块|块钱|¥|¥|人民币)?$/i.test(value)
}
2025-11-27 10:44:27 +08:00
const extractMerchant = (text, rule) => {
const content = text || ''
2025-11-27 10:44:27 +08:00
if (rule?.merchantPattern instanceof RegExp) {
2026-03-12 10:04:29 +08:00
const matched = content.match(rule.merchantPattern)
if (matched?.[1]) return matched[1].trim()
2025-11-27 10:44:27 +08:00
}
2026-03-12 10:04:29 +08:00
const routeMatch = content.match(/([\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,})/)
if (routeMatch?.[1]) {
return routeMatch[1].trim()
}
const genericPatterns = [
/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
/商户[:]?\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/,
]
for (const pattern of genericPatterns) {
2026-03-12 10:04:29 +08:00
const matched = content.match(pattern)
if (matched?.[1]) return matched[1].trim()
}
const parenMatch = content.match(/\(([^)]+)\)/)
if (parenMatch?.[1]) {
const inner = parenMatch[1]
2026-03-12 10:04:29 +08:00
const payMatch = inner.match(/(?:支付宝|微信支付|微信)[-\s]*([^\d元¥¥]+)$/)
if (payMatch?.[1]) {
const candidate = payMatch[1].trim()
if (candidate && !looksLikeAmount(candidate)) {
return candidate
}
}
}
const parts = content.split(/|:/)
2025-11-27 10:44:27 +08:00
if (parts.length > 1) {
const candidate = parts[1].split(/[,\s]/)[0]
if (candidate && !looksLikeAmount(candidate)) return candidate.trim()
2025-11-27 10:44:27 +08:00
}
2025-11-27 10:44:27 +08:00
return 'Unknown'
}
const matchRule = (notification, rule) => {
if (!rule || rule.enabled === false) return false
2026-03-12 10:04:29 +08:00
2025-11-27 10:44:27 +08:00
const channel = notification?.channel || ''
const text = notification?.text || ''
const channelHit =
!rule.channels?.length ||
2026-03-12 10:04:29 +08:00
rule.channels.some((item) => channel.includes(item)) ||
(!channel && rule.channels.some((item) => text.includes(item)))
2025-11-27 10:44:27 +08:00
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
2026-03-12 10:04:29 +08:00
const requiredTextHit =
!rule.requiredTextPatterns?.length ||
rule.requiredTextPatterns.some((word) => text.includes(word))
return channelHit && keywordHit && requiredTextHit
2025-11-27 10:44:27 +08:00
}
export const transformNotificationToTransaction = (notification, options = {}) => {
const ruleSet = options.ruleSet?.length ? options.ruleSet : cachedRules
const rule = ruleSet.find((item) => matchRule(notification, item))
2026-03-12 10:04:29 +08:00
if (!rule) {
return {
transaction: buildEmptyTransaction(notification, options),
rule: undefined,
requiresConfirmation: false,
}
}
const amountFromText = extractAmount(notification?.text || '')
if (amountFromText <= 0) {
return {
transaction: buildEmptyTransaction(notification, options),
rule: undefined,
requiresConfirmation: false,
}
}
const direction = options.direction || rule.direction || 'expense'
const normalizedAmount =
direction === 'income' ? Math.abs(amountFromText) : -Math.abs(amountFromText)
let merchant = options.merchant || extractMerchant(notification?.text || '', rule) || 'Unknown'
2026-03-12 10:04:29 +08:00
if (merchant === 'Unknown' && rule.defaultMerchant) {
merchant = rule.defaultMerchant
}
2025-11-27 10:44:27 +08:00
const date = options.date || notification?.createdAt || new Date().toISOString()
const transaction = {
id: options.id,
merchant,
2026-03-12 10:04:29 +08:00
category: options.category || rule.defaultCategory || 'Uncategorized',
amount: normalizedAmount,
2025-11-27 10:44:27 +08:00
date: new Date(date).toISOString(),
note: notification?.text || '',
syncStatus: 'pending',
}
const requiresConfirmation =
options.requiresConfirmation ??
2026-03-12 10:04:29 +08:00
!(rule.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown')
2025-11-27 10:44:27 +08:00
return { transaction, rule, requiresConfirmation }
}