From 6c209d8781ceaaddb2cd15badb28faed9766d432 Mon Sep 17 00:00:00 2001 From: Jafeng <2998840497@qq.com> Date: Thu, 12 Mar 2026 15:09:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E8=B4=A6=E5=8D=95?= =?UTF-8?q?=E5=AF=BC=E5=87=BA=E3=80=81=E6=94=AF=E4=BB=98=E5=AE=9D=E8=B4=A6?= =?UTF-8?q?=E5=8D=95=E5=AF=BC=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 52 ++- scripts/release.cjs | 28 +- scripts/sync-version.cjs | 6 +- src/lib/sqlite.js | 49 ++- src/services/dataTransferService.js | 495 ++++++++++++++++++++++++++++ src/services/transactionService.js | 118 ++++++- src/views/HomeView.vue | 24 +- src/views/ListView.vue | 42 +-- src/views/SettingsView.vue | 141 +++++++- 9 files changed, 886 insertions(+), 69 deletions(-) create mode 100644 src/services/dataTransferService.js diff --git a/README.md b/README.md index ffd13c1..2105b8f 100644 --- a/README.md +++ b/README.md @@ -1,24 +1,52 @@ -# Echo +# Echo -Local-first personal finance app built with Vue 3, Vite, Capacitor, and SQLite. +Echo 是一个本地优先的个人记账 App,当前重点是自动记账、储值账户语义、AI 分类与本地账本分析。 -## Commands +## 当前文档入口 + +- 主说明:[design/README.md](design/README.md) +- 项目现状与阶段规划:[design/echo-阶段总结与规划.md](design/echo-%E9%98%B6%E6%AE%B5%E6%80%BB%E7%BB%93%E4%B8%8E%E8%A7%84%E5%88%92.md) +- 设计/规划整理结论:[design/设计整理-2026-03-12.md](design/%E8%AE%BE%E8%AE%A1%E6%95%B4%E7%90%86-2026-03-12.md) +- 版本与协作约定:[design/版本与协作约定.md](design/%E7%89%88%E6%9C%AC%E4%B8%8E%E5%8D%8F%E4%BD%9C%E7%BA%A6%E5%AE%9A.md) + +## 常用命令 ```bash -npm run rules:sync npm run dev npm run build +npm run rules:sync npm run rule:smoke +npm run version:sync +npm run release:patch +npm run release:minor +npm run release:major ``` -## Structure +## 版本管理 -- `src/`: web app, stores, services, views, and SQLite bridge -- `android/`: Capacitor Android shell and notification listener implementation -- `scripts/`: release and local verification scripts -- `src/config/notificationRules.js`: single source of truth for notification rules +当前版本管理机制保留这套: +- `package.json.version` 是单一版本源。 +- `npm run version:sync` 会同步 Android `versionCode/versionName`。 +- `npm run release:*` 负责 bump 版本、同步 Android、提交 release commit 并打 tag。 -## Notes +建议按下面的方式使用: +- 日常开发不要直接在 `main` 上堆改动,开 `feat/*` 或 `fix/*` 分支。 +- `main` 只保留已验证、准备发版的内容。 +- release 必须在干净工作区执行。 -- Transaction data is stored locally. -- After editing notification rules, run `npm run rules:sync` to regenerate the Android rule file. +当前仓库已经加了两条保护: +- 只能在 `main` 分支执行 release。 +- 工作区有未提交改动时禁止 release。 + +## 目录说明 + +- `src/`:前端页面、状态、服务、SQLite 与 AI/通知逻辑。 +- `android/`:Capacitor Android 工程和通知监听原生实现。 +- `scripts/`:规则同步、版本同步、发布脚本。 +- `design/`:当前说明文档、历史设计稿和整理索引。 + +## 说明 + +- 通知规则单一来源:`src/config/notificationRules.js` +- 修改通知规则后,需要执行一次 `npm run rules:sync` +- 当前 AI 第一阶段使用本地 DeepSeek Key,正式发布前仍建议切到后端代理模式 diff --git a/scripts/release.cjs b/scripts/release.cjs index e15d4fa..d55da3f 100644 --- a/scripts/release.cjs +++ b/scripts/release.cjs @@ -1,5 +1,4 @@ -/* 自定义发布脚本:接管 npm version + Android 版本同步 + git 提交与打 tag */ -const { execSync } = require('child_process') +const { execSync } = require('child_process') const fs = require('fs') const path = require('path') @@ -10,23 +9,34 @@ const run = (cmd) => { execSync(cmd, { stdio: 'inherit', cwd: rootDir }) } +const read = (cmd) => execSync(cmd, { cwd: rootDir, encoding: 'utf8' }).trim() + const readJson = (file) => JSON.parse(fs.readFileSync(path.join(rootDir, file), 'utf8')) +const ensureReleaseReady = () => { + const branch = read('git branch --show-current') + if (branch !== 'main') { + throw new Error(`release 只能在 main 分支执行,当前分支:${branch}`) + } + + const status = read('git status --short') + if (status) { + throw new Error('release 前请先提交或清理当前改动,工作区必须保持干净') + } +} + const main = () => { - const type = process.argv[2] || 'patch' // patch / minor / major + const type = process.argv[2] || 'patch' + + ensureReleaseReady() - // 1)仅更新 version 字段,不让 npm 自动 commit / tag(.npmrc 已全局禁止,但这里再显式加一层保险) run(`npm version ${type} --no-git-tag-version`) - - // 2)同步 Android 版本(versionCode / versionName) run('npm run version:sync') - // 3)读取新版本号 const pkg = readJson('package.json') const version = pkg.version - // 4)只提交与版本相关的文件,避免误提交其它开发中的改动 const filesToAdd = [ 'package.json', 'package-lock.json', @@ -34,7 +44,6 @@ const main = () => { ] run(`git add ${filesToAdd.join(' ')}`) - // 5)提交并打 tag run(`git commit -m "chore: release v${version}"`) run(`git tag v${version}`) @@ -42,4 +51,3 @@ const main = () => { } main() - diff --git a/scripts/sync-version.cjs b/scripts/sync-version.cjs index 4b4d0ce..0c8a343 100644 --- a/scripts/sync-version.cjs +++ b/scripts/sync-version.cjs @@ -1,5 +1,4 @@ -/* 同步前端 package.json 的版本号到 Android 工程(versionCode / versionName) */ -const fs = require('fs') +const fs = require('fs') const path = require('path') const rootDir = path.resolve(__dirname, '..') @@ -16,7 +15,7 @@ const calcVersionCode = (version) => { const [major = 0, minor = 0, patch = 0] = String(version) .split('.') .map((n) => Number.parseInt(n, 10) || 0) - // 简单规则:MMmmpp → 1.2.3 => 10203,足够覆盖 0–99 范围 + return major * 10000 + minor * 100 + patch } @@ -49,4 +48,3 @@ const main = () => { } main() - diff --git a/src/lib/sqlite.js b/src/lib/sqlite.js index 4183589..3824f58 100644 --- a/src/lib/sqlite.js +++ b/src/lib/sqlite.js @@ -26,7 +26,15 @@ const TRANSACTION_TABLE_SQL = ` ai_reason TEXT, ai_status TEXT DEFAULT 'idle', ai_model TEXT, - ai_normalized_merchant TEXT + ai_normalized_merchant TEXT, + source_type TEXT, + source_order_id TEXT, + source_merchant_order_id TEXT, + source_counterparty TEXT, + source_account TEXT, + source_payment_method TEXT, + source_record_hash TEXT, + import_batch_id TEXT ); ` const MERCHANT_PROFILE_TABLE_SQL = ` @@ -39,6 +47,35 @@ const MERCHANT_PROFILE_TABLE_SQL = ` updated_at TEXT NOT NULL ); ` +const IMPORT_BATCH_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS import_batches ( + id TEXT PRIMARY KEY NOT NULL, + source TEXT NOT NULL, + file_name TEXT, + imported_at TEXT NOT NULL, + total_count INTEGER DEFAULT 0, + inserted_count INTEGER DEFAULT 0, + merged_count INTEGER DEFAULT 0, + skipped_count INTEGER DEFAULT 0, + status TEXT DEFAULT 'completed', + summary_json TEXT + ); +` +const IMPORT_LINE_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS import_lines ( + id TEXT PRIMARY KEY NOT NULL, + batch_id TEXT NOT NULL, + source_order_id TEXT, + merchant TEXT, + amount REAL, + occurred_at TEXT, + status TEXT NOT NULL, + transaction_id TEXT, + reason TEXT, + raw_data TEXT, + created_at TEXT NOT NULL + ); +` const TRANSACTION_REQUIRED_COLUMNS = { entry_type: "TEXT DEFAULT 'expense'", fund_source_type: "TEXT DEFAULT 'cash'", @@ -54,6 +91,14 @@ const TRANSACTION_REQUIRED_COLUMNS = { ai_status: "TEXT DEFAULT 'idle'", ai_model: 'TEXT', ai_normalized_merchant: 'TEXT', + source_type: 'TEXT', + source_order_id: 'TEXT', + source_merchant_order_id: 'TEXT', + source_counterparty: 'TEXT', + source_account: 'TEXT', + source_payment_method: 'TEXT', + source_record_hash: 'TEXT', + import_batch_id: 'TEXT', } let sqliteConnection @@ -141,6 +186,8 @@ const prepareConnection = async () => { await db.open() await db.execute(TRANSACTION_TABLE_SQL) await db.execute(MERCHANT_PROFILE_TABLE_SQL) + await db.execute(IMPORT_BATCH_TABLE_SQL) + await db.execute(IMPORT_LINE_TABLE_SQL) await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS) initialized = true } diff --git a/src/services/dataTransferService.js b/src/services/dataTransferService.js new file mode 100644 index 0000000..bccf610 --- /dev/null +++ b/src/services/dataTransferService.js @@ -0,0 +1,495 @@ +import { v4 as uuidv4 } from 'uuid' +import { fetchTransactions, insertTransaction, mergeImportedTransaction } from './transactionService.js' +import { getDb, saveDbToStore } from '../lib/sqlite.js' + +const IMPORT_SOURCE_ALIPAY = 'alipay' +const IMPORT_SUCCESS_STATUS = '交易成功' +const ALIPAY_REQUIRED_HEADERS = ['交易时间', '交易分类', '交易对方', '商品说明', '收/支', '金额', '交易状态'] +const ALIPAY_HEADERS = { + occurredAt: '交易时间', + tradeCategory: '交易分类', + merchant: '交易对方', + counterpartyAccount: '对方账号', + productName: '商品说明', + direction: '收/支', + amount: '金额', + paymentMethod: '收/付款方式', + status: '交易状态', + orderId: '交易订单号', + merchantOrderId: '商家订单号', + note: '备注', +} + +const downloadText = (filename, text, mimeType) => { + const blob = new Blob([text], { type: mimeType }) + const url = URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = filename + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + URL.revokeObjectURL(url) +} + +const escapeCsvCell = (value) => `"${String(value ?? '').replace(/"/g, '""')}"` + +const cleanCell = (value) => + String(value ?? '') + .replace(/^\uFEFF/, '') + .replace(/\r/g, '') + .replace(/^"|"$/g, '') + .trim() + +const normalizeMerchantKey = (value) => + String(value || '') + .toLowerCase() + .replace(/[\s"'`~!@#$%^&*()_+\-=[\]{};:,.<>/?\\|,。!?;:()【】《》·、]/g, '') + .slice(0, 80) + +const normalizeAmount = (value) => { + const numeric = Number(String(value ?? '').replace(/[^\d.-]/g, '')) + return Number.isFinite(numeric) ? numeric : 0 +} + +const normalizeDate = (value) => { + if (!value) return '' + const normalized = String(value).trim().replace(/\//g, '-') + const parsed = new Date(normalized) + return Number.isNaN(parsed.getTime()) ? '' : parsed.toISOString() +} + +const parseDelimitedLine = (line, delimiter) => { + const cells = [] + let current = '' + let inQuotes = false + + for (let index = 0; index < line.length; index += 1) { + const char = line[index] + const next = line[index + 1] + + if (char === '"') { + if (inQuotes && next === '"') { + current += '"' + index += 1 + } else { + inQuotes = !inQuotes + } + continue + } + + if (!inQuotes && char === delimiter) { + cells.push(cleanCell(current)) + current = '' + continue + } + + current += char + } + + cells.push(cleanCell(current)) + return cells +} + +const detectHeaderIndex = (rows) => + rows.findIndex((row) => ALIPAY_REQUIRED_HEADERS.every((header) => row.includes(header))) + +const extractRowsFromHtml = (content) => { + const parser = new DOMParser() + const doc = parser.parseFromString(content, 'text/html') + return Array.from(doc.querySelectorAll('tr')).map((row) => + Array.from(row.querySelectorAll('th,td')).map((cell) => cleanCell(cell.textContent)), + ) +} + +const extractRowsFromText = (content) => { + const lines = content.split(/\r?\n/).filter((line) => line.trim()) + const headerLine = lines.find((line) => ALIPAY_REQUIRED_HEADERS.every((header) => line.includes(header))) || '' + const delimiter = headerLine.split('\t').length >= headerLine.split(',').length ? '\t' : ',' + return lines.map((line) => parseDelimitedLine(line, delimiter)) +} + +const parseAlipayRows = (content) => { + const rows = / row[header.indexOf(ALIPAY_HEADERS[key])] || '' + + return rows + .slice(headerIndex + 1) + .filter((row) => cleanCell(getCell(row, 'occurredAt'))) + .map((row) => ({ + occurredAt: cleanCell(getCell(row, 'occurredAt')), + tradeCategory: cleanCell(getCell(row, 'tradeCategory')), + merchant: cleanCell(getCell(row, 'merchant')), + counterpartyAccount: cleanCell(getCell(row, 'counterpartyAccount')), + productName: cleanCell(getCell(row, 'productName')), + direction: cleanCell(getCell(row, 'direction')), + amount: cleanCell(getCell(row, 'amount')), + paymentMethod: cleanCell(getCell(row, 'paymentMethod')), + status: cleanCell(getCell(row, 'status')), + orderId: cleanCell(getCell(row, 'orderId')), + merchantOrderId: cleanCell(getCell(row, 'merchantOrderId')), + note: cleanCell(getCell(row, 'note')), + })) +} + +const resolveCategory = (row, direction) => { + const text = `${row.tradeCategory} ${row.productName} ${row.merchant}`.toLowerCase() + if (direction === 'neutral') return 'Transfer' + if (direction === 'income') return 'Income' + if (/餐饮|美食|咖啡|奶茶|外卖|饭|面|粉/.test(text)) return 'Food' + if (/交通|出行|打车|公交|地铁|单车|骑行|哈啰|滴滴/.test(text)) return 'Transport' + if (/超市|便利|百货|买菜|生鲜|7-11|罗森|全家/.test(text)) return 'Groceries' + if (/健康|药|医院|诊所|体检/.test(text)) return 'Health' + if (/娱乐|电影|游戏|门票|演出|网吧/.test(text)) return 'Entertainment' + return direction === 'expense' ? 'Expense' : 'Uncategorized' +} + +const resolveFundingSource = (paymentMethod) => { + const text = String(paymentMethod || '') + if (!text) return { fundSourceType: 'cash', fundSourceName: '现金账户' } + if (/信用卡|花呗|白条/.test(text)) { + return { fundSourceType: 'credit', fundSourceName: text } + } + if (/储蓄卡|银行卡|银行/.test(text)) { + return { fundSourceType: 'bank', fundSourceName: text } + } + if (/余额|零钱|账户余额/.test(text)) { + return { fundSourceType: 'cash', fundSourceName: text } + } + if (/卡/.test(text)) { + return { fundSourceType: 'stored_value', fundSourceName: text } + } + return { fundSourceType: 'cash', fundSourceName: text } +} + +const buildSourceRecordHash = (payload) => + [ + payload.sourceType, + payload.date, + payload.amount.toFixed(2), + normalizeMerchantKey(payload.merchant), + normalizeMerchantKey(payload.note), + ].join('|') + +const normalizeImportedRow = (row, importBatchId) => { + const normalizedDate = normalizeDate(row.occurredAt) + if (!normalizedDate) return null + + const directionText = row.direction + const rawAmount = Math.abs(normalizeAmount(row.amount)) + const direction = + directionText.includes('收入') ? 'income' : directionText.includes('支出') ? 'expense' : 'neutral' + + const { fundSourceType, fundSourceName } = resolveFundingSource(row.paymentMethod) + const merchant = row.merchant || row.productName || '支付宝账单导入' + const note = [row.productName, row.note].filter(Boolean).join(' | ') + const amount = + direction === 'income' ? rawAmount : direction === 'expense' ? (rawAmount === 0 ? 0 : rawAmount * -1) : rawAmount + + const payload = { + merchant, + category: resolveCategory(row, direction), + note, + date: normalizedDate, + amount, + syncStatus: 'pending', + entryType: direction === 'neutral' ? 'transfer' : direction, + fundSourceType, + fundSourceName, + fundTargetType: direction === 'income' ? 'cash' : direction === 'neutral' ? 'stored_value' : 'merchant', + fundTargetName: + direction === 'income' ? '现金账户' : direction === 'neutral' ? merchant || '储值账户' : merchant, + impactExpense: direction === 'expense', + impactIncome: direction === 'income', + sourceType: IMPORT_SOURCE_ALIPAY, + sourceOrderId: row.orderId, + sourceMerchantOrderId: row.merchantOrderId, + sourceCounterparty: row.merchant, + sourceAccount: row.counterpartyAccount, + sourcePaymentMethod: row.paymentMethod, + importBatchId, + } + + payload.sourceRecordHash = buildSourceRecordHash(payload) + return payload +} + +const isSameAmount = (left, right) => Math.abs(Number(left) - Number(right)) < 0.005 + +const isMerchantLikelySame = (left, right) => { + const leftKey = normalizeMerchantKey(left) + const rightKey = normalizeMerchantKey(right) + if (!leftKey || !rightKey) return false + return leftKey === rightKey || leftKey.includes(rightKey) || rightKey.includes(leftKey) +} + +const findExactDuplicate = (transactions, payload) => + transactions.find((transaction) => { + if (payload.sourceOrderId && transaction.sourceType === payload.sourceType) { + return transaction.sourceOrderId === payload.sourceOrderId + } + return payload.sourceRecordHash && transaction.sourceRecordHash === payload.sourceRecordHash + }) + +const findMergeCandidate = (transactions, payload) => { + const payloadTime = new Date(payload.date).getTime() + return transactions.find((transaction) => { + const diff = Math.abs(new Date(transaction.date).getTime() - payloadTime) + return ( + diff <= 10 * 60 * 1000 && + isSameAmount(transaction.amount, payload.amount) && + isMerchantLikelySame(transaction.merchant, payload.merchant) + ) + }) +} + +const createImportBatch = async ({ source, fileName }) => { + const db = await getDb() + const id = uuidv4() + await db.run( + ` + INSERT INTO import_batches (id, source, file_name, imported_at, status) + VALUES (?, ?, ?, ?, ?) + `, + [id, source, fileName || null, new Date().toISOString(), 'running'], + ) + return id +} + +const recordImportLine = async (batchId, payload) => { + const db = await getDb() + await db.run( + ` + INSERT INTO import_lines ( + id, + batch_id, + source_order_id, + merchant, + amount, + occurred_at, + status, + transaction_id, + reason, + raw_data, + created_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + uuidv4(), + batchId, + payload.sourceOrderId || null, + payload.merchant || null, + Number(payload.amount || 0), + payload.date || null, + payload.status, + payload.transactionId || null, + payload.reason || null, + JSON.stringify(payload.rawData || {}), + new Date().toISOString(), + ], + ) +} + +const finalizeImportBatch = async (batchId, summary) => { + const db = await getDb() + await db.run( + ` + UPDATE import_batches + SET total_count = ?, inserted_count = ?, merged_count = ?, skipped_count = ?, status = ?, summary_json = ? + WHERE id = ? + `, + [ + summary.totalCount, + summary.insertedCount, + summary.mergedCount, + summary.skippedCount, + 'completed', + JSON.stringify(summary), + batchId, + ], + ) +} + +export const importAlipayStatement = async (file) => { + const content = await file.text() + const parsedRows = parseAlipayRows(content) + const batchId = await createImportBatch({ + source: IMPORT_SOURCE_ALIPAY, + fileName: file?.name || 'alipay-statement', + }) + + const summary = { + batchId, + source: IMPORT_SOURCE_ALIPAY, + fileName: file?.name || '', + totalCount: 0, + insertedCount: 0, + mergedCount: 0, + skippedCount: 0, + skippedReasons: [], + } + + const existingTransactions = await fetchTransactions() + const inBatchKeys = new Set() + + for (const row of parsedRows) { + summary.totalCount += 1 + + if (row.status && row.status !== IMPORT_SUCCESS_STATUS) { + summary.skippedCount += 1 + summary.skippedReasons.push(`${row.occurredAt} ${row.merchant || row.productName}:状态不是交易成功`) + await recordImportLine(batchId, { + sourceOrderId: row.orderId, + merchant: row.merchant || row.productName, + amount: normalizeAmount(row.amount), + date: normalizeDate(row.occurredAt), + status: 'skipped', + reason: '交易状态不是交易成功', + rawData: row, + }) + continue + } + + const payload = normalizeImportedRow(row, batchId) + if (!payload) { + summary.skippedCount += 1 + summary.skippedReasons.push(`${row.occurredAt}:无法解析交易时间`) + await recordImportLine(batchId, { + sourceOrderId: row.orderId, + merchant: row.merchant || row.productName, + amount: normalizeAmount(row.amount), + date: '', + status: 'skipped', + reason: '无法解析交易时间', + rawData: row, + }) + continue + } + + const inBatchKey = payload.sourceOrderId || payload.sourceRecordHash + if (inBatchKey && inBatchKeys.has(inBatchKey)) { + summary.skippedCount += 1 + summary.skippedReasons.push(`${payload.merchant}:导入文件内重复记录已跳过`) + await recordImportLine(batchId, { + ...payload, + status: 'skipped', + reason: '导入文件内重复记录', + rawData: row, + }) + continue + } + if (inBatchKey) { + inBatchKeys.add(inBatchKey) + } + + const exactDuplicate = findExactDuplicate(existingTransactions, payload) + if (exactDuplicate) { + summary.skippedCount += 1 + await recordImportLine(batchId, { + ...payload, + transactionId: exactDuplicate.id, + status: 'skipped', + reason: '外部订单号或记录指纹已存在', + rawData: row, + }) + continue + } + + const mergeCandidate = findMergeCandidate(existingTransactions, payload) + if (mergeCandidate) { + const merged = await mergeImportedTransaction(mergeCandidate.id, payload) + if (merged) { + summary.mergedCount += 1 + const index = existingTransactions.findIndex((item) => item.id === merged.id) + if (index >= 0) existingTransactions[index] = merged + await recordImportLine(batchId, { + ...payload, + transactionId: merged.id, + status: 'merged', + reason: '与现有记录合并', + rawData: row, + }) + continue + } + } + + const inserted = await insertTransaction(payload) + existingTransactions.unshift(inserted) + summary.insertedCount += 1 + await recordImportLine(batchId, { + ...payload, + transactionId: inserted.id, + status: 'inserted', + reason: '新记录已导入', + rawData: row, + }) + } + + await finalizeImportBatch(batchId, summary) + await saveDbToStore() + return summary +} + +export const exportTransactionsAsCsv = async () => { + const transactions = await fetchTransactions() + const rows = [ + [ + '交易时间', + '金额', + '方向', + '商户', + '分类', + '备注', + '记录类型', + '资金来源', + '资金去向', + 'AI 状态', + '来源', + '外部订单号', + ], + ...transactions.map((transaction) => [ + transaction.date, + transaction.amount, + transaction.amount > 0 ? '收入' : transaction.amount < 0 ? '支出' : '零额', + transaction.merchant, + transaction.category, + transaction.note, + transaction.entryType, + transaction.fundSourceName || transaction.fundSourceType, + transaction.fundTargetName || transaction.fundTargetType, + transaction.aiStatus, + transaction.sourceType, + transaction.sourceOrderId, + ]), + ] + + const content = `\uFEFF${rows.map((row) => row.map(escapeCsvCell).join(',')).join('\n')}` + downloadText(`echo-ledger-${new Date().toISOString().slice(0, 10)}.csv`, content, 'text/csv;charset=utf-8;') + return transactions.length +} + +export const exportTransactionsAsJson = async () => { + const transactions = await fetchTransactions() + const content = JSON.stringify( + { + exportedAt: new Date().toISOString(), + count: transactions.length, + transactions, + }, + null, + 2, + ) + downloadText( + `echo-ledger-${new Date().toISOString().slice(0, 10)}.json`, + content, + 'application/json;charset=utf-8;', + ) + return transactions.length +} diff --git a/src/services/transactionService.js b/src/services/transactionService.js index d7954c0..1753c7b 100644 --- a/src/services/transactionService.js +++ b/src/services/transactionService.js @@ -32,7 +32,15 @@ const TRANSACTION_SELECT_FIELDS = ` ai_reason, ai_status, ai_model, - ai_normalized_merchant + ai_normalized_merchant, + source_type, + source_order_id, + source_merchant_order_id, + source_counterparty, + source_account, + source_payment_method, + source_record_hash, + import_batch_id ` const normalizeStatus = (value) => { @@ -66,6 +74,17 @@ const normalizeFlag = (value, fallback = false) => { const inferEntryTypeFromAmount = (amount) => (Number(amount) >= 0 ? 'income' : 'expense') +const sanitizeSourceMetadata = (payload = {}, existing = null) => ({ + sourceType: payload.sourceType ?? existing?.sourceType ?? '', + sourceOrderId: payload.sourceOrderId ?? existing?.sourceOrderId ?? '', + sourceMerchantOrderId: payload.sourceMerchantOrderId ?? existing?.sourceMerchantOrderId ?? '', + sourceCounterparty: payload.sourceCounterparty ?? existing?.sourceCounterparty ?? '', + sourceAccount: payload.sourceAccount ?? existing?.sourceAccount ?? '', + sourcePaymentMethod: payload.sourcePaymentMethod ?? existing?.sourcePaymentMethod ?? '', + sourceRecordHash: payload.sourceRecordHash ?? existing?.sourceRecordHash ?? '', + importBatchId: payload.importBatchId ?? existing?.importBatchId ?? '', +}) + const buildLedgerDefaults = (payload = {}, existing = null) => { const entryType = payload.entryType || existing?.entryType || inferEntryTypeFromAmount(payload.amount ?? existing?.amount ?? 0) const defaults = DEFAULT_LEDGER_BY_ENTRY_TYPE[entryType] || DEFAULT_LEDGER_BY_ENTRY_TYPE.expense @@ -118,6 +137,14 @@ const mapRow = (row) => { aiStatus: normalizeAiStatus(row.ai_status), aiModel: row.ai_model || '', aiNormalizedMerchant: row.ai_normalized_merchant || '', + sourceType: row.source_type || '', + sourceOrderId: row.source_order_id || '', + sourceMerchantOrderId: row.source_merchant_order_id || '', + sourceCounterparty: row.source_counterparty || '', + sourceAccount: row.source_account || '', + sourcePaymentMethod: row.source_payment_method || '', + sourceRecordHash: row.source_record_hash || '', + importBatchId: row.import_batch_id || '', } } @@ -249,6 +276,7 @@ export const insertTransaction = async (payload) => { const id = payload.id || uuidv4() const amount = Number(payload.amount) const ledger = buildLedgerDefaults(payload) + const sourceMeta = sanitizeSourceMetadata(payload) const sanitized = { merchant: payload.merchant?.trim() || 'Unknown', category: payload.category?.trim() || 'Uncategorized', @@ -257,6 +285,7 @@ export const insertTransaction = async (payload) => { amount, syncStatus: statusMap[payload.syncStatus] ?? statusMap.pending, ...ledger, + ...sourceMeta, } await db.run( @@ -275,9 +304,17 @@ export const insertTransaction = async (payload) => { fund_target_type, fund_target_name, impact_expense, - impact_income + impact_income, + source_type, + source_order_id, + source_merchant_order_id, + source_counterparty, + source_account, + source_payment_method, + source_record_hash, + import_batch_id ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, [ id, @@ -294,6 +331,14 @@ export const insertTransaction = async (payload) => { sanitized.fundTargetName || null, sanitized.impactExpense ? 1 : 0, sanitized.impactIncome ? 1 : 0, + sanitized.sourceType || null, + sanitized.sourceOrderId || null, + sanitized.sourceMerchantOrderId || null, + sanitized.sourceCounterparty || null, + sanitized.sourceAccount || null, + sanitized.sourcePaymentMethod || null, + sanitized.sourceRecordHash || null, + sanitized.importBatchId || null, ], ) @@ -310,6 +355,14 @@ export const insertTransaction = async (payload) => { aiStatus: 'idle', aiModel: '', aiNormalizedMerchant: '', + sourceType: sanitized.sourceType, + sourceOrderId: sanitized.sourceOrderId, + sourceMerchantOrderId: sanitized.sourceMerchantOrderId, + sourceCounterparty: sanitized.sourceCounterparty, + sourceAccount: sanitized.sourceAccount, + sourcePaymentMethod: sanitized.sourcePaymentMethod, + sourceRecordHash: sanitized.sourceRecordHash, + importBatchId: sanitized.importBatchId, } } @@ -319,10 +372,11 @@ export const updateTransaction = async (payload) => { const db = await getDb() const ledger = buildLedgerDefaults(payload, existing) + const sourceMeta = sanitizeSourceMetadata(payload, existing) await db.run( ` UPDATE transactions - SET 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 = ? + SET 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 = ?, source_type = ?, source_order_id = ?, source_merchant_order_id = ?, source_counterparty = ?, source_account = ?, source_payment_method = ?, source_record_hash = ?, import_batch_id = ? WHERE id = ? `, [ @@ -339,6 +393,14 @@ export const updateTransaction = async (payload) => { ledger.fundTargetName || null, ledger.impactExpense ? 1 : 0, ledger.impactIncome ? 1 : 0, + sourceMeta.sourceType || null, + sourceMeta.sourceOrderId || null, + sourceMeta.sourceMerchantOrderId || null, + sourceMeta.sourceCounterparty || null, + sourceMeta.sourceAccount || null, + sourceMeta.sourcePaymentMethod || null, + sourceMeta.sourceRecordHash || null, + sourceMeta.importBatchId || null, payload.id, ], ) @@ -435,3 +497,51 @@ export const deleteTransaction = async (id) => { await db.run(`DELETE FROM transactions WHERE id = ?`, [id]) await saveDbToStore() } + +export const mergeImportedTransaction = async (transactionId, payload) => { + const existing = await getTransactionById(transactionId) + if (!existing) return null + + const mergedNote = [existing.note, payload.note] + .filter(Boolean) + .filter((value, index, list) => list.indexOf(value) === index) + .join(' | ') + .slice(0, 500) + + const nextCategory = + existing.category === 'Uncategorized' || existing.category === 'Expense' + ? payload.category || existing.category + : existing.category + + return updateTransaction({ + ...existing, + id: transactionId, + category: nextCategory, + note: mergedNote, + fundSourceType: + existing.fundSourceType === 'cash' && payload.fundSourceType + ? payload.fundSourceType + : existing.fundSourceType, + fundSourceName: + existing.fundSourceType === 'cash' && payload.fundSourceName + ? payload.fundSourceName + : existing.fundSourceName, + fundTargetType: + existing.fundTargetType === 'merchant' && payload.fundTargetType + ? payload.fundTargetType + : existing.fundTargetType, + fundTargetName: + (!existing.fundTargetName || existing.fundTargetName === existing.merchant) && + payload.fundTargetName + ? payload.fundTargetName + : existing.fundTargetName, + sourceType: payload.sourceType || existing.sourceType, + sourceOrderId: payload.sourceOrderId || existing.sourceOrderId, + sourceMerchantOrderId: payload.sourceMerchantOrderId || existing.sourceMerchantOrderId, + sourceCounterparty: payload.sourceCounterparty || existing.sourceCounterparty, + sourceAccount: payload.sourceAccount || existing.sourceAccount, + sourcePaymentMethod: payload.sourcePaymentMethod || existing.sourcePaymentMethod, + sourceRecordHash: payload.sourceRecordHash || existing.sourceRecordHash, + importBatchId: payload.importBatchId || existing.importBatchId, + }) +} diff --git a/src/views/HomeView.vue b/src/views/HomeView.vue index edf51d2..5a40df5 100644 --- a/src/views/HomeView.vue +++ b/src/views/HomeView.vue @@ -317,26 +317,28 @@ const openTransactionDetail = (tx) => { 查看全部 -
+
-
+
-
-
-

{{ tx.merchant }}

+
+
+

+ {{ tx.merchant }} +

{ {{ resolveAiBadge(tx).label }}
-

+

{{ formatTime(tx.date) }} · {{ getCategoryLabel(tx.category) }}

-

+

{{ formatLedgerHint(tx) }}

-

+

{{ formatAiHint(tx) }}

diff --git a/src/views/ListView.vue b/src/views/ListView.vue index 6674fc5..a18be92 100644 --- a/src/views/ListView.vue +++ b/src/views/ListView.vue @@ -102,30 +102,30 @@ const hasData = computed(() => groupedTransactions.value.length > 0)