Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 26 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,7 +383,32 @@ Il motore vive in `src/shared/engine/` ed è fatto di **funzioni pure**: non sa

⚠️ **Tre punti aperti emersi leggendo il file, dettagliati in [`docs/MODELLO_FINANZIARIO.md` §8-bis](./docs/MODELLO_FINANZIARIO.md)**: il file consegnato è un **modello vuoto** (nessun saldo), il Gross Profit di §3.3 non torna con la riga di partenza dello stesso schema, e due grandezze (Acquisti, Debiti finanziari) non hanno una fonte esplicita nel modello.

**Da fare in Fase 4**: le tre schermate di §10.2-§10.4 sopra questi dati. Il motore restituisce già tutto quello che serve.
**Fase 4 — le schermate di analisi**

Navigazione a **menu laterale**, come nei mockup: in alto l'anagrafica, sotto le viste dell'azienda aperta, in fondo quelle non ancora costruite — elencate e spente, con la fase accanto. Un menu che si allunga a sorpresa disorienta più di uno che dichiara cosa manca. La navigazione vive nel guscio dell'applicazione e non dentro le pagine, così resta ferma mentre il contenuto cambia.

| Vista | Cosa mostra |
|---|---|
| **Panoramica** (§10.2) | Avvisi automatici sulle soglie di §5, KPI economici e finanziari, quattro grafici |
| **Conto Economico** (§10.3) | Prospetto a quattro colonne, selettore fra i tre schemi di §3, grafici |
| **Stato Patrimoniale** (§10.4) | Attivo, passivo, capitale circolante netto e tutti gli indici con le loro soglie |
| **Import dati** (§10.9) | Scelta del file, riepilogo pre-conferma, scrittura |

Le viste leggono **lo stesso payload di analisi**: un solo calcolo per periodo, tre modi di guardarlo.

**Conto economico a quattro colonne**: periodo, progressivo da inizio anno, budget e stesso periodo dell'anno precedente, ognuna con valore e % sui ricavi. Le colonne senza dati non compaiono — una colonna di trattini occupa spazio senza dire niente, una di zeri racconterebbe un'azienda a fatturato zero. `schemeLines()` è separata da `incomeStatement()` proprio per questo: le colonne di confronto hanno gli aggregati di altri periodi ma non i loro conti.

**Gli avvisi della Panoramica sono regole esplicite**, non un modello che indovina: confronti sulle soglie del foglio del consulente, e ognuno dice da quale numero arriva ("Margine operativo al 12,3% — positivo dal 15% in su"). È il primo tassello dell'idea di §11.8.

**Grafici** (Recharts): serie ricavi/costi/EBITDA, barre di EBITDA e utile, andamento della liquidità, composizione dei costi a ciambella, barra del break-even col margine di sicurezza. Le serie leggono l'endpoint `/series`, che calcola gli aggregati di ogni periodo caricato; sotto i due periodi il riquadro dice perché è vuoto invece di disegnare una linea piatta che sembrerebbe un dato. Donut e break-even bastano di un periodo solo.

Nella serie, "costi totali" sono i costi operativi **prima degli ammortamenti**: così `ricavi − costi = EBITDA` esattamente, e le tre linee si leggono senza doverci credere sulla parola.

**Due dettagli di formattazione che su un bilancio contano**: un indice indefinito si scrive "—", mai "0%"; e il raggruppamento delle migliaia resta sempre attivo anche a quattro cifre, perché in colonna "2000 €" sopra "1.050.000 €" sembra un errore di battitura.

**Migrazione 003 — correzione di un vincolo della 002.** L'indice univoco su `(company_uuid, sha256)` di `import_documents` nasceva da §5, che chiede di *riconoscere* le doppie importazioni: ma riconoscere non è vietare. Lo stesso identico file si importa legittimamente più volte — come budget e come consuntivo, o su due periodi quando si riusa un modello — e il vincolo lo impediva con un errore di database invece di una spiegazione. Il riconoscimento resta nell'anteprima, dove serve. `npm run verify:schema` contiene ora il controllo di regressione, e usa anni e codici che non possono scontrarsi con dati veri.

**Da fare in Fase 5**: Capitale Circolante e Tesoreria (§10.5-§10.6). Gli indici del circolante sono già calcolati dal motore; la previsione di cassa richiede scadenziario e previsioni manuali, che sono dati nuovi.

---

Expand Down
116 changes: 88 additions & 28 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,32 +16,103 @@ e risponde a "cosa succede se" senza toccare un foglio Excel.

---

## Il problema
## Il metodo di calcolo

Un consulente finanziario segue dieci, venti aziende contemporaneamente. Per ognuna
deve rispondere alle stesse domande: **quanto margina davvero?** **quanto tempo ha
prima di un problema di cassa?** **quanto valgono i soldi fermi in magazzino?**
**cosa cambia se assume una persona, o se chiede un altro finanziamento?**
Il motore di DaProdFinanza non è teoria da manuale: è l'estrazione di uno strumento
Excel che un consulente finanziario usa già oggi con i propri clienti. Vale la pena
conoscerlo, perché spiega come mai i numeri escono da soli.

Oggi quelle risposte arrivano da un foglio Excel costruito negli anni: bravissimo,
ma da rifare a mano per ogni cliente e per ogni mese. Un errore in una formula non
si vede finché non è troppo tardi, e i numeri vivono sul computer del consulente,
lontani dall'imprenditore che dovrebbe leggerli.
### 1. Ogni conto viene classificato una volta sola

Il piano dei conti di un'azienda è un elenco lungo e grezzo: *Vendite Italia*,
*Stipendi*, *Fondo ammortamento impianti*, *Fornitori materie prime*. Preso così non
racconta niente.

Il metodo assegna ogni conto a una **sezione** — ventiquattro in tutto, dai Ricavi
Operativi ai Debiti a Breve Termine. Da quella singola scelta discende tutto il resto:
dove il conto finisce nel bilancio riclassificato, se pesa sul margine o sulla
struttura, se entra nel calcolo dell'EBITDA o solo in quello dell'utile finale.

Classificare un conto è l'unico lavoro manuale. Tutto quello che segue è conseguenza.

### 2. I costi si dividono fra variabili e fissi, azienda per azienda

Un costo variabile cresce insieme alle vendite; uno fisso c'è comunque. La differenza
è tutto: decide il margine di contribuzione e il punto di pareggio.

Ogni sezione di costo parte con una percentuale suggerita — le materie prime sono
dirette al 100%, il personale al 70%, l'affitto allo 0% — ma **la percentuale si
cambia azienda per azienda**. Un tornitore e una pizzeria hanno strutture di costo
diverse, e il programma non finge il contrario.

### 3. Il bilancio si riclassifica in tre modi, che devono dare lo stesso utile

Riclassificare significa riordinare i conti grezzi in un prospetto che mostra dove
nascono i margini e dove si perdono. La dottrina italiana ne prevede tre modi:

- **a margine di contribuzione** — quanto resta dopo i costi che seguono le vendite
- **a valore aggiunto** — quanta ricchezza l'azienda crea prima di pagare le persone
- **a costo del venduto** — quanto costa davvero ciò che è stato venduto

Sono **tre letture dello stesso risultato**: cambiano i passaggi intermedi, l'utile
finale no. Il programma li calcola tutti e tre e li mostra affiancati — se non
coincidessero, ci sarebbe un errore da qualche parte, ed è esattamente così che il
motore viene collaudato.

### 4. Lo stato patrimoniale dice con quali soldi

Da una parte cosa possiede l'azienda: immobilizzazioni, magazzino, crediti, cassa.
Dall'altra con quali soldi lo ha pagato: capitale proprio, debiti a lungo termine,
debiti a breve. I due lati devono quadrare, e se non quadrano il programma lo dice
invece di far finta di niente.

### 5. Gli indici, con le soglie di chi li usa davvero

Dal bilancio riclassificato nascono gli indici: **ROE**, **ROI**, **ROS**, **MOL%**
per la redditività; indipendenza finanziaria e margini di struttura per la solidità;
indici di disponibilità e liquidità per la capacità di far fronte agli impegni.

Accanto a ognuno c'è la soglia che il consulente applica nel proprio foglio — *"ROS
positivo dal 10% in su"*, *"indipendenza finanziaria sopra il 30%"* — non un giudizio
inventato dal programma.

### 6. Il ciclo del circolante: dove la cassa si blocca

Quattro numeri raccontano perché un'azienda che guadagna può restare senza soldi:

| | |
|---|---|
| **DSO** | quanti giorni passano prima che i clienti paghino |
| **DIO** | quanti giorni la merce resta ferma in magazzino |
| **DPO** | quanti giorni l'azienda si prende per pagare i fornitori |
| **CCC** | i primi due meno il terzo: **i giorni in cui i soldi sono fuori** |

Se i clienti pagano a 90 giorni e i fornitori vanno pagati a 30, l'azienda finanzia i
propri clienti per due mesi — con i propri soldi, o con quelli della banca.

### 7. Il punto di pareggio

Quanti ricavi servono perché i conti tornino in pari, e quanto margine c'è fra i
ricavi di oggi e quella soglia. È la domanda che un imprenditore fa per prima.

---

Il metodo completo, formula per formula, è in
[`docs/MODELLO_FINANZIARIO.md`](./docs/MODELLO_FINANZIARIO.md): liberamente
consultabile. I file originali dei clienti da cui è stato estratto restano fuori
da qui.

## Cosa fa DaProdFinanza

Prende quel metodo e lo trasforma in un programma.

Il consulente carica il piano dei conti dell'azienda — lo stesso file Excel che usa
già — e il programma fa il resto: **riclassifica il bilancio**, cioè riordina i conti
grezzi in un prospetto leggibile che mostra dove nascono i margini e dove si perdono;
**calcola gli indici** che misurano redditività, solidità e liquidità; **prevede la
cassa** delle prossime settimane incrociando incassi attesi, pagamenti e rate dei
finanziamenti; e **simula gli scenari**, per vedere l'effetto di una decisione prima
di prenderla.
già — e il resto viene da sé: il bilancio riclassificato, gli indici con le loro
soglie, la previsione di cassa delle prossime settimane, e le simulazioni per vedere
l'effetto di una decisione prima di prenderla.

Tutto senza dipendere da internet, senza un abbonamento a un servizio esterno, e
senza che i numeri di un'azienda escano dal computer di chi ha il diritto di vederli.
Tutto senza dipendere da internet, senza un abbonamento a un servizio esterno, e senza
che i numeri di un'azienda escano dal computer di chi ha il diritto di vederli.

## Due programmi, due punti di vista

Expand Down Expand Up @@ -69,17 +140,6 @@ nessun dominio da comprare, nessun dato che passa da terzi per l'uso quotidiano.
- **Banche e Finanziamenti** — fidi, mutui e leasing, e quanto pesano sulla cassa futura
- **Analisi & Simulazioni** — "cosa succede se": assumo, investo, alzo i prezzi

## Come nasce

Il motore di calcolo non è teoria da manuale: è l'estrazione di uno strumento Excel
che un consulente finanziario usa già oggi con i propri clienti — piano dei conti
classificato, tre modi alternativi di riclassificare il bilancio, indici con le soglie
che lui stesso applica.

Quel metodo è stato letto riga per riga, generalizzato e reso anonimo, e vive in
[`docs/MODELLO_FINANZIARIO.md`](./docs/MODELLO_FINANZIARIO.md): formula per formula,
liberamente consultabile. I file originali dei clienti restano fuori da qui.

---

## A che punto siamo
Expand Down
50 changes: 36 additions & 14 deletions scripts/verify-schema.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,12 @@ app.whenReady().then(() => {
}

const now = new Date().toISOString()
// Anni e codici che non possono scontrarsi con dati veri: il controllo gira
// sul database dell'installazione, che di solito è già popolato.
const ANNO = 2999
const ANNO_PREC = 2998
const suffisso = randomUUID().slice(0, 8)
const codice = (n) => `ZZ-${suffisso}-${n}`
const accountUuid = randomUUID()
const yearUuid = randomUUID()
const monthUuid = randomUUID()
Expand All @@ -87,12 +93,12 @@ app.whenReady().then(() => {
.run(uuid, company.uuid, code, section, type, pct, now, now)

console.log(' Piano dei conti')
check('conto valido', () => insertAccount(accountUuid, 'TEST-001', 'costi_personale', 'COSTO', null), 'accettato')
check('sezione inesistente', () => insertAccount(randomUUID(), 'TEST-002', 'sezione_inventata', 'COSTO', null), 'rifiutato')
check("TIPO fuori dai cinque di §1", () => insertAccount(randomUUID(), 'TEST-003', 'costi_personale', 'SPESA', null), 'rifiutato')
check('% di costo diretto oltre 100', () => insertAccount(randomUUID(), 'TEST-004', 'costi_personale', 'COSTO', 120), 'rifiutato')
check('codice conto duplicato nella stessa azienda', () => insertAccount(randomUUID(), 'TEST-001', 'costi_personale', 'COSTO', null), 'rifiutato')
check('fondo ammortamento come ATTIVITA’ NEGATIVO', () => insertAccount(randomUUID(), 'TEST-005', 'immobilizzazioni_materiali', "ATTIVITA' NEGATIVO", null), 'accettato')
check('conto valido', () => insertAccount(accountUuid, codice(1), 'costi_personale', 'COSTO', null), 'accettato')
check('sezione inesistente', () => insertAccount(randomUUID(), codice(2), 'sezione_inventata', 'COSTO', null), 'rifiutato')
check("TIPO fuori dai cinque di §1", () => insertAccount(randomUUID(), codice(3), 'costi_personale', 'SPESA', null), 'rifiutato')
check('% di costo diretto oltre 100', () => insertAccount(randomUUID(), codice(4), 'costi_personale', 'COSTO', 120), 'rifiutato')
check('codice conto duplicato nella stessa azienda', () => insertAccount(randomUUID(), codice(1), 'costi_personale', 'COSTO', null), 'rifiutato')
check('fondo ammortamento come ATTIVITA’ NEGATIVO', () => insertAccount(randomUUID(), codice(5), 'immobilizzazioni_materiali', "ATTIVITA' NEGATIVO", null), 'accettato')

const insertPeriod = (uuid, type, year, month) =>
db
Expand All @@ -104,14 +110,14 @@ app.whenReady().then(() => {
.run(uuid, company.uuid, type, year, month, now, now)

console.log('\n Periodi contabili')
check('anno', () => insertPeriod(yearUuid, 'year', 2026, null), 'accettato')
check('mese', () => insertPeriod(monthUuid, 'month', 2026, 1), 'accettato')
check('anno con mese valorizzato', () => insertPeriod(randomUUID(), 'year', 2027, 3), 'rifiutato')
check('mese senza mese', () => insertPeriod(randomUUID(), 'month', 2027, null), 'rifiutato')
check('mese 13', () => insertPeriod(randomUUID(), 'month', 2027, 13), 'rifiutato')
check('anno duplicato', () => insertPeriod(randomUUID(), 'year', 2026, null), 'rifiutato')
check('mese duplicato', () => insertPeriod(randomUUID(), 'month', 2026, 1), 'rifiutato')
check('stesso mese di un altro anno', () => insertPeriod(randomUUID(), 'month', 2025, 1), 'accettato')
check('anno', () => insertPeriod(yearUuid, 'year', ANNO, null), 'accettato')
check('mese', () => insertPeriod(monthUuid, 'month', ANNO, 1), 'accettato')
check('anno con mese valorizzato', () => insertPeriod(randomUUID(), 'year', ANNO_PREC, 3), 'rifiutato')
check('mese senza mese', () => insertPeriod(randomUUID(), 'month', ANNO_PREC, null), 'rifiutato')
check('mese 13', () => insertPeriod(randomUUID(), 'month', ANNO_PREC, 13), 'rifiutato')
check('anno duplicato', () => insertPeriod(randomUUID(), 'year', ANNO, null), 'rifiutato')
check('mese duplicato', () => insertPeriod(randomUUID(), 'month', ANNO, 1), 'rifiutato')
check('stesso mese di un altro anno', () => insertPeriod(randomUUID(), 'month', ANNO_PREC, 1), 'accettato')

const insertBalance = (uuid, account, period, scenario, cents) =>
db
Expand All @@ -122,6 +128,22 @@ app.whenReady().then(() => {
)
.run(uuid, company.uuid, account, period, scenario, cents, now, now)

const insertDoc = (uuid, sha) =>
db
.prepare(
`INSERT INTO import_documents (uuid, company_uuid, kind, filename, sha256,
imported_at, created_at, updated_at, synced, deleted)
VALUES (?, ?, 'excel_chart_of_accounts', 'prova.xlsx', ?, ?, ?, ?, 0, 0)`
)
.run(uuid, company.uuid, sha, now, now, now)

console.log(''); console.log(' Documenti importati')
check('primo import di un file', () => insertDoc(randomUUID(), suffisso.padEnd(64, 'a')), 'accettato')
// Regressione: fino alla migrazione 003 un indice univoco impediva di
// importare lo stesso file due volte — cosa legittima, per esempio come
// budget e come consuntivo. Riconoscere un doppione non e' vietarlo.
check('stesso file importato di nuovo', () => insertDoc(randomUUID(), suffisso.padEnd(64, 'a')), 'accettato')

console.log('\n Saldi')
check('saldo a consuntivo', () => insertBalance(randomUUID(), accountUuid, monthUuid, 'actual', 1234567), 'accettato')
check('saldo negativo (le rettifiche esistono)', () => insertBalance(randomUUID(), accountUuid, yearUuid, 'actual', -50000), 'accettato')
Expand Down
25 changes: 25 additions & 0 deletions src/main/db/migrations/003_import_documents_no_unique_hash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Database } from 'better-sqlite3-multiple-ciphers'

/**
* Corregge un vincolo troppo rigido introdotto dalla 002.
*
* L'indice univoco su `(company_uuid, sha256)` nasceva da AGENTS.md §5, che
* chiede di **riconoscere** le doppie importazioni. Riconoscere però non è
* vietare: lo stesso identico file viene legittimamente importato più volte —
* come budget e come consuntivo, oppure su due periodi diversi quando il
* consulente riusa un modello. Il vincolo lo impediva, e l'import falliva con
* un errore di database invece di una spiegazione.
*
* Il riconoscimento resta dov'è utile: l'anteprima dice "questo identico file è
* già stato importato il …" e lascia decidere a chi guarda.
*/
export function up(db: Database): void {
db.exec(`
DROP INDEX IF EXISTS idx_import_documents_hash;

-- Non più univoco: serve a ritrovare velocemente gli import dello stesso
-- file, non a proibirli.
CREATE INDEX idx_import_documents_hash
ON import_documents(company_uuid, sha256);
`)
}
4 changes: 3 additions & 1 deletion src/main/db/migrations/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Database } from 'better-sqlite3-multiple-ciphers'
import { up as up001 } from './001_initial'
import { up as up002 } from './002_financial_model'
import { up as up003 } from './003_import_documents_no_unique_hash'

export interface Migration {
version: number
Expand All @@ -15,7 +16,8 @@ export interface Migration {
*/
export const MIGRATIONS: Migration[] = [
{ version: 1, name: '001_initial', up: up001 },
{ version: 2, name: '002_financial_model', up: up002 }
{ version: 2, name: '002_financial_model', up: up002 },
{ version: 3, name: '003_import_documents_no_unique_hash', up: up003 }
]

export function runMigrations(db: Database): number {
Expand Down
Loading