feat:新增ai分析,新增储值账户,优化通知规则
This commit is contained in:
@@ -1,24 +1,22 @@
|
||||
package com.echo.app.notification
|
||||
package com.echo.app.notification
|
||||
|
||||
import android.content.Context
|
||||
import android.database.Cursor
|
||||
import android.database.sqlite.SQLiteDatabase
|
||||
import android.database.sqlite.SQLiteOpenHelper
|
||||
import android.util.Log
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.locks.ReentrantLock
|
||||
import kotlin.concurrent.withLock
|
||||
import kotlin.math.abs
|
||||
|
||||
// 原生 SQLite 访问封装:与 @capacitor-community/sqlite 共用同一个 echo_local 数据库
|
||||
// Native SQLite access shared with @capacitor-community/sqlite.
|
||||
object NotificationDb {
|
||||
|
||||
private const val TAG = "NotificationDb"
|
||||
|
||||
// 注意:前端通过 @capacitor-community/sqlite 使用的 DB 名为 "echo_local"
|
||||
// 插件在 Android 上实际创建的文件名为 "<dbName>SQLite.db"
|
||||
private const val DB_FILE_NAME = "echo_localSQLite.db"
|
||||
private const val DB_VERSION = 1
|
||||
|
||||
// transactions 表结构需与 src/lib/sqlite.js 保持完全一致
|
||||
private const val SQL_CREATE_TRANSACTIONS = """
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -27,11 +25,24 @@ object NotificationDb {
|
||||
category TEXT DEFAULT 'Uncategorized',
|
||||
date TEXT NOT NULL,
|
||||
note TEXT,
|
||||
sync_status INTEGER DEFAULT 0
|
||||
sync_status INTEGER DEFAULT 0,
|
||||
entry_type TEXT DEFAULT 'expense',
|
||||
fund_source_type TEXT DEFAULT 'cash',
|
||||
fund_source_name TEXT,
|
||||
fund_target_type TEXT DEFAULT 'merchant',
|
||||
fund_target_name TEXT,
|
||||
impact_expense INTEGER DEFAULT 1,
|
||||
impact_income INTEGER DEFAULT 0,
|
||||
ai_category TEXT,
|
||||
ai_tags TEXT,
|
||||
ai_confidence REAL,
|
||||
ai_reason TEXT,
|
||||
ai_status TEXT DEFAULT 'idle',
|
||||
ai_model TEXT,
|
||||
ai_normalized_merchant TEXT
|
||||
);
|
||||
"""
|
||||
|
||||
// notifications_raw 表:用于调试与规则命中记录
|
||||
private const val SQL_CREATE_NOTIFICATIONS_RAW = """
|
||||
CREATE TABLE IF NOT EXISTS notifications_raw (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -47,18 +58,33 @@ object NotificationDb {
|
||||
);
|
||||
"""
|
||||
|
||||
// 简单 SQLiteOpenHelper:主要用于定位与创建数据库文件
|
||||
private val TRANSACTION_REQUIRED_COLUMNS = linkedMapOf(
|
||||
"entry_type" to "TEXT DEFAULT 'expense'",
|
||||
"fund_source_type" to "TEXT DEFAULT 'cash'",
|
||||
"fund_source_name" to "TEXT",
|
||||
"fund_target_type" to "TEXT DEFAULT 'merchant'",
|
||||
"fund_target_name" to "TEXT",
|
||||
"impact_expense" to "INTEGER DEFAULT 1",
|
||||
"impact_income" to "INTEGER DEFAULT 0",
|
||||
"ai_category" to "TEXT",
|
||||
"ai_tags" to "TEXT",
|
||||
"ai_confidence" to "REAL",
|
||||
"ai_reason" to "TEXT",
|
||||
"ai_status" to "TEXT DEFAULT 'idle'",
|
||||
"ai_model" to "TEXT",
|
||||
"ai_normalized_merchant" to "TEXT",
|
||||
)
|
||||
|
||||
private class EchoDbHelper(context: Context) :
|
||||
SQLiteOpenHelper(context, DB_FILE_NAME, null, DB_VERSION) {
|
||||
|
||||
override fun onCreate(db: SQLiteDatabase) {
|
||||
// 正常情况下,数据表由前端首次启动时创建;此处作为兜底
|
||||
db.execSQL(SQL_CREATE_TRANSACTIONS)
|
||||
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
|
||||
}
|
||||
|
||||
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
|
||||
// 当前仅有 v1,暂不实现升级逻辑;未来如有版本变更再补充
|
||||
// No-op for now.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,22 +95,37 @@ object NotificationDb {
|
||||
private var schemaInitialized = false
|
||||
|
||||
private fun getHelper(context: Context): EchoDbHelper {
|
||||
// 使用 applicationContext 避免泄漏 Activity
|
||||
val appContext = context.applicationContext
|
||||
return helper ?: synchronized(this) {
|
||||
helper ?: EchoDbHelper(appContext).also { helper = it }
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureTableColumns(db: SQLiteDatabase, tableName: String, requiredColumns: Map<String, String>) {
|
||||
val existingColumns = mutableSetOf<String>()
|
||||
val cursor: Cursor = db.rawQuery("PRAGMA table_info($tableName)", null)
|
||||
cursor.use {
|
||||
val nameIndex = it.getColumnIndex("name")
|
||||
while (it.moveToNext()) {
|
||||
existingColumns += it.getString(nameIndex)
|
||||
}
|
||||
}
|
||||
|
||||
requiredColumns.forEach { (columnName, definition) ->
|
||||
if (!existingColumns.contains(columnName)) {
|
||||
db.execSQL("ALTER TABLE $tableName ADD COLUMN $columnName $definition")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun ensureSchema(db: SQLiteDatabase) {
|
||||
if (schemaInitialized) return
|
||||
// 多线程下可能执行多次,但都是幂等的 IF NOT EXISTS,性能成本极低
|
||||
db.execSQL(SQL_CREATE_TRANSACTIONS)
|
||||
db.execSQL(SQL_CREATE_NOTIFICATIONS_RAW)
|
||||
ensureTableColumns(db, "transactions", TRANSACTION_REQUIRED_COLUMNS)
|
||||
schemaInitialized = true
|
||||
}
|
||||
|
||||
// 用于规则引擎输入的原始通知模型
|
||||
data class RawNotification(
|
||||
val sourceId: String,
|
||||
val packageName: String,
|
||||
@@ -94,30 +135,19 @@ object NotificationDb {
|
||||
val postedAt: String
|
||||
)
|
||||
|
||||
/**
|
||||
* 处理单条通知:
|
||||
* 1. 写入 notifications_raw,初始 status = 'unmatched'
|
||||
* 2. 调用 NotificationRuleEngine 做规则解析
|
||||
* 3. 命中规则时:
|
||||
* - 写入一条 transactions 记录
|
||||
* - 更新 notifications_raw.status = 'matched',补充 rule_id、transaction_id
|
||||
* 4. 解析异常时:
|
||||
* - 更新 notifications_raw.status = 'error',写入 error_message
|
||||
*/
|
||||
fun processAndStoreNotification(context: Context, raw: RawNotification) {
|
||||
lock.withLock {
|
||||
val db = getHelper(context).writableDatabase
|
||||
ensureSchema(db)
|
||||
|
||||
val rawId = UUID.randomUUID().toString()
|
||||
var status = "unmatched"
|
||||
var ruleId: String? = null
|
||||
var transactionId: String? = null
|
||||
var errorMessage: String? = null
|
||||
val rawId = UUID.randomUUID().toString()
|
||||
var status = "unmatched"
|
||||
var ruleId: String? = null
|
||||
var transactionId: String? = null
|
||||
var errorMessage: String? = null
|
||||
|
||||
db.beginTransaction()
|
||||
try {
|
||||
// 写入 notifications_raw(初始 unmatched)
|
||||
db.compileStatement(
|
||||
"""
|
||||
INSERT INTO notifications_raw (
|
||||
@@ -127,16 +157,8 @@ object NotificationDb {
|
||||
).apply {
|
||||
bindString(1, rawId)
|
||||
bindString(2, raw.sourceId)
|
||||
if (raw.channel != null) {
|
||||
bindString(3, raw.channel)
|
||||
} else {
|
||||
bindNull(3)
|
||||
}
|
||||
if (raw.title != null) {
|
||||
bindString(4, raw.title)
|
||||
} else {
|
||||
bindNull(4)
|
||||
}
|
||||
if (raw.channel != null) bindString(3, raw.channel) else bindNull(3)
|
||||
if (raw.title != null) bindString(4, raw.title) else bindNull(4)
|
||||
bindString(5, raw.text)
|
||||
bindString(6, raw.postedAt)
|
||||
bindString(7, status)
|
||||
@@ -144,34 +166,37 @@ object NotificationDb {
|
||||
close()
|
||||
}
|
||||
|
||||
// 调用规则引擎
|
||||
val parsed = NotificationRuleEngine.parse(raw)
|
||||
|
||||
if (parsed != null) {
|
||||
ruleId = parsed.ruleId
|
||||
|
||||
// 与 JS 侧逻辑保持一致:
|
||||
// requiresConfirmation = !(autoCapture && amount!=0 && merchant!='Unknown')
|
||||
val hasAmount = parsed.amount != 0.0
|
||||
val merchantKnown =
|
||||
parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
|
||||
val merchantKnown = parsed.merchant.isNotBlank() && parsed.merchant != "Unknown"
|
||||
val requiresConfirmation = !(parsed.autoCapture && hasAmount && merchantKnown)
|
||||
|
||||
if (!requiresConfirmation) {
|
||||
// 统一按照设计文档:支出为负数,收入为正数
|
||||
val signedAmount = if (parsed.isExpense) {
|
||||
-kotlin.math.abs(parsed.amount)
|
||||
} else {
|
||||
kotlin.math.abs(parsed.amount)
|
||||
}
|
||||
val signedAmount = if (parsed.isExpense) -abs(parsed.amount) else abs(parsed.amount)
|
||||
transactionId = UUID.randomUUID().toString()
|
||||
status = "matched"
|
||||
|
||||
db.compileStatement(
|
||||
"""
|
||||
INSERT INTO transactions (
|
||||
id, amount, merchant, category, date, note, sync_status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
id,
|
||||
amount,
|
||||
merchant,
|
||||
category,
|
||||
date,
|
||||
note,
|
||||
sync_status,
|
||||
entry_type,
|
||||
fund_source_type,
|
||||
fund_source_name,
|
||||
fund_target_type,
|
||||
fund_target_name,
|
||||
impact_expense,
|
||||
impact_income
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent()
|
||||
).apply {
|
||||
bindString(1, transactionId)
|
||||
@@ -179,23 +204,21 @@ object NotificationDb {
|
||||
bindString(3, parsed.merchant)
|
||||
bindString(4, parsed.category)
|
||||
bindString(5, raw.postedAt)
|
||||
if (parsed.note != null) {
|
||||
bindString(6, parsed.note)
|
||||
} else {
|
||||
bindNull(6)
|
||||
}
|
||||
// sync_status: 0 = pending(本地生成,尚未同步)
|
||||
if (parsed.note != null) bindString(6, parsed.note) else bindNull(6)
|
||||
bindLong(7, 0L)
|
||||
bindString(8, parsed.entryType)
|
||||
bindString(9, parsed.fundSourceType)
|
||||
bindString(10, parsed.fundSourceName)
|
||||
bindString(11, parsed.fundTargetType)
|
||||
bindString(12, parsed.fundTargetName)
|
||||
bindLong(13, if (parsed.impactExpense) 1L else 0L)
|
||||
bindLong(14, if (parsed.impactIncome) 1L else 0L)
|
||||
executeInsert()
|
||||
close()
|
||||
}
|
||||
} else {
|
||||
// 保持 status = 'unmatched',但记录命中的 rule_id,方便前端调试视图展示“有推荐规则”
|
||||
status = "unmatched"
|
||||
}
|
||||
}
|
||||
|
||||
// 根据结果更新 notifications_raw 状态
|
||||
db.compileStatement(
|
||||
"""
|
||||
UPDATE notifications_raw
|
||||
@@ -204,21 +227,9 @@ object NotificationDb {
|
||||
""".trimIndent()
|
||||
).apply {
|
||||
bindString(1, status)
|
||||
if (ruleId != null) {
|
||||
bindString(2, ruleId)
|
||||
} else {
|
||||
bindNull(2)
|
||||
}
|
||||
if (transactionId != null) {
|
||||
bindString(3, transactionId)
|
||||
} else {
|
||||
bindNull(3)
|
||||
}
|
||||
if (errorMessage != null) {
|
||||
bindString(4, errorMessage)
|
||||
} else {
|
||||
bindNull(4)
|
||||
}
|
||||
if (ruleId != null) bindString(2, ruleId) else bindNull(2)
|
||||
if (transactionId != null) bindString(3, transactionId) else bindNull(3)
|
||||
if (errorMessage != null) bindString(4, errorMessage) else bindNull(4)
|
||||
bindString(5, rawId)
|
||||
executeUpdateDelete()
|
||||
close()
|
||||
@@ -226,8 +237,7 @@ object NotificationDb {
|
||||
|
||||
db.setTransactionSuccessful()
|
||||
} catch (e: Exception) {
|
||||
// 若解析阶段抛异常,尽量在同一事务中回写 error 状态
|
||||
Log.e(TAG, "处理通知并写入 SQLite 失败", e)
|
||||
Log.e(TAG, "processAndStoreNotification failed", e)
|
||||
status = "error"
|
||||
errorMessage = e.message ?: e.toString()
|
||||
try {
|
||||
@@ -239,13 +249,13 @@ object NotificationDb {
|
||||
""".trimIndent()
|
||||
).apply {
|
||||
bindString(1, status)
|
||||
bindString(2, errorMessage!!)
|
||||
bindString(2, errorMessage)
|
||||
bindString(3, rawId)
|
||||
executeUpdateDelete()
|
||||
close()
|
||||
}
|
||||
} catch (inner: Exception) {
|
||||
Log.e(TAG, "更新 notifications_raw 错误状态失败", inner)
|
||||
Log.e(TAG, "failed to update notifications_raw error state", inner)
|
||||
}
|
||||
} finally {
|
||||
db.endTransaction()
|
||||
|
||||
@@ -10,6 +10,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("支付", "扣款", "支出", "消费", "成功支付"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "expense",
|
||||
fundSourceType = "cash",
|
||||
fundSourceName = "支付宝余额",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "merchant",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = true,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Food",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
|
||||
@@ -22,6 +31,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("到账", "收入", "收款", "入账"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
entryType = "income",
|
||||
fundSourceType = "external",
|
||||
fundSourceName = "外部转入",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "cash",
|
||||
fundTargetName = "支付宝余额",
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = false,
|
||||
impactIncome = true,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)", setOf(RegexOption.IGNORE_CASE)),
|
||||
@@ -34,6 +52,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("支付", "支出", "扣款", "消费成功"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "expense",
|
||||
fundSourceType = "cash",
|
||||
fundSourceName = "微信余额",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "merchant",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = true,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Groceries",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:向|收款方|商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
@@ -46,6 +73,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("收款", "到账", "入账", "转入"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
entryType = "income",
|
||||
fundSourceType = "external",
|
||||
fundSourceName = "外部转入",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "cash",
|
||||
fundTargetName = "微信余额",
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = false,
|
||||
impactIncome = true,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|收款方|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
@@ -58,6 +94,15 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("工资", "薪资", "代发", "到账", "入账", "收入"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
entryType = "income",
|
||||
fundSourceType = "external",
|
||||
fundSourceName = "外部转入",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "bank",
|
||||
fundTargetName = "银行卡",
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = false,
|
||||
impactIncome = true,
|
||||
defaultCategory = "Income",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("(?:来自|付款方|来源)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
@@ -70,11 +115,62 @@ internal object NotificationRuleData {
|
||||
keywords = listOf("支出", "消费", "扣款", "刷卡消费", "银联消费"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "expense",
|
||||
fundSourceType = "bank",
|
||||
fundSourceName = "银行卡",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "merchant",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = true,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Expense",
|
||||
autoCapture = false,
|
||||
merchantPattern = Regex("(?:商户|商家|消费商户)\\s*([\\u4e00-\\u9fa5A-Za-z0-9\\s&-]+)"),
|
||||
defaultMerchant = null,
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "stored-value-topup",
|
||||
label = "储值账户充值",
|
||||
channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"),
|
||||
keywords = listOf("充值", "充值成功", "充值到账", "钱包充值", "卡内充值"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "transfer",
|
||||
fundSourceType = "cash",
|
||||
fundSourceName = "现金账户",
|
||||
fundSourceNameFromChannel = false,
|
||||
fundTargetType = "stored_value",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = true,
|
||||
impactExpense = false,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Transfer",
|
||||
autoCapture = true,
|
||||
merchantPattern = null,
|
||||
defaultMerchant = "储值账户充值",
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "stored-value-refund",
|
||||
label = "储值账户退款",
|
||||
channels = listOf("杭州通互联互通卡", "杭州通", "北京一卡通", "上海公共交通卡", "深圳通", "苏州公交卡", "交通联合", "乘车码", "地铁", "公交", "Mi Pay", "Huawei Pay", "华为钱包", "小米钱包", "OPPO 钱包"),
|
||||
keywords = listOf("退款", "退资", "退卡", "余额退回", "退款成功"),
|
||||
requiredTextPatterns = emptyList(),
|
||||
direction = NotificationRuleDirection.INCOME,
|
||||
entryType = "transfer",
|
||||
fundSourceType = "stored_value",
|
||||
fundSourceName = null,
|
||||
fundSourceNameFromChannel = true,
|
||||
fundTargetType = "cash",
|
||||
fundTargetName = "现金账户",
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = false,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Transfer",
|
||||
autoCapture = true,
|
||||
merchantPattern = null,
|
||||
defaultMerchant = "储值账户退款",
|
||||
),
|
||||
NotificationRuleDefinition(
|
||||
id = "transit-card-expense",
|
||||
label = "公交出行",
|
||||
@@ -82,6 +178,15 @@ internal object NotificationRuleData {
|
||||
keywords = emptyList(),
|
||||
requiredTextPatterns = listOf("扣费", "扣款", "支付", "乘车码", "车费", "票价", "本次乘车", "实付", "优惠后"),
|
||||
direction = NotificationRuleDirection.EXPENSE,
|
||||
entryType = "expense",
|
||||
fundSourceType = "stored_value",
|
||||
fundSourceName = null,
|
||||
fundSourceNameFromChannel = true,
|
||||
fundTargetType = "merchant",
|
||||
fundTargetName = null,
|
||||
fundTargetNameFromChannel = false,
|
||||
impactExpense = true,
|
||||
impactIncome = false,
|
||||
defaultCategory = "Transport",
|
||||
autoCapture = true,
|
||||
merchantPattern = Regex("([\\u4e00-\\u9fa5]{2,}\\s*(?:-|->|→|至|到)\\s*[\\u4e00-\\u9fa5]{2,})"),
|
||||
|
||||
@@ -14,6 +14,15 @@ internal data class NotificationRuleDefinition(
|
||||
val keywords: List<String> = emptyList(),
|
||||
val requiredTextPatterns: List<String> = emptyList(),
|
||||
val direction: NotificationRuleDirection,
|
||||
val entryType: String = "expense",
|
||||
val fundSourceType: String = "cash",
|
||||
val fundSourceName: String? = null,
|
||||
val fundSourceNameFromChannel: Boolean = false,
|
||||
val fundTargetType: String = "merchant",
|
||||
val fundTargetName: String? = null,
|
||||
val fundTargetNameFromChannel: Boolean = false,
|
||||
val impactExpense: Boolean = true,
|
||||
val impactIncome: Boolean = false,
|
||||
val defaultCategory: String,
|
||||
val autoCapture: Boolean,
|
||||
val merchantPattern: Regex? = null,
|
||||
@@ -30,6 +39,13 @@ object NotificationRuleEngine {
|
||||
val category: String,
|
||||
val amount: Double,
|
||||
val isExpense: Boolean,
|
||||
val entryType: String,
|
||||
val fundSourceType: String,
|
||||
val fundSourceName: String,
|
||||
val fundTargetType: String,
|
||||
val fundTargetName: String,
|
||||
val impactExpense: Boolean,
|
||||
val impactIncome: Boolean,
|
||||
val note: String?,
|
||||
val autoCapture: Boolean,
|
||||
)
|
||||
@@ -60,11 +76,24 @@ object NotificationRuleEngine {
|
||||
category = rule.defaultCategory.ifBlank { "Uncategorized" },
|
||||
amount = amount,
|
||||
isExpense = rule.direction == NotificationRuleDirection.EXPENSE,
|
||||
entryType = rule.entryType,
|
||||
fundSourceType = rule.fundSourceType,
|
||||
fundSourceName = resolveLedgerName(raw.channel, rule.fundSourceName, rule.fundSourceNameFromChannel),
|
||||
fundTargetType = rule.fundTargetType,
|
||||
fundTargetName = resolveLedgerName(raw.channel, rule.fundTargetName, rule.fundTargetNameFromChannel)
|
||||
.ifBlank { if (rule.entryType == "expense") merchant else "" },
|
||||
impactExpense = rule.impactExpense,
|
||||
impactIncome = rule.impactIncome,
|
||||
note = content.take(200),
|
||||
autoCapture = rule.autoCapture,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveLedgerName(channel: String?, configuredName: String?, fromChannel: Boolean): String {
|
||||
if (fromChannel && !channel.isNullOrBlank()) return channel
|
||||
return configuredName.orEmpty()
|
||||
}
|
||||
|
||||
private fun matchRule(
|
||||
raw: NotificationDb.RawNotification,
|
||||
text: String,
|
||||
@@ -80,17 +109,25 @@ object NotificationRuleEngine {
|
||||
val keywordHit =
|
||||
rule.keywords.isEmpty() || rule.keywords.any { text.contains(it) }
|
||||
val requiredTextHit =
|
||||
rule.requiredTextPatterns.isEmpty() || rule.requiredTextPatterns.any { text.contains(it) }
|
||||
rule.requiredTextPatterns.isEmpty() ||
|
||||
rule.requiredTextPatterns.any { text.contains(it) } ||
|
||||
hasTransitRouteAmountFallback(rule, text)
|
||||
|
||||
return channelHit && keywordHit && requiredTextHit
|
||||
}
|
||||
|
||||
private fun hasTransitRouteAmountFallback(rule: NotificationRuleDefinition, text: String): Boolean {
|
||||
if (rule.id != "transit-card-expense") return false
|
||||
return Regex("""[\u4e00-\u9fa5]{2,}\s*(?:-|->|→|至|到)\s*[\u4e00-\u9fa5]{2,}[::]?\s*\d+(?:\.\d{1,2})?\s*(?:元|块|块钱)""")
|
||||
.containsMatchIn(text)
|
||||
}
|
||||
|
||||
private fun extractAmount(text: String): Double {
|
||||
val normalized = text.replace(",", "").trim()
|
||||
val patterns = listOf(
|
||||
Regex("""(?:¥|¥|RMB|CNY|人民币)\s*(-?\d+(?:\.\d{1,2})?)""", RegexOption.IGNORE_CASE),
|
||||
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)"""),
|
||||
Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""),
|
||||
Regex("""(?:金额|实付|扣费|扣款|消费|支出|收入|收款|到账|入账|转入|转出|支付|票价|车费|本次乘车|充值)\D{0,8}?(-?\d+(?:\.\d{1,2})?)"""),
|
||||
Regex("""(-?\d+(?:\.\d{1,2})?)\s*(?:元|块|块钱)?\s*(?:已支付|已收款|到账|入账)"""),
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user