chore: persist sqlite storage and support csv import
All checks were successful
Deploy to Production / Build and Test (push) Successful in 10m1s
Deploy to Production / Deploy to Server (push) Successful in 6m26s

This commit is contained in:
你的用户名
2025-11-06 18:44:00 +08:00
parent 6971e61f43
commit 9b89421967
3 changed files with 225 additions and 3 deletions

View File

@@ -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=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 COPY --from=backend-builder /app/node_modules /app/node_modules
# 创建nginx配置和日志目录 # 创建nginx配置和日志目录

View File

@@ -1,4 +1,5 @@
#!/usr/bin/env node #!/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 fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
@@ -43,7 +44,7 @@ const dbFile = path.join(storeDir, 'finance.db');
const db = new Database(dbFile); const db = new Database(dbFile);
function assertIdentifier(name) { 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}`); throw new Error(`Invalid identifier: ${name}`);
} }
return 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_by', 'approved_by TEXT');
ensureColumn('finance_transactions', 'approved_at', 'approved_at 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 RAW_TEXT = fs.readFileSync(inputPath, 'utf8').replace(/^\uFEFF/, '');
const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0); const lines = RAW_TEXT.split(/\r?\n/).filter((line) => line.trim().length > 0);
if (lines.length <= 1) { if (lines.length <= 1) {
@@ -163,7 +241,148 @@ if (lines.length <= 1) {
process.exit(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 DATE_IDX = header.indexOf('日期');
const PROJECT_IDX = header.indexOf('项目'); const PROJECT_IDX = header.indexOf('项目');
const TYPE_IDX = header.indexOf('收支'); const TYPE_IDX = header.indexOf('收支');

View File

@@ -14,6 +14,7 @@ services:
- TZ=Asia/Shanghai - TZ=Asia/Shanghai
volumes: volumes:
- ./logs:/var/log - ./logs:/var/log
- ./storage/backend:/app/apps/backend/storage
networks: networks:
- kt-network - kt-network