From 9b8942196751d854a680858f5583d8d01ce8e157 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BD=A0=E7=9A=84=E7=94=A8=E6=88=B7=E5=90=8D?= <你的邮箱> Date: Thu, 6 Nov 2025 18:44:00 +0800 Subject: [PATCH] chore: persist sqlite storage and support csv import --- Dockerfile | 4 +- apps/backend/scripts/import-finance-data.js | 223 +++++++++++++++++++- docker-compose.yml | 1 + 3 files changed, 225 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index 34927ce4..4aff7fa8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -44,7 +44,9 @@ RUN npm install -g pnpm@9 COPY --from=frontend-builder /app/apps/web-antd/dist /usr/share/nginx/html # 从构建阶段复制后端代码和依赖 -COPY --from=backend-builder /app/apps/backend /app/backend +RUN mkdir -p /app/apps +COPY --from=backend-builder /app/apps/backend /app/apps/backend +RUN ln -s /app/apps/backend /app/backend COPY --from=backend-builder /app/node_modules /app/node_modules # 创建nginx配置和日志目录 diff --git a/apps/backend/scripts/import-finance-data.js b/apps/backend/scripts/import-finance-data.js index b71bf095..4ad581cf 100644 --- a/apps/backend/scripts/import-finance-data.js +++ b/apps/backend/scripts/import-finance-data.js @@ -1,4 +1,5 @@ #!/usr/bin/env node +/* eslint-disable @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports, unicorn/prefer-module, n/prefer-global/process, no-console, unicorn/no-nested-ternary */ const fs = require('node:fs'); const path = require('node:path'); @@ -43,7 +44,7 @@ const dbFile = path.join(storeDir, 'finance.db'); const db = new Database(dbFile); function assertIdentifier(name) { - if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) { + if (!/^[A-Z_]\w*$/i.test(name)) { throw new Error(`Invalid identifier: ${name}`); } return name; @@ -156,6 +157,83 @@ ensureColumn('finance_transactions', 'submitted_by', 'submitted_by TEXT'); ensureColumn('finance_transactions', 'approved_by', 'approved_by TEXT'); ensureColumn('finance_transactions', 'approved_at', 'approved_at TEXT'); +function ensureCurrency(code, name = code, symbol = code) { + db.prepare( + `INSERT OR IGNORE INTO finance_currencies (code, name, symbol, is_base, is_active) + VALUES (?, ?, ?, ?, 1)`, + ).run(code, name, symbol, code === 'CNY' ? 1 : 0); +} + +function getCategoryId(name, type, icon = '📝') { + const selectStmt = db.prepare( + 'SELECT id FROM finance_categories WHERE name = ? LIMIT 1', + ); + const existing = selectStmt.get(name); + if (existing) return existing.id; + + const insertStmt = db.prepare( + `INSERT INTO finance_categories (name, type, icon, color, user_id, is_active) + VALUES (?, ?, ?, ?, ?, 1)`, + ); + const info = insertStmt.run(name, type, icon, '#dfe4ea', 1); + return Number(info.lastInsertRowid); +} + +function getAccountId(name, currency, type = 'cash') { + const selectStmt = db.prepare( + 'SELECT id FROM finance_accounts WHERE name = ? LIMIT 1', + ); + const existing = selectStmt.get(name); + if (existing) return existing.id; + + const insertStmt = db.prepare( + `INSERT INTO finance_accounts (name, currency, type, icon, color, user_id, is_active) + VALUES (?, ?, ?, ?, ?, ?, 1)`, + ); + const info = insertStmt.run(name, currency, type, '💼', '#1677ff', 1); + return Number(info.lastInsertRowid); +} + +function ensureExchangeRate( + fromCurrency, + toCurrency, + rate = 1, + dateStr = '1970-01-01', +) { + const stmt = db.prepare( + `SELECT id FROM finance_exchange_rates + WHERE from_currency = ? AND to_currency = ? + ORDER BY date DESC LIMIT 1`, + ); + const existing = stmt.get(fromCurrency, toCurrency); + if (existing) return existing.id; + + const insertStmt = db.prepare( + `INSERT INTO finance_exchange_rates (from_currency, to_currency, rate, date, source) + VALUES (?, ?, ?, ?, ?)`, + ); + const info = insertStmt.run( + fromCurrency, + toCurrency, + rate, + dateStr, + 'import-script', + ); + return Number(info.lastInsertRowid); +} + +function parseCategoryWithIcon(raw) { + if (!raw) { + return { icon: '📝', name: '未分类' }; + } + const trimmed = raw.trim(); + const parts = trimmed.split(/\s+/); + if (parts.length > 1 && /\p{Emoji}/u.test(parts[0])) { + return { icon: parts[0], name: parts.slice(1).join(' ') }; + } + return { icon: '📝', name: trimmed }; +} + const RAW_TEXT = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, ''); const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0); if (lines.length <= 1) { @@ -163,7 +241,148 @@ if (lines.length <= 1) { process.exit(1); } -const header = lines[0].split(','); +const header = lines[0].split(',').map((item) => item.trim()); + +const NEW_HEADER_KEYS = { + date: '日期', + type: '类型', + category: '分类', + project: '项目名称', + amount: '金额', + currency: '币种', + account: '账户', +}; + +const isNewFormat = Object.values(NEW_HEADER_KEYS).every((key) => + header.includes(key), +); + +if (isNewFormat) { + const DATE_IDX = header.indexOf(NEW_HEADER_KEYS.date); + const TYPE_IDX = header.indexOf(NEW_HEADER_KEYS.type); + const CATEGORY_IDX = header.indexOf(NEW_HEADER_KEYS.category); + const PROJECT_IDX = header.indexOf(NEW_HEADER_KEYS.project); + const AMOUNT_IDX = header.indexOf(NEW_HEADER_KEYS.amount); + const CURRENCY_IDX = header.indexOf(NEW_HEADER_KEYS.currency); + const ACCOUNT_IDX = header.indexOf(NEW_HEADER_KEYS.account); + + ensureCurrency('CNY', '人民币', '¥'); + + const insertTransactionStmt = db.prepare( + `INSERT INTO finance_transactions ( + type, + amount, + currency, + exchange_rate_to_base, + amount_in_base, + category_id, + account_id, + transaction_date, + description, + project, + memo, + created_at, + status, + status_updated_at, + reimbursement_batch, + review_notes, + submitted_by, + approved_by, + approved_at, + is_deleted + ) VALUES ( + @type, + @amount, + @currency, + @exchangeRate, + @amountInBase, + @categoryId, + @accountId, + @transactionDate, + @description, + NULL, + NULL, + @createdAt, + 'approved', + @statusUpdatedAt, + NULL, + NULL, + NULL, + NULL, + @approvedAt, + 0 + )`, + ); + + const importTx = db.transaction((records) => { + for (const record of records) { + insertTransactionStmt.run(record); + } + }); + + const transactions = []; + + for (let i = 1; i < lines.length; i += 1) { + const columns = lines[i].split(',').map((item) => item.trim()); + if (columns.length < header.length) continue; + + const date = columns[DATE_IDX]; + if (!date) continue; + + const typeRaw = columns[TYPE_IDX]; + const type = + typeRaw && typeRaw.includes('收') + ? 'income' + : typeRaw && typeRaw.includes('入') + ? 'income' + : 'expense'; + + const categoryInfo = parseCategoryWithIcon(columns[CATEGORY_IDX]); + ensureCurrency('CNY', '人民币', '¥'); + + const currency = (columns[CURRENCY_IDX] || 'CNY').toUpperCase(); + ensureCurrency( + currency, + currency, + currency === 'CNY' ? '¥' : currency === 'USD' ? '$' : currency, + ); + ensureExchangeRate(currency, 'CNY', 1, date); + + const accountName = columns[ACCOUNT_IDX] || '默认账户'; + const accountId = getAccountId(accountName, currency); + const categoryId = getCategoryId( + categoryInfo.name, + type, + categoryInfo.icon, + ); + + const amount = Number(columns[AMOUNT_IDX]?.replace(/,/g, '') || 0); + if (Number.isNaN(amount) || amount === 0) continue; + + const description = columns[PROJECT_IDX] || ''; + const createdAt = `${date}T09:00:00.000Z`; + + transactions.push({ + type, + amount: Math.abs(amount), + currency, + exchangeRate: 1, + amountInBase: Math.abs(amount), + categoryId, + accountId, + transactionDate: date, + description, + createdAt, + statusUpdatedAt: createdAt, + approvedAt: createdAt, + }); + } + + importTx(transactions); + console.log(`导入完成,共写入 ${transactions.length} 条记录`); + process.exit(0); +} + const DATE_IDX = header.indexOf('日期'); const PROJECT_IDX = header.indexOf('项目'); const TYPE_IDX = header.indexOf('收支'); diff --git a/docker-compose.yml b/docker-compose.yml index 1382577c..c14911ff 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,6 +14,7 @@ services: - TZ=Asia/Shanghai volumes: - ./logs:/var/log + - ./storage/backend:/app/apps/backend/storage networks: - kt-network