From bca80c5f979fafff77ab21bdef8e462b82d26ce9 Mon Sep 17 00:00:00 2001 From: cammo22 Date: Wed, 23 Sep 2026 14:19:07 +0200 Subject: [PATCH] Import Excel tollerante: file simili ma non identici (fase 9, #47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Il consulente userà file "simili" al suo modello, non identici: prima bastava un titolo sopra le intestazioni, un foglio in più davanti o "Codice" al posto di "Codice Conto Mastro" per fermare l'import. - la riga di intestazione si cerca nelle prime 20 righe e il foglio fra tutti quelli del file; se i fogli buoni sono più d'uno si sceglie - sinonimi per colonne, sezioni e sotto-classificazioni; numerazioni, punteggiatura, maiuscole e "v/" non contano - una sezione sconosciuta si abbina a mano dalla schermata: l'anteprima si rifà e l'import scrive con lo stesso abbinamento, annotato nel documento di origine - un conto senza TIPO prende quello della sezione; un TIPO incoerente con la sezione si ferma nel riepilogo invece di entrare col segno sbagliato (stessa regola dei conti scritti nel programma: typesForSection ora vive nel parser e il ledger la riusa) - importi come testo: "1.234" migliaia, "(1.234,56)" e "1.234,56-" negativi, "-" vuoto; una cella in formato % (0,7) diventa 70 Provato anche sull'app compilata, via API: file "da un altro studio" → anteprima → abbinamento → import → conti con sezione, tipo, 70% e sotto-classificazione giusti. I due test sul file vero del consulente restano verdi. 170 test. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 11 +- .../chart-of-accounts.tolerance.test.ts | 186 +++++++ src/main/import/chart-of-accounts.ts | 520 ++++++++++++------ src/main/server/routes/analysis.routes.ts | 22 +- src/main/server/services/import.service.ts | 32 +- src/main/server/services/ledger.service.ts | 10 +- src/renderer/src/pages/business/DataView.tsx | 2 +- .../src/pages/business/ImportPanel.tsx | 120 +++- 8 files changed, 715 insertions(+), 188 deletions(-) create mode 100644 src/main/import/chart-of-accounts.tolerance.test.ts diff --git a/AGENTS.md b/AGENTS.md index 986af96..370c3d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -262,6 +262,15 @@ Prende la forma dell'attività (indovinata dal *tipo di attività* dell'anagrafi ⚠️ **"Simili" implica variabilità** — file di clienti/periodi diversi non saranno byte-identici (righe compilate diverse, forse qualche colonna in più/meno, intestazioni leggermente diverse). L'importer **non deve fare affidamento su numeri di riga fissi**: deve riconoscere le sezioni dalle intestazioni di categoria (colonna `Descrizione`, es. "RICAVI OPERATIVI", "COSTI MATERIE PRIME"...) e dai nomi di colonna in riga 1, con un **riepilogo pre-conferma** (righe riconosciute / non riconosciute / da mappare a mano) prima di scrivere sul database — mai un import "silenzioso" su dati contabili. Questo è ora un requisito, non solo una buona pratica. +**Fase 9 — cosa tollera oggi** (`src/main/import/chart-of-accounts.ts`, test in `chart-of-accounts.tolerance.test.ts`): +- **Dove sta la tabella**: la riga di intestazione si cerca nelle prime 20 righe (sopra ci possono essere titolo, azienda, data) e il foglio fra tutti quelli del file: prima quello scelto, poi "PIANO DEI CONTI", poi il primo con la colonna TIPO, poi il primo con codice e descrizione. Se i fogli buoni sono più d'uno, la schermata fa scegliere. +- **Intestazioni con altre parole**: "Tipologia", "Codice", "Descrizione conto", "Saldo", "Importo", "% diretto", "Crediti verso clienti"… Maiuscole, accenti, parentesi, punti e "v/" non contano. +- **Sezioni numerate o con sinonimi**: "1. Ricavi operativi:", "B) Costi del personale", "Debiti a m/l termine". Una sezione che non si riconosce **si abbina a mano** dalla schermata: l'anteprima si rifà, e l'import scrive con lo stesso abbinamento (annotato nel documento di origine). +- **TIPO mancante**: un conto senza TIPO prende quello della sua sezione, e il riepilogo lo dice. Un TIPO **incoerente** con la sezione (un RICAVO fra i costi del personale) si ferma nel riepilogo: finirebbe nel bilancio col segno sbagliato. +- **Importi scritti come testo**: "1.234,56", "1.234" (migliaia), "(1.234,56)" e "1.234,56-" negativi, "-" vuoto. Una percentuale in una cella formattata "%" (0,7) diventa 70. + +Quello che non si riconosce non si indovina: si chiede. La prova su più file veri di studi diversi resta da fare (§13, fase 9). + ### 11.2 Import XML fatture elettroniche (FatturaPA) — non richiesto ora, resta come idea futura Il "file XML" annunciato nel brief iniziale **non esisteva**: il cliente ha confermato che si trattava di un refuso e intendeva l'Excel (§11.1). Non c'è quindi, al momento, una richiesta reale di import FatturaPA — la ricerca fatta sul tracciato (blocchi `FatturaElettronicaHeader`/`FatturaElettronicaBody`, `TipoDocumento` TD01/TD04/TD05, oltre 200 campi) resta valida **se e quando** servirà, ma va trattata come idea di Fase futura (collegata al Cassetto Fiscale, §11.4) e non come requisito MVP. Non investire tempo di progettazione qui finché non arriva una richiesta esplicita con un file reale. @@ -308,7 +317,7 @@ La nota *"se i numeri sono questi cosa devo fare per crescere?"* suggerisce un l | **6** | UI Banche e Finanziamenti (§10.7) + collegamento rate→Cash Flow | Fidi/finanziamenti con impatto visibile in Tesoreria | ✅ **Fatta** (sessione 2) | | **7** | Analisi & Simulazioni (§10.8) | Scenario what-if salvabile e confrontabile | ✅ **Fatta** (sessione 2) | | **8** | Sync Consulente↔Azienda via Tailscale (§6) + status bar (§7) | Due installazioni reali che si scambiano dati | ⬜ Prossima | -| **9** | Import Excel avanzato: tolleranza a varianti di formato tra clienti/periodi (§11.1) | Import robusto su più file Excel reali diversi tra loro | ⬜ | +| **9** | Import Excel avanzato: tolleranza a varianti di formato tra clienti/periodi (§11.1) | Import robusto su più file Excel reali diversi tra loro | 🟡 **Fatta la tolleranza** (versione 1.4.0, §11.1): intestazioni e fogli ovunque, sinonimi, sezioni da abbinare a mano, TIPO dalla sezione, importi all'italiana. Manca la prova su file veri di più studi | | **10** | Installer offline (electron-builder) per Consulente e Azienda | `.exe` funzionanti, Tailscale bundled | 🟡 **Parziale**: `.exe` installabile, portable e demo funzionanti. Mancano le due varianti separate e Tailscale bundled, che hanno senso solo dopo la Fase 8. **Regola del cliente (2026-09-16): una release a ogni aggiornamento importante, sempre con i tre eseguibili — installer, portable e demo** (`npm run dist` e `npm run dist:demo`). La prima così è la v0.0.5, con le Fasi 5 e 6 | | **A** | Attività e Tempi (§10.11), idea presa da Ever Teams | Bacheca, timer, ore e valore per azienda | ✅ **Fatta** (versione 1.2.0) | | **B** | Demo per Android (§13, versione 1.2.0) | APK con i dati di esempio, dalla release | ✅ **Fatta** (versione 1.2.0) — da provare su più telefoni | diff --git a/src/main/import/chart-of-accounts.tolerance.test.ts b/src/main/import/chart-of-accounts.tolerance.test.ts new file mode 100644 index 0000000..53745b4 --- /dev/null +++ b/src/main/import/chart-of-accounts.tolerance.test.ts @@ -0,0 +1,186 @@ +import ExcelJS from 'exceljs' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { AccountType } from '@shared/types' +import { SECTIONS } from '../db/migrations/002_financial_model' +import { detailTagFromHeader, parseAmount, readPreview } from './chart-of-accounts' + +/** + * Fase 9 (§13): file "simili" ma non identici. Qui un piano dei conti come + * potrebbe uscire da un altro studio o da un gestionale: titolo sopra le + * intestazioni, un foglio di note prima, sinonimi al posto dei nomi del + * modello, sezioni numerate, importi scritti come testo all'italiana. + */ + +const CATALOGO = SECTIONS.map((s) => ({ + code: s.code, + label: s.label, + account_type: s.account_type as AccountType +})) + +let dir: string +let filePath: string + +beforeAll(async () => { + dir = await mkdtemp(join(tmpdir(), 'daprodfinanza-import-tollerante-')) + filePath = join(dir, 'bilancio-altro-studio.xlsx') + + const workbook = new ExcelJS.Workbook() + workbook.addWorksheet('Note').addRow(['Bilancio di verifica fornito dal cliente']) + + const sheet = workbook.addWorksheet('Foglio1') + sheet.addRow(['Rossi S.r.l. — bilancio al 31/12']) + sheet.addRow([]) + sheet.addRow([ + 'Tipologia', + 'Codice', + 'Descrizione conto', + 'Saldo', + '% diretto', + 'Debiti v/fornitori (costi variabili)', + 'Crediti verso clienti' + ]) + const rows: (string | number | null)[][] = [ + [null, null, '1. Ricavi operativi:'], + ['RICAVO', '60.01', 'Vendite', 1_000_000], + [null, null, 'B) Costi del personale'], + ['COSTO', '70.40', 'Stipendi', '250.000,50', 0.7], + // senza TIPO: con il catalogo prende quello della sezione + [null, '70.41', 'Contributi', '80.000'], + // TIPO incoerente con la sezione: si ferma nel riepilogo + ['RICAVO', '70.42', 'Rimborso dipendente', 100], + [null, null, 'Liquidità differite'], + ["ATTIVITA'", '21.01', 'Clienti Italia', 180_000, null, null, 'x'], + [null, null, 'Debiti a m/l termine'], + ["PASSIVITA'", '32.01', 'Mutuo', '(1.234,56)'], + [null, null, 'Debiti a breve termine'], + ["PASSIVITA'", '40.01', 'Fornitori merci', '5.000,00-', null, 'X'], + ["PASSIVITA'", '40.02', 'Fornitori vari', '-'], + [null, null, 'Spese varie di gestione'], + ['COSTO', '75.01', 'Cancelleria', 1_200] + ] + for (const row of rows) sheet.addRow(row) + // La colonna "% diretto" formattata come percentuale: la cella contiene 0,7. + sheet.getCell('E7').numFmt = '0%' + + await workbook.xlsx.writeFile(filePath) +}) + +afterAll(async () => { + await rm(dir, { recursive: true, force: true }) +}) + +describe('Import tollerante (fase 9)', () => { + it('trova il foglio e la riga di intestazione anche se non sono i primi', async () => { + const preview = await readPreview(filePath, { sections: CATALOGO }) + expect(preview.file.sheet).toBe('Foglio1') + expect(preview.availableSheets).toEqual(['Foglio1']) + expect(preview.valueColumn).toBe('Saldo') + }) + + it('riconosce le sezioni numerate e scritte con altre parole', async () => { + const preview = await readPreview(filePath, { sections: CATALOGO }) + const per = new Map(preview.accounts.map((a) => [a.code, a.section_code])) + expect(per.get('60.01')).toBe('ricavi_operativi') + expect(per.get('70.40')).toBe('costi_personale') + expect(per.get('21.01')).toBe('liquidita_differite') + expect(per.get('32.01')).toBe('debiti_medio_lungo') + expect(per.get('40.01')).toBe('debiti_breve') + }) + + it('legge gli importi scritti come testo all’italiana, negativi compresi', async () => { + const preview = await readPreview(filePath, { sections: CATALOGO }) + const cents = new Map(preview.accounts.map((a) => [a.code, a.amount_cents])) + expect(cents.get('70.40')).toBe(25_000_050) + expect(cents.get('70.41')).toBe(8_000_000) + expect(cents.get('32.01')).toBe(-123_456) + expect(cents.get('40.01')).toBe(-500_000) + expect(cents.get('40.02')).toBeNull() + }) + + it('porta a 70 una percentuale di costo diretto scritta come cella percentuale', async () => { + const preview = await readPreview(filePath, { sections: CATALOGO }) + expect(preview.accounts.find((a) => a.code === '70.40')?.direct_cost_pct).toBe(70) + }) + + it('riconosce le sotto-classificazioni scritte con altre parole', async () => { + const preview = await readPreview(filePath, { sections: CATALOGO }) + const tag = new Map(preview.accounts.map((a) => [a.code, a.detail_tag])) + expect(tag.get('21.01')).toBe('Crediti Commerciali') + expect(tag.get('40.01')).toBe('Debiti v/Fornitori (costi variabili)') + expect(detailTagFromHeader('DEBITI V/ENTI PREVIDENZIALI')).toBe('Debiti v/Enti Previdenziali') + }) + + it('dà a un conto senza TIPO quello della sua sezione, e lo dice', async () => { + const preview = await readPreview(filePath, { sections: CATALOGO }) + expect(preview.accounts.find((a) => a.code === '70.41')?.account_type).toBe('COSTO') + expect(preview.warnings.join(' ')).toContain('1 conti senza TIPO') + }) + + it('senza catalogo non indovina il TIPO mancante', async () => { + const preview = await readPreview(filePath) + expect(preview.accounts.find((a) => a.code === '70.41')).toBeUndefined() + expect(preview.unmapped.find((u) => u.code === '70.41')?.reason).toMatch(/manca il tipo/i) + }) + + it('ferma nel riepilogo un TIPO incoerente con la sezione', async () => { + const preview = await readPreview(filePath, { sections: CATALOGO }) + expect(preview.accounts.find((a) => a.code === '70.42')).toBeUndefined() + expect(preview.unmapped.find((u) => u.code === '70.42')?.reason).toMatch(/incoerente/) + }) + + it('lascia abbinare a mano una sezione sconosciuta', async () => { + const prima = await readPreview(filePath, { sections: CATALOGO }) + const sconosciuta = prima.sections.find((s) => s.label === 'Spese varie di gestione') + expect(sconosciuta?.section_code).toBeNull() + expect(prima.unmapped.find((u) => u.code === '75.01')).toBeDefined() + + const dopo = await readPreview(filePath, { + sections: CATALOGO, + sectionMap: { 'Spese varie di gestione': 'costi_generali_amministrativi' } + }) + const abbinata = dopo.sections.find((s) => s.label === 'Spese varie di gestione') + expect(abbinata).toMatchObject({ section_code: 'costi_generali_amministrativi', manual: true }) + expect(dopo.accounts.find((a) => a.code === '75.01')?.section_code).toBe( + 'costi_generali_amministrativi' + ) + expect(dopo.warnings.join(' ')).not.toContain('Spese varie di gestione') + }) + + it('ignora un abbinamento verso una sezione che non esiste', async () => { + const preview = await readPreview(filePath, { + sections: CATALOGO, + sectionMap: { 'Spese varie di gestione': 'non_esiste' } + }) + expect(preview.warnings.join(' ')).toContain('Abbinamento ignorato') + expect(preview.accounts.find((a) => a.code === '75.01')).toBeUndefined() + }) + + it('usa il foglio chiesto, e avvisa se non c’è', async () => { + const preview = await readPreview(filePath, { sheet: 'Inesistente' }) + expect(preview.file.sheet).toBe('Foglio1') + expect(preview.warnings.join(' ')).toContain('"Inesistente" non esiste') + }) +}) + +describe('parseAmount', () => { + it.each([ + ['1.234,56', 1234.56], + ['1.234', 1234], + ['1.234.567', 1234567], + ['1234.56', 1234.56], + ['1,234.56', 1234.56], + ['€ 12,5', 12.5], + ['(1.000)', -1000], + ['1.000,00-', -1000], + ['-250', -250], + ['70%', 70], + ['-', null], + ['', null], + ['abc', null] + ])('%s → %s', (raw, expected) => { + expect(parseAmount(raw)).toBe(expected) + }) +}) diff --git a/src/main/import/chart-of-accounts.ts b/src/main/import/chart-of-accounts.ts index fa9e21d..580301b 100644 --- a/src/main/import/chart-of-accounts.ts +++ b/src/main/import/chart-of-accounts.ts @@ -11,11 +11,17 @@ import type { AccountType } from '@shared/types' * * 1. **Niente numeri di riga fissi.** File di clienti e periodi diversi non * sono identici: le sezioni si riconoscono dalle intestazioni di categoria - * nella colonna `Descrizione`, le colonne dai nomi nella prima riga. + * nella colonna `Descrizione`, le colonne dai nomi nella riga di + * intestazione — che non è per forza la prima, né sul primo foglio. * 2. **Mai un import silenzioso su dati contabili.** Questo modulo si ferma a * produrre un *riepilogo*: righe riconosciute, righe da mappare a mano, * doppioni. Scrivere sul database è un passo separato, che parte solo dopo * una conferma esplicita. + * + * Dalla fase 9 (§13) la lettura è tollerante: sinonimi nelle intestazioni, + * punteggiatura e numerazioni diverse nelle sezioni, importi scritti come + * testo in più forme. Quello che non si riconosce non si indovina: si chiede + * (una sezione sconosciuta si abbina a mano, e l'anteprima si rifà). */ /** Normalizza per il confronto: minuscole, senza accenti, spazi compattati. */ @@ -28,6 +34,27 @@ function norm(value: string): string { .toLowerCase() } +/** + * Chiave di confronto per intestazioni ed etichette: oltre a `norm`, toglie la + * punteggiatura che cambia da un file all'altro. "Debiti v/Fornitori (costi + * variabili)" e "DEBITI VERSO FORNITORI COSTI VARIABILI" danno la stessa chiave. + * Restano "/" (c/IVA, medio/lungo), "%" e "-" (VALORE T -1), che distinguono. + */ +function key(value: string): string { + return norm(value) + .replace(/[’`]/g, "'") + .replace(/\bv\//g, 'verso ') + .replace(/&/g, ' e ') + .replace(/[()[\]{}.,;:_*"]/g, ' ') + .replace(/\s+/g, ' ') + .trim() +} + +/** Come `key`, senza la numerazione davanti: "1. Ricavi operativi", "B) Costi personale". */ +function labelKey(value: string): string { + return key(norm(value).replace(/^(\d{1,2}|[a-z]|[ivxl]{1,4})\s*[.)-]\s+/, '')) +} + const ACCOUNT_TYPES: AccountType[] = [ 'RICAVO', 'COSTO', @@ -42,36 +69,69 @@ function parseAccountType(raw: string): AccountType | null { return ACCOUNT_TYPES.find((type) => norm(type).replace(/[’`]/g, "'") === n) ?? null } +/** + * Il tipo di un conto segue la sezione. L'unica scelta è nelle sezioni + * dell'attivo, dove un fondo (ammortamento, svalutazione) è ATTIVITA' NEGATIVO + * e si sottrae (MODELLO_FINANZIARIO.md §1). Vale per l'import e per i conti + * scritti nel programma. + */ +export function typesForSection(sectionType: AccountType): AccountType[] { + return sectionType === "ATTIVITA'" ? ["ATTIVITA'", "ATTIVITA' NEGATIVO"] : [sectionType] +} + +type SectionMapping = string | { RICAVO?: string; ATTIVITA?: string } + /** * Etichette di sezione come compaiono nel file del consulente → codice della * sezione nel database. Dove la stessa etichetta vale per due sezioni * ("Rimanenze Finali" esiste sia nel conto economico sia nell'attivo) si - * disambigua con il TIPO dei conti che stanno sotto. + * disambigua con il TIPO dei conti che stanno sotto. Dopo le etichette del + * modello, i sinonimi incontrati o prevedibili: stessa sezione, altre parole. */ -const SECTION_BY_LABEL: Record = { - 'ricavi operativi': 'ricavi_operativi', - 'rimanenze finali': { RICAVO: 'rimanenze_finali_ricavo', ATTIVITA: 'rimanenze_finali_magazzino' }, - 'proventi straordinari': 'proventi_straordinari', - 'proventi finanziari': 'proventi_finanziari', - 'esistenze iniziali': 'esistenze_iniziali', - 'costi materie prime': 'costi_materie_prime', - 'costi produzione': 'costi_produzione', - 'ammortamenti operativi': 'ammortamenti_operativi', - 'costi personale': 'costi_personale', - 'costi commerciali': 'costi_commerciali', - 'costi generali e amministrativi': 'costi_generali_amministrativi', - 'ammortamenti non operativi': 'ammortamenti_non_operativi', - 'oneri straordinari': 'oneri_straordinari', - 'oneri finanziari': 'oneri_finanziari', - imposte: 'imposte', - 'immobilizzazioni immateriali': 'immobilizzazioni_immateriali', - 'immobilizzazioni materiali': 'immobilizzazioni_materiali', - 'immobilizzazioni finanziarie': 'immobilizzazioni_finanziarie', - 'liquidita differite': 'liquidita_differite', - 'liquidita immediate': 'liquidita_immediate', - 'patrimonio netto': 'patrimonio_netto', - 'debiti a medio/lungo termine': 'debiti_medio_lungo', - 'debiti a breve termine': 'debiti_breve' +const SECTION_LABELS: [string, SectionMapping][] = [ + ['ricavi operativi', 'ricavi_operativi'], + ['rimanenze finali', { RICAVO: 'rimanenze_finali_ricavo', ATTIVITA: 'rimanenze_finali_magazzino' }], + ['proventi straordinari', 'proventi_straordinari'], + ['proventi finanziari', 'proventi_finanziari'], + ['esistenze iniziali', 'esistenze_iniziali'], + ['costi materie prime', 'costi_materie_prime'], + ['costi produzione', 'costi_produzione'], + ['ammortamenti operativi', 'ammortamenti_operativi'], + ['costi personale', 'costi_personale'], + ['costi commerciali', 'costi_commerciali'], + ['costi generali e amministrativi', 'costi_generali_amministrativi'], + ['ammortamenti non operativi', 'ammortamenti_non_operativi'], + ['oneri straordinari', 'oneri_straordinari'], + ['oneri finanziari', 'oneri_finanziari'], + ['imposte', 'imposte'], + ['immobilizzazioni immateriali', 'immobilizzazioni_immateriali'], + ['immobilizzazioni materiali', 'immobilizzazioni_materiali'], + ['immobilizzazioni finanziarie', 'immobilizzazioni_finanziarie'], + ['liquidita differite', 'liquidita_differite'], + ['liquidita immediate', 'liquidita_immediate'], + ['patrimonio netto', 'patrimonio_netto'], + ['debiti a medio/lungo termine', 'debiti_medio_lungo'], + ['debiti a breve termine', 'debiti_breve'], + // sinonimi + ['rimanenze iniziali', 'esistenze_iniziali'], + ['costi per materie prime', 'costi_materie_prime'], + ['costi di produzione', 'costi_produzione'], + ['costi del personale', 'costi_personale'], + ['costi generali amministrativi', 'costi_generali_amministrativi'], + ['costi generali ed amministrativi', 'costi_generali_amministrativi'], + ['debiti a medio lungo termine', 'debiti_medio_lungo'], + ['debiti a medio-lungo termine', 'debiti_medio_lungo'], + ['debiti a m/l termine', 'debiti_medio_lungo'], + ['debiti medio/lungo termine', 'debiti_medio_lungo'], + ['debiti a breve', 'debiti_breve'], + ['debiti breve termine', 'debiti_breve'] +] + +const SECTION_BY_LABEL = new Map(SECTION_LABELS.map(([label, code]) => [labelKey(label), code])) + +/** Sezione di un'etichetta del file; `undefined` se l'etichetta non è nota. */ +function sectionFromLabel(label: string): SectionMapping | undefined { + return SECTION_BY_LABEL.get(labelKey(label)) } /** @@ -83,24 +143,26 @@ const SECTION_BY_LABEL: Record = { - 'crediti commerciali': 'Crediti Commerciali', - 'crediti diversi': 'Crediti Diversi', - 'erario c/iva': 'Erario c/IVA', - 'utile a nuovo': 'Utile a nuovo', - utile: 'Utile', - 'fondo tfr': 'Fondo TFR', - 'debiti diversi': 'Debiti Diversi', - 'debiti verso fornitori costi variabili': 'Debiti v/Fornitori (costi variabili)', - 'debiti verso fornitori costi fissi': 'Debiti v/Fornitori (costi fissi)', - 'debiti v/enti previdenziali': 'Debiti v/Enti Previdenziali', - 'debiti verso enti previdenziali': 'Debiti v/Enti Previdenziali' -} +const DETAIL_TAG_HEADERS: [string, string][] = [ + ['Crediti Commerciali', 'Crediti Commerciali'], + ['Crediti verso clienti', 'Crediti Commerciali'], + ['Crediti Diversi', 'Crediti Diversi'], + ['Erario c/IVA', 'Erario c/IVA'], + ['Utile a nuovo', 'Utile a nuovo'], + ['Utile', 'Utile'], + ['Fondo TFR', 'Fondo TFR'], + ['Debiti Diversi', 'Debiti Diversi'], + ['Debiti verso Fornitori costi variabili', 'Debiti v/Fornitori (costi variabili)'], + ['Debiti verso Fornitori costi fissi', 'Debiti v/Fornitori (costi fissi)'], + ['Debiti verso Enti Previdenziali', 'Debiti v/Enti Previdenziali'] +] + +const DETAIL_TAG_BY_HEADER = new Map(DETAIL_TAG_HEADERS.map(([header, tag]) => [key(header), tag])) /** * Intestazione con cui il modello scaricabile scrive ogni sotto-classificazione. - * Ognuna deve essere una chiave di DETAIL_TAG_BY_HEADER, una volta normalizzata: - * il modello si deve rileggere senza perdite (lo verifica un test). + * Ognuna deve essere riconosciuta da `detailTagFromHeader`: il modello si deve + * rileggere senza perdite (lo verifica un test). */ export const DETAIL_TAG_TEMPLATE_HEADERS: Record = { 'Crediti Commerciali': 'Crediti Commerciali', @@ -117,16 +179,50 @@ export const DETAIL_TAG_TEMPLATE_HEADERS: Record = { /** Riconosce una sotto-classificazione dall'intestazione di colonna. */ export function detailTagFromHeader(header: string): string | null { - return DETAIL_TAG_BY_HEADER[norm(header)] ?? null + return DETAIL_TAG_BY_HEADER.get(key(header)) ?? null } -/** Intestazioni delle colonne che servono, con le varianti già incontrate. */ -const COLUMN_ALIASES: Record = { - type: ['tipo'], - code: ['codice conto mastro', 'codice conto', 'codice'], - name: ['descrizione'], - period: ['periodo (t)', 'periodo'], - directCostPct: ['% costo diretto'] +/** Intestazioni delle colonne che servono, con le varianti già incontrate o prevedibili. */ +const COLUMN_ALIASES: Record<'type' | 'code' | 'name' | 'period' | 'directCostPct', string[]> = { + type: ['tipo', 'tipo conto', 'tipologia'], + code: ['codice conto mastro', 'codice conto', 'codice mastro', 'codice', 'cod conto', 'cod', 'conto', 'mastro'], + name: ['descrizione', 'descrizione conto', 'denominazione', 'nome conto'], + period: ['periodo t', 'periodo'], + directCostPct: ['% costo diretto', '% diretto', 'costo diretto %', 'percentuale costo diretto'] +} + +/** + * Colonne di valore: "VALORE T", "VALORE T -1", "BUDGET Gennaio", "CONSUNT + * Marzo", e nei file esportati da un gestionale "Saldo" o "Importo". + */ +const VALUE_COLUMN = /^(valore|saldo|importo|budget |consunt|previsionale)/ + +/** Fin dove si cerca la riga di intestazione: sopra ci possono essere titolo, azienda, data. */ +const HEADER_SEARCH_ROWS = 20 + +/** Sezione del catalogo, quanto basta al parser per dedurre e controllare il TIPO. */ +export interface ParserSection { + code: string + label: string + account_type: AccountType +} + +export interface ReadOptions { + /** Colonna dei valori da leggere; senza, "VALORE T" o la prima trovata. */ + valueColumn?: string + /** Foglio da leggere; senza, "PIANO DEI CONTI" o il primo che ne ha la forma. */ + sheet?: string + /** + * Abbinamenti fatti a mano: etichetta di sezione come scritta nel file → + * codice della sezione. Vincono sul riconoscimento automatico. + */ + sectionMap?: Record + /** + * Catalogo delle sezioni (migrazione 002). Con il catalogo un conto senza + * TIPO prende quello della sezione, e un TIPO incoerente con la sezione si + * ferma nel riepilogo invece di finire nel bilancio col segno sbagliato. + */ + sections?: ParserSection[] } export interface ParsedAccount { @@ -155,12 +251,16 @@ export interface UnmappedRow { export interface SectionSummary { label: string section_code: string | null + /** true quando la sezione è stata abbinata a mano (`sectionMap`). */ + manual: boolean rows: number withValue: number } export interface ImportPreview { file: { name: string; sha256: string; sheet: string } + /** Fogli del file che hanno la forma di un piano dei conti. */ + availableSheets: string[] /** Colonna valore scelta, e tutte quelle disponibili nel file. */ valueColumn: string | null availableValueColumns: string[] @@ -185,6 +285,35 @@ function cellText(cell: ExcelJS.Cell | undefined): string { return String(value) } +/** + * Un importo, numerico o scritto come testo. Il testo si legge nelle forme + * che un file italiano usa davvero: "1.234,56", "1.234" (migliaia), "1234.56", + * "(1.234,56)" e "1.234,56-" per i negativi, "-" per il vuoto. + */ +export function parseAmount(raw: string): number | null { + let text = raw.replace(/[\s€]/g, '') + if (!text || /^[-–—]+$/.test(text)) return null + + let sign = 1 + if (/^\(.*\)$/.test(text)) { + sign = -1 + text = text.slice(1, -1) + } + if (/\d-$/.test(text)) { + sign = -sign + text = text.slice(0, -1) + } + text = text.replace(/%$/, '') + + let cleaned: string + if (/,\d{1,2}$/.test(text)) cleaned = text.replace(/\./g, '').replace(',', '.') + else if (/^-?\d{1,3}(\.\d{3})+$/.test(text)) cleaned = text.replace(/\./g, '') + else cleaned = text.replace(/,/g, '') + + const parsed = Number(cleaned) + return Number.isFinite(parsed) ? sign * parsed : null +} + function cellNumber(cell: ExcelJS.Cell | undefined): number | null { if (!cell) return null const value = cell.value @@ -193,13 +322,18 @@ function cellNumber(cell: ExcelJS.Cell | undefined): number | null { if (typeof value === 'object' && 'result' in value && typeof value.result === 'number') { return value.result } - // Numeri scritti come testo: "1.234,56" all'italiana oppure "1234.56". - const text = cellText(cell).replace(/[\s€]/g, '') - if (!text) return null - const italian = /,\d{1,2}$/.test(text) - const cleaned = italian ? text.replace(/\./g, '').replace(',', '.') : text.replace(/,/g, '') - const parsed = Number(cleaned) - return Number.isFinite(parsed) ? parsed : null + return parseAmount(cellText(cell)) +} + +/** + * Una percentuale di costo diretto. Una cella formattata come percentuale + * contiene 0,7 per "70%": nel database si tiene 70. + */ +function cellPercent(cell: ExcelJS.Cell | undefined): number | null { + const value = cellNumber(cell) + if (value === null || !cell) return null + const numeric = typeof cell.value === 'number' || (typeof cell.value === 'object' && cell.value !== null && 'result' in cell.value) + return numeric && cell.numFmt?.includes('%') ? Math.round(value * 10000) / 100 : value } /** Euro → centesimi interi, senza passare dalla virgola mobile all'arrotondamento. */ @@ -207,9 +341,49 @@ export function toCents(amount: number): number { return Math.round(amount * 100) } +type ColumnKey = keyof typeof COLUMN_ALIASES + +interface HeaderInfo { + row: number + /** colonna → chiave dell'intestazione */ + headers: Map + col: Record +} + +/** Cerca la riga di intestazione di un foglio: la prima con codice e descrizione. */ +function findHeader(sheet: ExcelJS.Worksheet): HeaderInfo | null { + const last = Math.min(sheet.rowCount, HEADER_SEARCH_ROWS) + for (let r = 1; r <= last; r++) { + const headers = new Map() + sheet.getRow(r).eachCell({ includeEmpty: false }, (cell, c) => { + const text = key(cellText(cell)) + if (text) headers.set(c, text) + }) + + const find = (aliases: string[]): number | null => { + // Si prova alias per alias, così "codice conto" vince su "conto" se ci sono entrambe. + for (const alias of aliases) { + for (const [c, text] of headers) if (text === alias) return c + } + return null + } + const col = { + type: find(COLUMN_ALIASES.type), + code: find(COLUMN_ALIASES.code), + name: find(COLUMN_ALIASES.name), + period: find(COLUMN_ALIASES.period), + directCostPct: find(COLUMN_ALIASES.directCostPct) + } + if (col.code !== null && col.name !== null && col.code !== col.name) { + return { row: r, headers, col } + } + } + return null +} + export async function readPreview( filePath: string, - options: { valueColumn?: string } = {} + options: ReadOptions = {} ): Promise { const buffer = await readFile(filePath) const sha256 = createHash('sha256').update(buffer).digest('hex') @@ -217,49 +391,51 @@ export async function readPreview( const workbook = new ExcelJS.Workbook() await workbook.xlsx.load(buffer as unknown as ArrayBuffer) - const sheet = - workbook.worksheets.find((w) => norm(w.name) === 'piano dei conti') ?? workbook.worksheets[0] - if (!sheet) throw new Error('Il file non contiene alcun foglio.') - const warnings: string[] = [] - // --- colonne, per nome e non per posizione --------------------------------- - const headerRow = sheet.getRow(1) - const headers = new Map() - headerRow.eachCell({ includeEmpty: false }, (cell, col) => { - const text = norm(cellText(cell)) - if (text) headers.set(col, text) - }) - - const findColumn = (aliases: string[]): number | null => { - for (const [col, text] of headers) { - if (aliases.includes(text)) return col - } - return null + // --- foglio e intestazioni: per nome, e non per posizione ------------------ + const candidates = workbook.worksheets + .map((sheet) => ({ sheet, header: findHeader(sheet) })) + .filter((c): c is { sheet: ExcelJS.Worksheet; header: HeaderInfo } => c.header !== null) + + if (candidates.length === 0) { + throw new Error( + workbook.worksheets.length === 0 + ? 'Il file non contiene alcun foglio.' + : 'Nessun foglio ha la forma di un piano dei conti: servono almeno le colonne ' + + '"Codice conto" e "Descrizione" (e di solito "TIPO").' + ) } - const col = { - type: findColumn(COLUMN_ALIASES.type), - code: findColumn(COLUMN_ALIASES.code), - name: findColumn(COLUMN_ALIASES.name), - period: findColumn(COLUMN_ALIASES.period), - directCostPct: findColumn(COLUMN_ALIASES.directCostPct) + // Preferenze: il foglio chiesto, poi quello che si chiama "Piano dei conti", + // poi il primo con la colonna TIPO, poi il primo che ha codice e descrizione. + const requested = options.sheet + ? candidates.find((c) => norm(c.sheet.name) === norm(options.sheet!)) + : undefined + if (options.sheet && !requested) { + warnings.push(`Il foglio "${options.sheet}" non esiste o non ha la forma di un piano dei conti.`) } - - for (const [key, index] of Object.entries(col)) { - if (index === null && key !== 'period' && key !== 'directCostPct') { - throw new Error( - `Nel foglio "${sheet.name}" manca la colonna "${COLUMN_ALIASES[key][0]}". ` + - 'Il file non ha la forma di un piano dei conti.' - ) - } + const chosen = + requested ?? + candidates.find((c) => key(c.sheet.name) === 'piano dei conti') ?? + candidates.find((c) => c.header.col.type !== null) ?? + candidates[0] + const { sheet, header } = chosen + const { col, headers } = header + const headerRow = sheet.getRow(header.row) + + if (col.type === null) { + warnings.push( + options.sections + ? 'Il file non ha la colonna TIPO: ogni conto prende il tipo della sua sezione.' + : 'Il file non ha la colonna TIPO.' + ) } - // Colonne di valore: quelle con un'intestazione tipo "VALORE T", "BUDGET - // Gennaio", "CONSUNT Marzo". Sono quelle fra cui il consulente sceglie. + // Colonne di valore: quelle fra cui il consulente sceglie. const valueColumns = new Map() for (const [index, text] of headers) { - if (/^(valore t|budget |consunt|previsionale)/.test(text)) { + if (VALUE_COLUMN.test(text)) { valueColumns.set(cellText(headerRow.getCell(index)).replace(/\s+/g, ' ').trim(), index) } } @@ -267,14 +443,14 @@ export async function readPreview( // Colonne di sotto-classificazione presenti in questo file. const detailColumns: { col: number; tag: string }[] = [] for (const [index, text] of headers) { - const tag = DETAIL_TAG_BY_HEADER[text] + const tag = DETAIL_TAG_BY_HEADER.get(text) if (tag) detailColumns.push({ col: index, tag }) } const availableValueColumns = [...valueColumns.keys()] const chosenName = options.valueColumn ?? - availableValueColumns.find((name) => norm(name) === 'valore t') ?? + availableValueColumns.find((name) => key(name) === 'valore t') ?? availableValueColumns[0] ?? null const valueCol = chosenName ? (valueColumns.get(chosenName) ?? null) : null @@ -283,113 +459,144 @@ export async function readPreview( warnings.push(`La colonna valore "${options.valueColumn}" non esiste in questo file.`) } + // --- catalogo e abbinamenti a mano ------------------------------------------ + const catalog = new Map((options.sections ?? []).map((s) => [s.code, s])) + const manualMap = new Map() + for (const [label, code] of Object.entries(options.sectionMap ?? {})) { + if (catalog.size > 0 && !catalog.has(code)) { + warnings.push(`Abbinamento ignorato: "${label}" → sezione sconosciuta "${code}".`) + continue + } + manualMap.set(labelKey(label), code) + } + + /** Sezione di un conto, dall'etichetta sotto cui si trova e dal suo TIPO. */ + const resolveSection = (label: string | null, type: AccountType | null): string | null => { + if (!label) return null + const manual = manualMap.get(labelKey(label)) + if (manual) return manual + const mapping = sectionFromLabel(label) + if (mapping === undefined) return null + if (typeof mapping === 'string') return mapping + if (type === 'RICAVO') return mapping.RICAVO ?? null + if (type?.startsWith('ATTIVITA')) return mapping.ATTIVITA ?? null + return null + } + // --- scorrimento delle righe ---------------------------------------------- const accounts: ParsedAccount[] = [] const unmapped: UnmappedRow[] = [] - const sections = new Map() + const sections: SectionSummary[] = [] const seenCodes = new Map() let templateRows = 0 + let typeFromSection = 0 let currentLabel: string | null = null - let currentCode: string | null | undefined = undefined const lastRow = sheet.rowCount - for (let r = 2; r <= lastRow; r++) { + for (let r = header.row + 1; r <= lastRow; r++) { const row = sheet.getRow(r) - const rawType = cellText(row.getCell(col.type!)).trim() + const rawType = col.type !== null ? cellText(row.getCell(col.type)).trim() : '' const name = cellText(row.getCell(col.name!)).trim() const code = cellText(row.getCell(col.code!)).trim() - // Intestazione di sezione: descrizione presente, TIPO assente. - if (!rawType && name) { + // Intestazione di sezione: descrizione senza TIPO, e senza codice — a meno + // che la descrizione sia proprio il nome di una sezione ("60 RICAVI OPERATIVI"). + const knownLabel = name ? sectionFromLabel(name) !== undefined || manualMap.has(labelKey(name)) : false + if (!rawType && name && (!code || knownLabel)) { currentLabel = name - const mapping = SECTION_BY_LABEL[norm(name)] - currentCode = typeof mapping === 'string' ? mapping : undefined - if (mapping === undefined) { - currentCode = null - warnings.push(`Sezione non riconosciuta alla riga ${r}: "${name}".`) + const manual = manualMap.get(labelKey(name)) + const mapping = sectionFromLabel(name) + if (!manual && mapping === undefined) { + warnings.push(`Sezione non riconosciuta alla riga ${r}: "${name}". Si può abbinare a mano.`) } - sections.set(`${r}:${name}`, { + sections.push({ label: name, - section_code: typeof mapping === 'string' ? mapping : null, + section_code: manual ?? (typeof mapping === 'string' ? mapping : null), + manual: manual !== undefined, rows: 0, withValue: 0 }) continue } - if (!rawType) continue - - const accountType = parseAccountType(rawType) - const sectionKey = [...sections.keys()].at(-1) - const summary = sectionKey ? sections.get(sectionKey) : undefined - - // Sezioni ambigue per nome: si sceglie in base al TIPO dei conti sotto. - let sectionCode = currentCode ?? null - if (currentLabel && sectionCode === undefined) sectionCode = null - const mapping = currentLabel ? SECTION_BY_LABEL[norm(currentLabel)] : undefined - if (mapping && typeof mapping !== 'string' && accountType) { - sectionCode = - accountType === 'RICAVO' - ? (mapping.RICAVO ?? null) - : accountType.startsWith('ATTIVITA') - ? (mapping.ATTIVITA ?? null) - : null - if (summary && sectionCode) summary.section_code = sectionCode - } - - if (summary) summary.rows++ + const summary = sections.at(-1) - // Riga del modello: struttura pronta ma nessun conto dentro. Non è un - // errore, è un file non ancora compilato. + // Riga vuota, o riga del modello senza conto dentro. if (!code && !name) { + if (!rawType) continue + if (summary) { + summary.rows++ + // Le righe del modello portano il TIPO: bastano a disambiguare la sezione. + const t = parseAccountType(rawType) + if (summary.section_code === null && t) summary.section_code = resolveSection(currentLabel, t) + } templateRows++ continue } - if (!accountType) { + if (summary) summary.rows++ + + const fail = (reason: string): void => { unmapped.push({ row: r, - reason: `TIPO non riconosciuto: "${rawType}".`, + reason, code: code || null, name: name || null, section_label: currentLabel, - raw_type: rawType + raw_type: rawType || null }) + } + + let accountType = rawType ? parseAccountType(rawType) : null + if (rawType && !accountType) { + fail(`TIPO non riconosciuto: "${rawType}".`) continue } + const sectionCode = resolveSection(currentLabel, accountType) + if (summary && sectionCode && summary.section_code === null) summary.section_code = sectionCode + if (!sectionCode) { - unmapped.push({ - row: r, - reason: currentLabel - ? `La sezione "${currentLabel}" non corrisponde a nessuna categoria nota.` - : 'Riga fuori da qualsiasi sezione.', - code: code || null, - name: name || null, - section_label: currentLabel, - raw_type: rawType - }) + const mapping = currentLabel ? sectionFromLabel(currentLabel) : undefined + fail( + !currentLabel + ? 'Riga fuori da qualsiasi sezione.' + : mapping !== undefined + ? `Manca il TIPO, che in "${currentLabel}" serve a distinguere conto economico e attivo.` + : `La sezione "${currentLabel}" non corrisponde a nessuna categoria nota.` + ) + continue + } + + const section = catalog.get(sectionCode) + if (!accountType) { + if (!section) { + fail('Manca il TIPO del conto.') + continue + } + accountType = section.account_type + typeFromSection++ + } + + if (section && !typesForSection(section.account_type).includes(accountType)) { + fail( + `TIPO "${accountType}" incoerente con la sezione "${section.label}", ` + + `che contiene conti di tipo ${section.account_type}.` + ) continue } if (!code) { - unmapped.push({ - row: r, - reason: 'Manca il codice del conto mastro.', - code: null, - name: name || null, - section_label: currentLabel, - raw_type: rawType - }) + fail('Manca il codice del conto mastro.') continue } // La prima colonna di dettaglio marcata vince: sono alternative, non // cumulabili. Se ce n'è più d'una è un errore nel file, e va detto. const marked = detailColumns.filter(({ col: c }) => { - const text = cellText(row.getCell(c)).trim().toLowerCase() - return text === 'x' || text === 'si' || text === 'sì' || text === 'true' + const text = norm(cellText(row.getCell(c))) + return text === 'x' || text === 'si' || text === 'true' || text === '1' }) if (marked.length > 1) { warnings.push( @@ -412,11 +619,15 @@ export async function readPreview( section_label: currentLabel ?? '', account_type: accountType, detail_tag: marked[0]?.tag ?? null, - direct_cost_pct: col.directCostPct ? cellNumber(row.getCell(col.directCostPct)) : null, + direct_cost_pct: col.directCostPct !== null ? cellPercent(row.getCell(col.directCostPct)) : null, amount_cents: amount === null ? null : toCents(amount) }) } + if (typeFromSection > 0 && col.type !== null) { + warnings.push(`${typeFromSection} conti senza TIPO: hanno preso quello della loro sezione.`) + } + const duplicates = [...seenCodes.entries()] .filter(([, rows]) => rows.length > 1) .map(([code, rows]) => ({ code, rows })) @@ -435,9 +646,10 @@ export async function readPreview( return { file: { name: basename(filePath), sha256, sheet: sheet.name }, + availableSheets: candidates.map((c) => c.sheet.name), valueColumn: chosenName, availableValueColumns, - sections: [...sections.values()], + sections, accounts, unmapped, templateRows, diff --git a/src/main/server/routes/analysis.routes.ts b/src/main/server/routes/analysis.routes.ts index 6335cfe..fc7fb4f 100644 --- a/src/main/server/routes/analysis.routes.ts +++ b/src/main/server/routes/analysis.routes.ts @@ -5,6 +5,7 @@ import { analyse, listPeriods, series } from '../services/analysis.service' import { applyChartOfAccounts, previewChartOfAccounts, + type PreviewChoices, writeChartOfAccountsTemplate } from '../services/import.service' import { requireAuth, requireRole } from '../middleware/auth' @@ -65,22 +66,35 @@ analysisRouter.get('/periods/:periodUuid/analysis', bilanci, (req, res) => { ) }) +/** Foglio, colonna valore e sezioni abbinate a mano, come arrivano dal modulo. */ +function previewChoices(body: unknown): PreviewChoices { + const b = (body ?? {}) as Record + const text = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined) + const sectionMap: Record = {} + if (b.sectionMap && typeof b.sectionMap === 'object') { + for (const [label, code] of Object.entries(b.sectionMap as Record)) { + if (typeof code === 'string' && code) sectionMap[label] = code + } + } + return { valueColumn: text(b.valueColumn), sheet: text(b.sheet), sectionMap } +} + /** Anteprima: legge il file e non scrive nulla (§11.1). */ analysisRouter.post('/import/chart-of-accounts/preview', requireRole('consultant'), async (req, res) => { - const { filePath, valueColumn } = req.body ?? {} + const { filePath } = req.body ?? {} if (!filePath) throw new HttpError(400, 'Manca il percorso del file da importare.') - res.json(await previewChartOfAccounts(param(req, 'uuid'), filePath, valueColumn)) + res.json(await previewChartOfAccounts(param(req, 'uuid'), filePath, previewChoices(req.body))) }) /** Scrittura: solo dopo che il consulente ha visto l'anteprima. */ analysisRouter.post('/import/chart-of-accounts', requireRole('consultant'), async (req, res) => { - const { filePath, valueColumn, year, month, scenario, overwrite } = req.body ?? {} + const { filePath, year, month, scenario, overwrite } = req.body ?? {} if (!filePath) throw new HttpError(400, 'Manca il percorso del file da importare.') if (!Number.isInteger(year)) throw new HttpError(400, "Indicare l'anno del periodo da importare.") res.status(201).json( await applyChartOfAccounts(param(req, 'uuid'), filePath, { - valueColumn, + ...previewChoices(req.body), year, month: month ?? null, scenario, diff --git a/src/main/server/services/import.service.ts b/src/main/server/services/import.service.ts index e578e33..5add9e4 100644 --- a/src/main/server/services/import.service.ts +++ b/src/main/server/services/import.service.ts @@ -4,7 +4,7 @@ import type { AccountType } from '@shared/types' import { getDatabase } from '../../db' import { newUuid, nowIso } from '../../lib/ids' import { companyFolder } from '../../lib/paths' -import { readPreview, type ImportPreview } from '../../import/chart-of-accounts' +import { readPreview, type ImportPreview, type ReadOptions } from '../../import/chart-of-accounts' import { buildTemplate, type TemplateAccount } from '../../import/template' import { HttpError } from '../http-error' import { getCompany } from './companies.service' @@ -23,13 +23,21 @@ export interface PreviewResult extends ImportPreview { alreadyImported: { filename: string; imported_at: string } | null } +/** Scelte fatte nell'anteprima: foglio, colonna valore, sezioni abbinate a mano. */ +export type PreviewChoices = Pick + export async function previewChartOfAccounts( companyUuid: string, filePath: string, - valueColumn?: string + choices: PreviewChoices = {} ): Promise { getCompany(companyUuid) - const preview = await readPreview(filePath, { valueColumn }) + const preview = await readPreview(filePath, { + valueColumn: choices.valueColumn, + sheet: choices.sheet, + sectionMap: choices.sectionMap, + sections: listSections() + }) const previous = getDatabase() .prepare( @@ -41,8 +49,7 @@ export async function previewChartOfAccounts( return { ...preview, alreadyImported: previous ?? null } } -export interface ApplyOptions { - valueColumn?: string +export interface ApplyOptions extends PreviewChoices { year: number /** null = periodo annuale. */ month?: number | null @@ -68,7 +75,13 @@ export async function applyChartOfAccounts( ): Promise { const company = getCompany(companyUuid) const db = getDatabase() - const preview = await previewChartOfAccounts(companyUuid, filePath, options.valueColumn) + // L'anteprima si rifà con le stesse scelte: si scrive esattamente quello che + // il consulente ha visto. + const preview = await previewChartOfAccounts(companyUuid, filePath, { + valueColumn: options.valueColumn, + sheet: options.sheet, + sectionMap: options.sectionMap + }) if (preview.duplicates.length > 0) { throw new HttpError( @@ -166,7 +179,12 @@ export async function applyChartOfAccounts( preview.file.sha256, archivedTo, preview.accounts.length, - `Foglio "${preview.file.sheet}", colonna valore "${preview.valueColumn}".`, + [ + `Foglio "${preview.file.sheet}", colonna valore "${preview.valueColumn}".`, + ...preview.sections + .filter((section) => section.manual) + .map((section) => `"${section.label}" abbinata a mano a ${section.section_code}.`) + ].join(' '), now, now, now diff --git a/src/main/server/services/ledger.service.ts b/src/main/server/services/ledger.service.ts index 4bcb1a5..2821b98 100644 --- a/src/main/server/services/ledger.service.ts +++ b/src/main/server/services/ledger.service.ts @@ -7,6 +7,7 @@ import type { Scenario } from '@shared/types' import { getDatabase } from '../../db' +import { typesForSection } from '../../import/chart-of-accounts' import { newUuid, nowIso } from '../../lib/ids' import { HttpError } from '../http-error' import { getCompany } from './companies.service' @@ -66,15 +67,6 @@ function getAccount(companyUuid: string, uuid: string): AccountRow { return account } -/** - * Il tipo di un conto segue la sezione. L'unica scelta è nelle sezioni - * dell'attivo, dove un fondo (ammortamento, svalutazione) è ATTIVITA' NEGATIVO - * e si sottrae (§1). - */ -export function typesForSection(sectionType: AccountType): AccountType[] { - return sectionType === "ATTIVITA'" ? ["ATTIVITA'", "ATTIVITA' NEGATIVO"] : [sectionType] -} - export function saveAccount(companyUuid: string, input: Row, uuid?: string): AccountRow { getCompany(companyUuid) const current = uuid ? getAccount(companyUuid, uuid) : null diff --git a/src/renderer/src/pages/business/DataView.tsx b/src/renderer/src/pages/business/DataView.tsx index 7d32413..e188085 100644 --- a/src/renderer/src/pages/business/DataView.tsx +++ b/src/renderer/src/pages/business/DataView.tsx @@ -123,7 +123,7 @@ export function DataView({ Facoltativo: se hai già il piano dei conti in un file Excel lo puoi caricare da qui. Il modello scaricabile ha le colonne giuste; dopo l’import tutto si modifica nel programma. - + )} diff --git a/src/renderer/src/pages/business/ImportPanel.tsx b/src/renderer/src/pages/business/ImportPanel.tsx index 5177357..df368e5 100644 --- a/src/renderer/src/pages/business/ImportPanel.tsx +++ b/src/renderer/src/pages/business/ImportPanel.tsx @@ -1,5 +1,5 @@ import { useState } from 'react' -import type { Company } from '@shared/types' +import type { AccountSection, Company } from '@shared/types' import { api, ApiRequestError } from '../../lib/api' import { euro } from '../../lib/format' import { Alert, Button, Card, Field, Select, TextInput } from '../../components/ui' @@ -10,13 +10,25 @@ import { Alert, Button, Card, Field, Select, TextInput } from '../../components/ * Il passaggio obbligato è l'anteprima: il consulente vede quante righe sono * state riconosciute e quante no *prima* che qualcosa venga scritto. Il * pulsante che importa davvero compare solo dopo. + * + * Un file che non ha esattamente la forma del modello (fase 9) non si + * rifiuta: si sceglie il foglio, e le sezioni che non si riconoscono si + * abbinano a mano. Ogni scelta rifà l'anteprima, e l'import scrive con le + * stesse scelte. */ interface Preview { file: { name: string; sha256: string; sheet: string } + availableSheets: string[] valueColumn: string | null availableValueColumns: string[] - sections: { label: string; section_code: string | null; rows: number; withValue: number }[] + sections: { + label: string + section_code: string | null + manual: boolean + rows: number + withValue: number + }[] accounts: { code: string; name: string; section_label: string; amount_cents: number | null }[] unmapped: { row: number; reason: string; code: string | null; name: string | null }[] templateRows: number @@ -97,16 +109,26 @@ const MESI = [ 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre' ] +/** Scelte fatte sull'anteprima, rimandate uguali all'import. */ +interface Scelte { + valueColumn?: string + sheet?: string + sectionMap: Record +} + export function ImportPanel({ company, + sections, onImported }: { company: Company + /** Catalogo delle sezioni, per abbinare a mano quelle che il file chiama in un altro modo. */ + sections: AccountSection[] onImported: () => void }): React.JSX.Element { const [filePath, setFilePath] = useState(null) const [preview, setPreview] = useState(null) - const [valueColumn, setValueColumn] = useState('') + const [scelte, setScelte] = useState({ sectionMap: {} }) const [year, setYear] = useState(String(new Date().getFullYear())) const [month, setMonth] = useState('') // vuoto = periodo annuale const [scenario, setScenario] = useState<'actual' | 'budget' | 'forecast'>('actual') @@ -114,17 +136,22 @@ export function ImportPanel({ const [esito, setEsito] = useState(null) const [busy, setBusy] = useState(false) - const caricaAnteprima = async (path: string, colonna?: string): Promise => { + const caricaAnteprima = async (path: string, nuove: Scelte = { sectionMap: {} }): Promise => { setError(null) setEsito(null) setBusy(true) try { const result = await api.post( `/api/companies/${company.uuid}/import/chart-of-accounts/preview`, - { filePath: path, valueColumn: colonna } + { filePath: path, ...nuove } ) setPreview(result) - setValueColumn(result.valueColumn ?? '') + // Quello che il server ha scelto davvero diventa la scelta di partenza. + setScelte({ + ...nuove, + valueColumn: result.valueColumn ?? undefined, + sheet: result.file.sheet + }) } catch (err) { setPreview(null) setError(err instanceof Error ? err.message : 'Lettura del file non riuscita.') @@ -140,6 +167,18 @@ export function ImportPanel({ await caricaAnteprima(path) } + const cambia = (modifica: Partial): void => { + if (!filePath) return + void caricaAnteprima(filePath, { ...scelte, ...modifica }) + } + + const abbina = (label: string, code: string): void => { + const sectionMap = { ...scelte.sectionMap } + if (code) sectionMap[label] = code + else delete sectionMap[label] + cambia({ sectionMap }) + } + const importa = async (overwrite = false): Promise => { if (!filePath) return setError(null) @@ -151,7 +190,7 @@ export function ImportPanel({ balances_written: number }>(`/api/companies/${company.uuid}/import/chart-of-accounts`, { filePath, - valueColumn, + ...scelte, year: Number(year), month: month ? Number(month) : null, scenario, @@ -177,6 +216,9 @@ export function ImportPanel({ } const conValore = preview?.accounts.filter((a) => a.amount_cents !== null).length ?? 0 + // Sezioni da abbinare: quelle che il file chiama in un modo che non si + // riconosce, e quelle già abbinate a mano (per poter cambiare idea). + const daAbbinare = preview?.sections.filter((s) => s.section_code === null || s.manual) ?? [] const importabile = preview !== null && conValore > 0 && preview.duplicates.length === 0 return ( @@ -211,6 +253,22 @@ export function ImportPanel({ {filePath ? filePath.split(/[\\/]/).pop() : 'Nessun file selezionato.'} + {preview && preview.availableSheets.length > 1 && ( + + )} {!preview && !busy && ( @@ -258,6 +316,46 @@ export function ImportPanel({ ))} + {daAbbinare.length > 0 && ( +
+

+ Sezioni da abbinare +

+

+ Il file le chiama in un modo che il programma non conosce. Scegli a quale sezione + del bilancio corrispondono: i loro conti entrano nel riepilogo. +

+
    + {daAbbinare.map((sezione) => ( +
  • + + {sezione.label} + + {sezione.rows} {sezione.rows === 1 ? 'riga' : 'righe'} + + + +
  • + ))} +
+
+ )} + {preview.unmapped.length > 0 && (

@@ -282,11 +380,9 @@ export function ImportPanel({