Files
echo/src/services/notificationRuleService.js

238 lines
7.5 KiB
JavaScript
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.

const fallbackRules = [
// 支付宝消费:日常吃饭、网购等
{
id: 'alipay-expense',
label: '支付宝消费',
channels: ['支付宝', '支付', 'Alipay'],
keywords: ['支付', '扣款', '支出', '已消费', '成功支付'],
direction: 'expense',
defaultCategory: 'Food',
autoCapture: false,
merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
},
// 支付宝收款 / 到账
{
id: 'alipay-income',
label: '支付宝收款',
channels: ['支付宝', '支付', 'Alipay'],
keywords: ['到账', '收入', '收款', '入账'],
direction: 'income',
defaultCategory: 'Income',
autoCapture: true,
merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/i,
},
// 微信消费
{
id: 'wechat-expense',
label: '微信消费',
channels: ['微信', '微信支付', 'WeChat'],
keywords: ['支付', '支出', '扣款', '消费成功'],
direction: 'expense',
defaultCategory: 'Groceries',
autoCapture: false,
merchantPattern: /向\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
},
// 微信收款 / 转账入账
{
id: 'wechat-income',
label: '微信收款',
channels: ['微信', '微信支付', 'WeChat'],
keywords: ['收款', '到账', '入账', '转入'],
direction: 'income',
defaultCategory: 'Income',
autoCapture: true,
merchantPattern: /(?:来自|收款方)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
},
// 银行工资 / 常规入账
{
id: 'bank-income',
label: '工资到账',
channels: [
'招商银行',
'中国银行',
'建设银行',
'工商银行',
'农业银行',
'交通银行',
'浦发银行',
'兴业银行',
'光大银行',
'中信银行',
'广发银行',
'平安银行',
],
keywords: ['工资', '薪资', '代发', '到账', '入账', '收入'],
direction: 'income',
defaultCategory: 'Income',
autoCapture: true,
merchantPattern: /(?:来自|付款方|来源)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
},
// 银行消费 / 借记卡扣款
{
id: 'bank-expense',
label: '银行卡支出',
channels: [
'招商银行',
'中国银行',
'建设银行',
'工商银行',
'农业银行',
'交通银行',
'浦发银行',
'兴业银行',
'光大银行',
'中信银行',
'广发银行',
'平安银行',
'借记卡',
'信用卡',
],
keywords: ['支出', '消费', '扣款', '刷卡消费', '银联消费'],
direction: 'expense',
defaultCategory: 'Expense',
autoCapture: false,
merchantPattern: /(?:商户|商家|消费商户)\s*([\u4e00-\u9fa5A-Za-z0-9\s&]+)/,
},
// 手机公交卡 / 乘车码扣款(地铁、公交等),按交通分类
{
id: 'transit-card-expense',
label: '公交出行',
channels: [
'公交',
'地铁',
'乘车码',
'交通联合',
'一卡通',
'北京一卡通',
'上海公共交通卡',
'深圳通',
'苏州公交卡',
'杭州通互联互通卡',
'Mi Pay',
'Huawei Pay',
'华为钱包',
'小米智能卡',
'小米钱包',
'OPPO 钱包',
],
keywords: ['扣款', '支出', '乘车', '刷卡', '过闸','行程中','地铁','公交','进站','出站'],
direction: 'expense',
defaultCategory: 'Transport',
autoCapture: true,
// 公交卡类通知里商户往往不重要,给一个统一名称
defaultMerchant: '公交/地铁出行',
},
]
let cachedRules = [...fallbackRules]
let remoteRuleFetcher = async () => []
/**
* 允许后续接入服务端规则同步,只需在应用启动时调用并注入实际的 fetch 函数
* @param {(currentRules: Array) => Promise<Array>} fetcher
*/
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]
console.warn('[rules] 使用本地规则,远程规则获取失败:', err)
}
return cachedRules
}
export const getCachedNotificationRules = () => cachedRules
// 从通知文本中提取交易金额,兼容「¥」「¥」「元」「人民币」等常见形式
const extractAmount = (text = '') => {
const primary = text.match(
/(?:[¥¥]|人民币)\s*(-?\d+(?:\.\d{1,2})?)|(-?\d+(?:\.\d{1,2})?)\s*元/,
)
const value = primary?.[1] || primary?.[2]
if (value) {
return Math.abs(parseFloat(value))
}
const loose = text.match(/(-?\d+(?:\.\d{1,2})?)/)
return loose?.[1] ? Math.abs(parseFloat(loose[1])) : 0
}
const extractMerchant = (text, rule) => {
if (rule?.merchantPattern instanceof RegExp) {
const m = text.match(rule.merchantPattern)
if (m?.[1]) return m[1].trim()
}
const generic = text.match(/向\s*([\u4e00-\u9fa5A-Za-z0-9\-\s&]+)/)
if (generic?.[1]) return generic[1].trim()
const parts = text.split(/|:/)
if (parts.length > 1) {
const candidate = parts[1].split(/[,\s]/)[0]
if (candidate) return candidate.trim()
}
return 'Unknown'
}
const matchRule = (notification, rule) => {
if (!rule) return false
const channel = notification?.channel || ''
const text = notification?.text || ''
const channelHit = !rule.channels?.length || rule.channels.some((item) => channel.includes(item))
const keywordHit = !rule.keywords?.length || rule.keywords.some((word) => text.includes(word))
return channelHit && keywordHit
}
/**
* 根据当前规则集生成交易草稿,并返回命中的规则信息
* @param {{ id: string, channel: string, text: string, createdAt: string }} notification
* @param {{ ruleSet?: Array, overrides?: object }} options
* @returns {{ transaction: import('../types/transaction').Transaction, rule?: object, requiresConfirmation: boolean }}
*/
export const transformNotificationToTransaction = (notification, options = {}) => {
const ruleSet = options.ruleSet?.length ? options.ruleSet : cachedRules
const rule = ruleSet.find((item) => matchRule(notification, item))
const amountFromText = extractAmount(notification?.text)
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'
if (merchant === 'Unknown' && rule?.defaultMerchant) {
merchant = rule.defaultMerchant
}
const date = options.date || notification?.createdAt || new Date().toISOString()
const transaction = {
id: options.id,
merchant,
category: options.category || rule?.defaultCategory || 'Uncategorized',
amount: Number.isFinite(normalizedAmount) ? normalizedAmount : 0,
date: new Date(date).toISOString(),
note: notification?.text || '',
syncStatus: 'pending',
}
const requiresConfirmation =
options.requiresConfirmation ??
!(rule?.autoCapture && transaction.amount !== 0 && merchant !== 'Unknown')
return { transaction, rule, requiresConfirmation }
}