diff --git a/AGENTS.md b/AGENTS.md index 6d01122..a763c4b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. --- diff --git a/README.md b/README.md index 08f25a6..b887dc6 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/scripts/verify-schema.cjs b/scripts/verify-schema.cjs index c108a8d..cc8b3e7 100644 --- a/scripts/verify-schema.cjs +++ b/scripts/verify-schema.cjs @@ -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() @@ -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 @@ -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 @@ -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') diff --git a/src/main/db/migrations/003_import_documents_no_unique_hash.ts b/src/main/db/migrations/003_import_documents_no_unique_hash.ts new file mode 100644 index 0000000..2432225 --- /dev/null +++ b/src/main/db/migrations/003_import_documents_no_unique_hash.ts @@ -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); + `) +} diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 8dea020..b630ad5 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -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 @@ -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 { diff --git a/src/main/server/services/analysis.service.ts b/src/main/server/services/analysis.service.ts index 76aae20..d4e95fd 100644 --- a/src/main/server/services/analysis.service.ts +++ b/src/main/server/services/analysis.service.ts @@ -6,9 +6,10 @@ import { DEFAULT_SCHEME, SCHEMES, type EngineAccount, + type IncomeAggregates, type Scheme } from '@shared/engine' -import type { Analysis, SeriesPoint } from '@shared/analysis' +import type { Analysis, IncomeComparison, SeriesPoint } from '@shared/analysis' import type { FiscalPeriod, Scenario } from '@shared/types' import { getDatabase } from '../../db' import { HttpError } from '../http-error' @@ -130,6 +131,77 @@ export function analyse( balance, days: periodDays(period.year, period.month), debtServiceCents: options.debtServiceCents ?? null - }) + }), + comparison: comparison(companyUuid, period, scenario, statement.aggregates) + } +} + +/** + * Costruisce le colonne di confronto di §10.3. + * + * - **Periodo**: quello selezionato. + * - **YTD**: la somma dei mesi da gennaio fino a quello selezionato. Ha senso + * solo su un periodo mensile: su un anno coinciderebbe col periodo stesso. + * - **Budget**: lo stesso periodo, scenario budget. + * - **Anno precedente**: lo stesso mese dell'anno prima, a consuntivo. + * + * Una colonna senza dati resta `null` invece di tornare zeri: zero e "non c'è" + * su un bilancio sono due informazioni diverse. + */ +function aggregatesOf(companyUuid: string, periodUuid: string, scenario: Scenario) { + const accounts = engineAccounts(companyUuid, periodUuid, scenario) + return accounts.length === 0 ? null : incomeStatement(accounts, DEFAULT_SCHEME).aggregates +} + +function findPeriod( + periods: FiscalPeriod[], + year: number, + month: number | null +): FiscalPeriod | undefined { + return periods.find( + (p) => p.year === year && (p.month ?? null) === month && p.period_type === (month === null ? 'year' : 'month') + ) +} + +export function comparison( + companyUuid: string, + period: FiscalPeriod, + scenario: Scenario, + current: IncomeAggregates +): IncomeComparison { + const periods = listPeriods(companyUuid) + + // YTD: somma dei mesi da gennaio al mese selezionato, compreso. + let ytd: IncomeAggregates | null = null + if (period.month !== null) { + const mesi = periods.filter( + (p) => p.period_type === 'month' && p.year === period.year && (p.month ?? 0) <= period.month! + ) + const conti = mesi.flatMap((p) => engineAccounts(companyUuid, p.uuid, scenario)) + if (conti.length > 0) ytd = incomeStatement(conti, DEFAULT_SCHEME).aggregates + } + + const budgetPeriod = scenario === 'budget' ? null : period + const annoPrima = findPeriod(periods, period.year - 1, period.month) + + return { + columns: [ + { key: 'current', label: period.label, aggregates: current }, + { + key: 'ytd', + label: period.month === null ? 'Anno' : `YTD ${period.year}`, + aggregates: ytd + }, + { + key: 'budget', + label: `Budget ${period.year}`, + aggregates: budgetPeriod ? aggregatesOf(companyUuid, budgetPeriod.uuid, 'budget') : null + }, + { + key: 'previousYear', + label: annoPrima ? annoPrima.label : `${period.year - 1}`, + aggregates: annoPrima ? aggregatesOf(companyUuid, annoPrima.uuid, 'actual') : null + } + ] } } diff --git a/src/renderer/src/pages/business/IncomeStatementView.tsx b/src/renderer/src/pages/business/IncomeStatementView.tsx index 8a0a83a..3bda130 100644 --- a/src/renderer/src/pages/business/IncomeStatementView.tsx +++ b/src/renderer/src/pages/business/IncomeStatementView.tsx @@ -1,5 +1,6 @@ +import { Fragment } from 'react' import type { Analysis, SeriesPoint } from '@shared/analysis' -import { SCHEME_LABELS, SCHEMES, type Scheme } from '@shared/engine' +import { SCHEME_LABELS, SCHEMES, schemeLines, type Scheme } from '@shared/engine' import { euro, percent, share, tone } from '../../lib/format' import { Card, Select } from '../../components/ui' import { BarraBreakEven, CompositionePie, SerieEconomica } from '../../components/charts' @@ -59,6 +60,17 @@ export function IncomeStatementView({ { nome: 'Oneri finanziari', valore: a.oneriFinanziari } ].filter((fetta) => fetta.valore > 0) + // Le colonne di confronto di §10.3. Quelle senza dati si tolgono: una colonna + // di trattini occupa spazio senza dire niente. + const colonne = analysis.comparison.columns + .filter((colonna) => colonna.aggregates !== null) + .map((colonna) => ({ + ...colonna, + righe: new Map( + schemeLines(colonna.aggregates!, scheme).map((riga) => [riga.key, riga.amount_cents]) + ) + })) + return (
@@ -111,52 +123,89 @@ export function IncomeStatementView({ } > - - - - - - - - - - {analysis.incomeStatement.lines.map((line) => { - const forte = line.kind !== 'voce' - const risultato = line.kind === 'risultato' - return ( - - + + {colonne.map((colonna) => { + const valore = colonna.righe?.get(line.key) + const ricavi = colonna.aggregates?.ricaviNetti ?? 0 + return ( + + + + + ) + })} + + ) + })} + +
VoceValore% ricavi
+ + + + + {colonne.map((colonna) => ( + + + {colonne.map((colonna) => ( + + + + + ))} + + + + {analysis.incomeStatement.lines.map((line) => { + const forte = line.kind !== 'voce' + const risultato = line.kind === 'risultato' + return ( + - {euro(line.amount_cents)} - - - - ) - })} - -
+ Voce + - {line.sign} - {line.label} - - = 0 - ? 'font-semibold text-positive' - : 'font-semibold text-negative' - : forte - ? 'font-semibold text-ink-100' - : 'text-ink-300' - }`} + {colonna.label} + + ))} +
% ricavi
- {share(line.amount_cents, a.ricaviNetti)} -
+
+ {line.sign} + {line.label} + = 0 + ? 'font-semibold text-positive' + : 'font-semibold text-negative' + : forte + ? 'font-semibold text-ink-100' + : 'text-ink-300' + }`} + > + {valore === undefined ? '—' : euro(valore)} + + {valore === undefined ? '' : share(valore, ricavi)} +
+
+ + {colonne.length === 1 && ( +

+ Le colonne di confronto — progressivo da inizio anno, budget, anno precedente — + compaiono quando quei dati sono caricati. +

+ )}
diff --git a/src/shared/analysis.ts b/src/shared/analysis.ts index 7cb9b0a..4dcb71b 100644 --- a/src/shared/analysis.ts +++ b/src/shared/analysis.ts @@ -1,4 +1,4 @@ -import type { BalanceSheet, IncomeStatement, Ratios, Scheme } from './engine' +import type { BalanceSheet, IncomeAggregates, IncomeStatement, Ratios, Scheme } from './engine' import type { FiscalPeriod, Scenario } from './types' /** @@ -19,6 +19,8 @@ export interface Analysis { alternativeSchemes: IncomeStatement[] balanceSheet: BalanceSheet ratios: Ratios + /** Le colonne di confronto di §10.3. */ + comparison: IncomeComparison } /** Un punto della serie storica: tutti gli importi in centesimi. */ @@ -32,3 +34,24 @@ export interface SeriesPoint { utile: number liquidita: number } + +/** + * Le colonne di confronto del Conto Economico — AGENTS.md §10.3. + * + * Il prospetto del consulente affianca quattro letture dello stesso periodo: + * il mese, il progressivo da inizio anno, il budget e lo stesso periodo + * dell'anno prima. Una colonna che non ha dati resta `null` e la UI la nasconde: + * una colonna di zeri sembrerebbe un'azienda a fatturato zero. + */ +export type ComparisonKey = 'current' | 'ytd' | 'budget' | 'previousYear' + +export interface ComparisonColumn { + key: ComparisonKey + label: string + /** Aggregati del periodo, oppure null se per quella colonna non ci sono dati. */ + aggregates: IncomeAggregates | null +} + +export interface IncomeComparison { + columns: ComparisonColumn[] +} diff --git a/src/shared/engine/income-statement.ts b/src/shared/engine/income-statement.ts index 81165e6..85dfbf9 100644 --- a/src/shared/engine/income-statement.ts +++ b/src/shared/engine/income-statement.ts @@ -180,11 +180,14 @@ export interface IncomeStatement { aggregates: IncomeAggregates } -export function incomeStatement( - accounts: EngineAccount[], - scheme: Scheme = DEFAULT_SCHEME -): IncomeStatement { - const a = incomeAggregates(accounts) +/** + * Le righe di uno schema a partire dai soli aggregati. + * + * Separata da `incomeStatement` perché le colonne di confronto (§10.3) hanno + * gli aggregati di altri periodi ma non i loro conti: così si allineano riga + * per riga senza ricalcolare nulla. + */ +export function schemeLines(a: IncomeAggregates, scheme: Scheme = DEFAULT_SCHEME): IncomeLine[] { const head = line('ricavi_netti', 'Ricavi operativi + rimanenze finali', a.ricaviNetti, '+') let lines: IncomeLine[] @@ -231,5 +234,13 @@ export function incomeStatement( ] } - return { scheme, label: SCHEME_LABELS[scheme], lines, aggregates: a } + return lines +} + +export function incomeStatement( + accounts: EngineAccount[], + scheme: Scheme = DEFAULT_SCHEME +): IncomeStatement { + const a = incomeAggregates(accounts) + return { scheme, label: SCHEME_LABELS[scheme], lines: schemeLines(a, scheme), aggregates: a } }