feat: implement bulk account deletion and enhance account management with folder organization and drag-and-drop functionality
This commit is contained in:
307
scripts/import-csv-to-db.ts
Normal file
307
scripts/import-csv-to-db.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { transactionService } from '../services/transaction.service';
|
||||
import { generateId } from '../lib/store-db';
|
||||
|
||||
interface CSVTransaction {
|
||||
date: string;
|
||||
amount: string;
|
||||
libelle: string;
|
||||
beneficiaire: string;
|
||||
iban: string;
|
||||
bic: string;
|
||||
numeroCompte: string;
|
||||
codeBanque: string;
|
||||
categorie: string;
|
||||
commentaire: string;
|
||||
numeroCheque: string;
|
||||
tags: string;
|
||||
compte: string;
|
||||
}
|
||||
|
||||
function parseCSVLine(line: string): string[] {
|
||||
const result: string[] = [];
|
||||
let current = '';
|
||||
let inQuotes = false;
|
||||
|
||||
for (let i = 0; i < line.length; i++) {
|
||||
const char = line[i];
|
||||
|
||||
if (char === '"') {
|
||||
inQuotes = !inQuotes;
|
||||
} else if (char === ',' && !inQuotes) {
|
||||
result.push(current.trim());
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
|
||||
result.push(current.trim());
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseCSV(csvPath: string): CSVTransaction[] {
|
||||
const content = fs.readFileSync(csvPath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
|
||||
// Skip header lines (first 8 lines)
|
||||
const dataLines = lines.slice(8);
|
||||
|
||||
const transactions: CSVTransaction[] = [];
|
||||
|
||||
for (const line of dataLines) {
|
||||
if (!line.trim()) continue;
|
||||
|
||||
const fields = parseCSVLine(line);
|
||||
if (fields.length < 13) continue;
|
||||
|
||||
// Skip if date or amount is missing
|
||||
if (!fields[0] || !fields[1] || !fields[2]) continue;
|
||||
|
||||
transactions.push({
|
||||
date: fields[0],
|
||||
amount: fields[1],
|
||||
libelle: fields[2],
|
||||
beneficiaire: fields[3],
|
||||
iban: fields[4],
|
||||
bic: fields[5],
|
||||
numeroCompte: fields[6],
|
||||
codeBanque: fields[7],
|
||||
categorie: fields[8],
|
||||
commentaire: fields[9],
|
||||
numeroCheque: fields[10],
|
||||
tags: fields[11],
|
||||
compte: fields[12],
|
||||
});
|
||||
}
|
||||
|
||||
return transactions;
|
||||
}
|
||||
|
||||
function parseDate(dateStr: string): string {
|
||||
// Format: DD/MM/YYYY -> YYYY-MM-DD
|
||||
const [day, month, year] = dateStr.split('/');
|
||||
return `${year}-${month.padStart(2, '0')}-${day.padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function parseAmount(amountStr: string): number {
|
||||
if (!amountStr || amountStr.trim() === '' || amountStr === '""') {
|
||||
return 0;
|
||||
}
|
||||
// Remove quotes, spaces (including non-breaking spaces), and replace comma with dot
|
||||
const cleaned = amountStr.replace(/["\s\u00A0]/g, '').replace(',', '.');
|
||||
const parsed = parseFloat(cleaned);
|
||||
return isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function generateFITID(transaction: CSVTransaction, index: number): string {
|
||||
const date = parseDate(transaction.date);
|
||||
const dateStr = date.replace(/-/g, '');
|
||||
const amountStr = Math.abs(parseAmount(transaction.amount)).toFixed(2).replace('.', '');
|
||||
const libelleHash = transaction.libelle.substring(0, 20).replace(/[^A-Z0-9]/gi, '');
|
||||
return `${dateStr}-${amountStr}-${libelleHash}-${index}`;
|
||||
}
|
||||
|
||||
function removeAccountPrefix(accountName: string): string {
|
||||
// Remove prefixes: LivretA, LDDS, CCP, PEL (case insensitive)
|
||||
const prefixes = ['LivretA', 'Livret A', 'LDDS', 'CCP', 'PEL'];
|
||||
let cleaned = accountName;
|
||||
|
||||
for (const prefix of prefixes) {
|
||||
// Remove prefix followed by optional spaces and dashes
|
||||
const regex = new RegExp(`^${prefix}\\s*-?\\s*`, 'i');
|
||||
cleaned = cleaned.replace(regex, '');
|
||||
}
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
function determineAccountType(accountName: string): "CHECKING" | "SAVINGS" | "CREDIT_CARD" | "OTHER" {
|
||||
const upper = accountName.toUpperCase();
|
||||
if (upper.includes('LIVRET') || upper.includes('LDDS') || upper.includes('PEL')) {
|
||||
return 'SAVINGS';
|
||||
}
|
||||
if (upper.includes('CCP') || upper.includes('COMPTE COURANT')) {
|
||||
return 'CHECKING';
|
||||
}
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const csvPath = path.join(__dirname, '../temp/all account.csv');
|
||||
|
||||
if (!fs.existsSync(csvPath)) {
|
||||
console.error(`Fichier CSV introuvable: ${csvPath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('Lecture du fichier CSV...');
|
||||
const csvTransactions = parseCSV(csvPath);
|
||||
console.log(`✓ ${csvTransactions.length} transactions trouvées`);
|
||||
|
||||
// Group by account
|
||||
const accountsMap = new Map<string, CSVTransaction[]>();
|
||||
for (const transaction of csvTransactions) {
|
||||
if (!transaction.compte) continue;
|
||||
const amount = parseAmount(transaction.amount);
|
||||
if (amount === 0) continue; // Skip zero-amount transactions
|
||||
|
||||
if (!accountsMap.has(transaction.compte)) {
|
||||
accountsMap.set(transaction.compte, []);
|
||||
}
|
||||
accountsMap.get(transaction.compte)!.push(transaction);
|
||||
}
|
||||
|
||||
console.log(`✓ ${accountsMap.size} comptes trouvés\n`);
|
||||
|
||||
let totalTransactionsCreated = 0;
|
||||
let totalAccountsCreated = 0;
|
||||
let totalAccountsUpdated = 0;
|
||||
|
||||
// Process each account
|
||||
for (const [accountName, transactions] of accountsMap.entries()) {
|
||||
console.log(`Traitement du compte: ${accountName}`);
|
||||
console.log(` ${transactions.length} transactions`);
|
||||
|
||||
// Remove prefixes and extract account number from account name
|
||||
const cleanedAccountName = removeAccountPrefix(accountName);
|
||||
const accountNumber = cleanedAccountName.replace(/[^A-Z0-9]/gi, '').substring(0, 22);
|
||||
const bankId = transactions[0]?.codeBanque || 'FR';
|
||||
|
||||
console.log(` Numéro de compte extrait: ${accountNumber}`);
|
||||
|
||||
// Find account by account number (try multiple strategies)
|
||||
let account = await prisma.account.findFirst({
|
||||
where: {
|
||||
accountNumber: accountNumber,
|
||||
bankId: bankId,
|
||||
},
|
||||
});
|
||||
|
||||
// If not found with bankId, try without bankId constraint
|
||||
if (!account) {
|
||||
account = await prisma.account.findFirst({
|
||||
where: {
|
||||
accountNumber: accountNumber,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// If still not found, try to find by account number in existing account numbers
|
||||
// (some accounts might have been created with prefixes in accountNumber)
|
||||
if (!account) {
|
||||
const allAccounts = await prisma.account.findMany({
|
||||
where: {
|
||||
accountNumber: {
|
||||
contains: accountNumber,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Try to find exact match in accountNumber (after cleaning)
|
||||
for (const acc of allAccounts) {
|
||||
const cleanedExisting = removeAccountPrefix(acc.accountNumber);
|
||||
const existingNumber = cleanedExisting.replace(/[^A-Z0-9]/gi, '');
|
||||
if (existingNumber === accountNumber) {
|
||||
account = acc;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!account) {
|
||||
console.log(` → Création du compte...`);
|
||||
account = await prisma.account.create({
|
||||
data: {
|
||||
name: accountName,
|
||||
bankId: bankId,
|
||||
accountNumber: accountNumber,
|
||||
type: determineAccountType(accountName),
|
||||
folderId: null,
|
||||
balance: 0,
|
||||
currency: 'EUR',
|
||||
lastImport: null,
|
||||
externalUrl: null,
|
||||
},
|
||||
});
|
||||
totalAccountsCreated++;
|
||||
} else {
|
||||
console.log(` → Compte existant trouvé: ${account.name} (${account.accountNumber})`);
|
||||
totalAccountsUpdated++;
|
||||
}
|
||||
|
||||
// Sort transactions by date
|
||||
transactions.sort((a, b) => {
|
||||
const dateA = parseDate(a.date);
|
||||
const dateB = parseDate(b.date);
|
||||
return dateA.localeCompare(dateB);
|
||||
});
|
||||
|
||||
// Calculate balance
|
||||
const balance = transactions.reduce((sum, t) => sum + parseAmount(t.amount), 0);
|
||||
|
||||
// Prepare transactions for insertion
|
||||
const dbTransactions = transactions.map((transaction, index) => {
|
||||
const amount = parseAmount(transaction.amount);
|
||||
const date = parseDate(transaction.date);
|
||||
|
||||
// Build memo from available fields
|
||||
let memo = transaction.libelle;
|
||||
if (transaction.beneficiaire) {
|
||||
memo += ` - ${transaction.beneficiaire}`;
|
||||
}
|
||||
if (transaction.categorie) {
|
||||
memo += ` [${transaction.categorie}]`;
|
||||
}
|
||||
if (transaction.commentaire) {
|
||||
memo += ` (${transaction.commentaire})`;
|
||||
}
|
||||
|
||||
return {
|
||||
id: generateId(),
|
||||
accountId: account.id,
|
||||
date: date,
|
||||
amount: amount,
|
||||
description: transaction.libelle.substring(0, 255),
|
||||
type: amount >= 0 ? 'CREDIT' as const : 'DEBIT' as const,
|
||||
categoryId: null, // Will be auto-categorized later if needed
|
||||
isReconciled: false,
|
||||
fitId: generateFITID(transaction, index),
|
||||
memo: memo.length > 255 ? memo.substring(0, 255) : memo,
|
||||
checkNum: transaction.numeroCheque || undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// Insert transactions (will skip duplicates based on fitId)
|
||||
const result = await transactionService.createMany(dbTransactions);
|
||||
console.log(` → ${result.count} nouvelles transactions insérées`);
|
||||
totalTransactionsCreated += result.count;
|
||||
|
||||
// Update account balance and lastImport
|
||||
await prisma.account.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
balance: balance,
|
||||
lastImport: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(` ✓ Solde mis à jour: ${balance.toFixed(2)} EUR\n`);
|
||||
}
|
||||
|
||||
console.log('\n=== Résumé ===');
|
||||
console.log(`Comptes créés: ${totalAccountsCreated}`);
|
||||
console.log(`Comptes mis à jour: ${totalAccountsUpdated}`);
|
||||
console.log(`Transactions insérées: ${totalTransactionsCreated}`);
|
||||
console.log('\n✓ Import terminé!');
|
||||
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error('Erreur:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user