Files
fintrack/scripts/sync-categories.ts

273 lines
11 KiB
TypeScript

import { PrismaClient } from "@prisma/client"
import { defaultCategories, defaultCategoryRules, type CategoryDefinition } from "../lib/defaults"
const prisma = new PrismaClient()
async function main() {
console.log("🏷️ Synchronisation des catégories hiérarchiques...")
console.log(` ${defaultCategories.length} catégories à synchroniser\n`)
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 0: Nettoyage des doublons existants
// ═══════════════════════════════════════════════════════════════════════════
console.log("═".repeat(50))
console.log("PHASE 0: Nettoyage des doublons")
console.log("═".repeat(50))
const allExisting = await prisma.category.findMany()
const byNormalizedName = new Map<string, typeof allExisting>()
for (const cat of allExisting) {
const normalized = normalizeName(cat.name)
if (!byNormalizedName.has(normalized)) {
byNormalizedName.set(normalized, [])
}
byNormalizedName.get(normalized)!.push(cat)
}
let merged = 0
for (const [_normalized, cats] of byNormalizedName) {
if (cats.length > 1) {
// Garder celui avec emoji
let keeper = cats[0]
for (const cat of cats) {
if (/[\u{1F300}-\u{1F9FF}]/u.test(cat.name)) {
keeper = cat
break
}
}
const toDelete: typeof cats = []
for (const cat of cats) {
if (cat.id !== keeper.id) toDelete.push(cat)
}
for (const dup of toDelete) {
// Transférer transactions
await prisma.transaction.updateMany({
where: { categoryId: dup.id },
data: { categoryId: keeper.id },
})
// Transférer enfants
await prisma.category.updateMany({
where: { parentId: dup.id },
data: { parentId: keeper.id },
})
// Supprimer doublon
await prisma.category.delete({ where: { id: dup.id } })
console.log(`🗑️ Fusionné: "${dup.name}" → "${keeper.name}"`)
merged++
}
}
}
console.log(merged > 0 ? ` ${merged} doublons fusionnés` : " Aucun doublon ✓")
// Séparer parents et enfants
const parentCategories = defaultCategories.filter((c) => c.parentSlug === null)
const childCategories = defaultCategories.filter((c) => c.parentSlug !== null)
console.log(`\n📁 ${parentCategories.length} catégories parentes`)
console.log(` └─ ${childCategories.length} sous-catégories\n`)
// Map slug -> id (pour résoudre les parentId)
const slugToId = new Map<string, string>()
let created = 0
let updated = 0
let unchanged = 0
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 1: Créer/MAJ les catégories parentes
// ═══════════════════════════════════════════════════════════════════════════
console.log("═".repeat(50))
console.log("PHASE 1: Catégories parentes")
console.log("═".repeat(50))
for (const category of parentCategories) {
const result = await upsertCategory(category, null)
slugToId.set(category.slug, result.id)
if (result.action === "created") created++
else if (result.action === "updated") updated++
else unchanged++
}
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 2: Créer/MAJ les sous-catégories
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50))
console.log("PHASE 2: Sous-catégories")
console.log("═".repeat(50))
for (const category of childCategories) {
const parentId = slugToId.get(category.parentSlug!)
if (!parentId) {
console.log(`⚠️ Parent introuvable pour: ${category.name} (parentSlug: ${category.parentSlug})`)
continue
}
const result = await upsertCategory(category, parentId)
slugToId.set(category.slug, result.id)
if (result.action === "created") created++
else if (result.action === "updated") updated++
else unchanged++
}
// ═══════════════════════════════════════════════════════════════════════════
// PHASE 3: Sync des règles (optionnel)
// ═══════════════════════════════════════════════════════════════════════════
if (defaultCategoryRules.length > 0) {
console.log("\n" + "═".repeat(50))
console.log("PHASE 3: Règles de catégorisation")
console.log("═".repeat(50))
let rulesCreated = 0
let rulesSkipped = 0
for (const rule of defaultCategoryRules) {
const categoryId = slugToId.get(rule.categorySlug)
if (!categoryId) {
console.log(`⚠️ Catégorie introuvable pour règle: ${rule.categorySlug}`)
rulesSkipped++
continue
}
// Vérifier si la règle existe déjà
const existing = await prisma.categoryRule.findFirst({
where: {
categoryId,
pattern: rule.pattern,
},
})
if (!existing) {
await prisma.categoryRule.create({
data: {
categoryId,
pattern: rule.pattern,
isRegex: rule.isRegex,
},
})
console.log(`✅ Règle créée: ${rule.pattern.substring(0, 40)}...`)
rulesCreated++
} else {
rulesSkipped++
}
}
console.log(`\n📊 Règles: ${rulesCreated} créées, ${rulesSkipped} existantes`)
}
// ═══════════════════════════════════════════════════════════════════════════
// RÉSUMÉ
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n" + "═".repeat(50))
console.log("📊 RÉSUMÉ CATÉGORIES:")
console.log("═".repeat(50))
console.log(` ✅ Créées: ${created}`)
console.log(` ✏️ Mises à jour: ${updated}`)
console.log(` ⏭️ Inchangées: ${unchanged}`)
// Stats finales
const totalCategories = await prisma.category.count()
const parentCount = await prisma.category.count({ where: { parentId: null } })
const childCount = await prisma.category.count({ where: { NOT: { parentId: null } } })
const totalRules = await prisma.categoryRule.count()
const totalKeywords = defaultCategories.reduce((sum, c) => sum + c.keywords.length, 0)
console.log("\n📈 Base de données:")
console.log(` Total catégories: ${totalCategories} (${parentCount} parents, ${childCount} enfants)`)
console.log(` Total règles: ${totalRules}`)
console.log(` Total keywords: ${totalKeywords}`)
}
// Normaliser un nom (enlever emojis, espaces multiples, lowercase)
function normalizeName(name: string): string {
return name
.replace(/[\u{1F300}-\u{1F9FF}]/gu, "") // Remove emojis
.replace(/[^\w\sÀ-ÿ]/g, "") // Keep only alphanumeric and accents
.replace(/\s+/g, " ")
.toLowerCase()
.trim()
}
async function upsertCategory(
category: CategoryDefinition,
parentId: string | null
): Promise<{ id: string; action: "created" | "updated" | "unchanged" }> {
// Chercher par nom exact d'abord
let existing = await prisma.category.findFirst({
where: { name: category.name },
})
// Si pas trouvé, chercher par nom normalisé (sans emoji) dans TOUTES les catégories
if (!existing) {
const allCategories = await prisma.category.findMany()
const normalizedTarget = normalizeName(category.name)
for (const cat of allCategories) {
if (normalizeName(cat.name) === normalizedTarget) {
existing = cat
console.log(` 🔗 Match normalisé: "${cat.name}" → "${category.name}"`)
break
}
}
}
if (existing) {
// Comparer pour voir si mise à jour nécessaire
const existingKeywords = JSON.parse(existing.keywords) as string[]
const keywordsChanged =
JSON.stringify(existingKeywords.sort()) !== JSON.stringify([...category.keywords].sort())
const nameChanged = existing.name !== category.name
const colorChanged = existing.color !== category.color
const iconChanged = existing.icon !== category.icon
const parentChanged = existing.parentId !== parentId
if (nameChanged || keywordsChanged || colorChanged || iconChanged || parentChanged) {
await prisma.category.update({
where: { id: existing.id },
data: {
name: category.name, // Met à jour le nom aussi (ajout emoji)
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
parentId: parentId,
},
})
console.log(`✏️ MAJ: ${existing.name}${nameChanged ? `${category.name}` : ""}`)
if (keywordsChanged) {
console.log(` └─ Keywords: ${existingKeywords.length}${category.keywords.length}`)
}
if (parentChanged) {
console.log(` └─ Parent modifié`)
}
return { id: existing.id, action: "updated" }
}
return { id: existing.id, action: "unchanged" }
}
// Créer nouvelle catégorie
const created = await prisma.category.create({
data: {
name: category.name,
color: category.color,
icon: category.icon,
keywords: JSON.stringify(category.keywords),
parentId: parentId,
},
})
console.log(`✅ Créée: ${category.name}${category.keywords.length > 0 ? ` (${category.keywords.length} keywords)` : ""}`)
return { id: created.id, action: "created" }
}
main()
.catch((e) => {
console.error("❌ Erreur:", e)
process.exit(1)
})
.finally(async () => {
await prisma.$disconnect()
})