diff --git a/AGENTS.md b/AGENTS.md index fb68534..85bfec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -266,8 +266,8 @@ La nota *"se i numeri sono questi cosa devo fare per crescere?"* suggerisce un l | **4** | UI Business: Panoramica + Conto Economico + Stato Patrimoniale (§10.2-10.4) | Le 3 schermate con dati reali importati | ✅ **Fatta** (sessione 1) — menu laterale e grafici compresi | | **5** | UI Capitale Circolante + Tesoreria/Cash Flow + Scadenziario (§10.5-10.6) | Previsione di cassa funzionante su dati reali | ✅ **Fatta** (sessione 2) | | **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 | ⬜ Prossima | -| **8** | Sync Consulente↔Azienda via Tailscale (§6) + status bar (§7) | Due installazioni reali che si scambiano dati | ⬜ | +| **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 | ⬜ | | **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 | | **11+** | Integrazioni Fase futura: connettore IRIS, Cassetto Fiscale, Open Banking, pianificazione fiscale, marginalità multi-dimensionale, assistente numeri | Una alla volta, dopo validazione col cliente | ⬜ | @@ -516,6 +516,39 @@ Verificato con i numeri da manuale: 12.000 € al 6% in 12 rate mensili danno la Corretta anche la grammatica delle note automatiche ("Magazzino in calo…: libera liquidità"). +**Fase 7 — Analisi & Simulazioni (§10.8)** + +| Pezzo | Dove | +|---|---| +| Motore dello scenario e dettaglio impatti | `src/shared/engine/simulation.ts` | +| Base (12 mesi, patrimonio, liquidità, finanziamenti), scenari, esportazione Excel | `src/main/server/services/simulation.service.ts` | +| Schermata | `src/renderer/src/pages/business/SimulationView.tsx` | +| Scenari salvati | migrazione `006_simulation_scenarios` | + +**Il calcolo gira nella schermata.** Il server prepara la base; lo scenario lo calcola lo stesso motore condiviso direttamente nel renderer, così ogni leva mossa aggiorna i risultati all'istante. L'esportazione Excel usa lo stesso motore sul server: schermata e file non possono divergere. + +**Uno scenario salvato è solo l'elenco delle variazioni**, in JSON. I risultati si ricalcolano sempre sulla base corrente: uno scenario di marzo riaperto a settembre ragiona sui numeri di settembre. Salvare è del Consulente; simulare ed esportare anche dell'operatore Azienda, perché non cambia nessun dato. + +**La base** sono i 12 mesi che terminano col periodo scelto (tutti obbligatori: altrimenti la schermata dice quali mancano) oppure un bilancio annuale. Un mese solo porterebbe dentro la sua stagionalità. + +**Il confronto è fra due proiezioni fatte allo stesso modo**: la situazione attuale è lo stesso calcolo con le leve a zero, sui 12 mesi da oggi. + +Il modello del consulente non tratta le simulazioni: le regole sono scelte dichiarate nel codice, da validare con lui. + +1. **I costi variabili seguono i ricavi** (materie prime e produzione); "prezzo merci" e "costi di produzione" sono variazioni di prezzo sopra l'effetto volume. Rimanenze ferme. +2. **"Altri costi fissi"** = commerciali + generali. Il personale ha la sua leva (dipendenti × costo annuo), gli ammortamenti seguono gli investimenti. +3. **Imposte** all'aliquota effettiva della base; 24% (IRES) se la base non ha utile. +4. **Circolante**: i crediti seguono i ricavi, magazzino e fornitori gli **acquisti**. Il magazzino non segue il costo del venduto perché nel modello questo comprende personale diretto e ammortamenti, e un forno nuovo farebbe crescere le scorte. Un obiettivo di giorni sposta la voce in proporzione. La variazione assorbe (o libera) cassa nei primi tre mesi. +5. **Cash flow annuo** = EBITDA − imposte − variazione del circolante − investimenti + nuovi finanziamenti − rate dei 12 mesi (esistenti e nuove). Straordinari esclusi. +6. **Investimento** pagato subito e ammortizzato a quote costanti; **finanziamento** incassato subito, francese, prima rata dopo un mese. +7. **PFN fra 12 mesi** = debiti finanziari di oggi − capitale rimborsato + nuovo debito residuo − liquidità finale. + +Otto indicatori a confronto (ricavi, margine lordo, EBITDA, utile, break-even, cash flow, liquidità e PFN fra 12 mesi), grafico a barre del conto economico, liquidità mese per mese con la soglia minima, dettaglio degli impatti con la nota che dice da dove arriva ogni differenza, riepilogo di investimento e finanziamento, **Esporta scenario** in Excel e **Vai alla Tesoreria**. + +**Verifiche**: 80 test (11 sulle simulazioni: +10% di ricavi calcolato a mano, dipendenti, investimento con lo scudo fiscale dell'ammortamento, finanziamento con la PFN che torna al centesimo, giorni obiettivo, leve fuori scala); `npm run verify:schema` a 51 controlli; nell'app: leve mosse, scenario salvato e riaperto, esportazione Excel riletta, base rifiutata con i mesi mancanti elencati. + +**Il menu non ha più voci "in arrivo"**: tutte le sette viste di §10 esistono. + --- ## 14. Punti aperti diff --git a/README.md b/README.md index a084657..47189b6 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ Un gestionale desktop per consulenti finanziari aziendali: riclassifica il bilan calcola gli indici che contano, previene le tensioni di cassa prima che arrivino, e risponde a "cosa succede se" senza toccare un foglio Excel. -![stato](https://img.shields.io/badge/stato-in%20sviluppo%20%C2%B7%20fase%206%2F10-3ddbff) +![stato](https://img.shields.io/badge/stato-in%20sviluppo%20%C2%B7%20fase%207%2F10-3ddbff) ![piattaforma](https://img.shields.io/badge/Windows-x64-3ddbff) [![licenza](https://img.shields.io/badge/licenza-MIT-5cff9d)](LICENSE) @@ -172,8 +172,11 @@ riclassificato con tutti gli indici, e lo mostra in cinque schermate: panoramica avvisi automatici, conto economico, stato patrimoniale, capitale circolante con le sue note automatiche, e tesoreria con scadenziario e previsione di cassa a sei mesi. C'è anche la parte bancaria: fidi, mutui e leasing con il loro piano di ammortamento, -le rate che entrano da sole nella previsione di cassa. Mancano le simulazioni e il -collegamento fra i due programmi. +le rate che entrano da sole nella previsione di cassa. E ci sono le **simulazioni**: +si muovono le leve (ricavi, costi, dipendenti, giorni di incasso, un investimento, un +finanziamento) e si vede subito l'effetto su utile, cassa e debito, scenario salvabile +ed esportabile in Excel. Tutte e sette le viste esistono; manca il collegamento fra i +due programmi. | | Fase | Stato | |---|---|---| @@ -184,8 +187,8 @@ collegamento fra i due programmi. | 4 | Le prime tre schermate di analisi | ✅ Fatta | | 5 | Capitale circolante, tesoreria, scadenziario | ✅ Fatta | | 6 | Banche e finanziamenti | ✅ Fatta | -| 7 | Analisi e simulazioni | ⬜ Prossima | -| 8-9 | Collegamento fra i due programmi | ⬜ | +| 7 | Analisi e simulazioni | ✅ Fatta | +| 8-9 | Collegamento fra i due programmi, import Excel più tollerante | ⬜ Prossima | | 10 | Eseguibili: installer, portable, demo | 🟡 Pubblicati a ogni aggiornamento importante; mancano le varianti Consulente/Azienda | La roadmap completa, con il dettaglio di cosa c'è dentro ogni fase, è in diff --git a/package-lock.json b/package-lock.json index 411fe11..cd82fdf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "daprodfinanza", - "version": "0.0.5", + "version": "0.0.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "daprodfinanza", - "version": "0.0.5", + "version": "0.0.6", "hasInstallScript": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0ccfc74..9472b16 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "daprodfinanza", - "version": "0.0.5", + "version": "0.0.6", "description": "Gestionale desktop di controllo di gestione per consulenti finanziari aziendali", "main": "./out/main/index.js", "author": "DaProdProduzioni", diff --git a/scripts/verify-schema.cjs b/scripts/verify-schema.cjs index 2875e0d..b00a2cb 100644 --- a/scripts/verify-schema.cjs +++ b/scripts/verify-schema.cjs @@ -269,6 +269,19 @@ app.whenReady().then(() => { check('periodicità inventata', () => insertLoan({ frequency: 'weekly' }), 'rifiutato') check('tasso negativo', () => insertLoan({ rate: -1 }), 'rifiutato') check('ammortamento inventato', () => insertLoan({ amortization: 'tedesco' }), 'rifiutato') + + console.log('\n Scenari di simulazione') + const insertScenario = (name, params) => + db + .prepare( + `INSERT INTO simulation_scenarios (uuid, company_uuid, name, params, + created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, ?, ?, ?, 0, 0)` + ) + .run(randomUUID(), tempCompany, name, params, now, now) + check('scenario', () => insertScenario('Espansione', '{"ricaviPercent":10}'), 'accettato') + check('stesso nome con le maiuscole diverse', () => insertScenario('ESPANSIONE', '{}'), 'rifiutato') + check('parametri che non sono JSON', () => insertScenario('Rotto', 'ricavi +10'), 'rifiutato') } finally { db.exec('ROLLBACK') db.close() diff --git a/src/main/db/migrations/006_simulation_scenarios.ts b/src/main/db/migrations/006_simulation_scenarios.ts new file mode 100644 index 0000000..d964ca9 --- /dev/null +++ b/src/main/db/migrations/006_simulation_scenarios.ts @@ -0,0 +1,29 @@ +import type { Database } from 'better-sqlite3-multiple-ciphers' + +/** + * Fase 7 — scenari di simulazione salvati (AGENTS.md §10.8). + * + * Uno scenario è solo un insieme di variazioni con un nome: i risultati non si + * memorizzano, si ricalcolano sempre dalla base corrente. Così uno scenario + * salvato a marzo, riaperto a settembre, ragiona sui numeri di settembre. + * I parametri stanno in JSON perché sono un blocco unico che cambia insieme + * alla schermata, non dati da interrogare. + */ +export function up(db: Database): void { + db.exec(` + CREATE TABLE simulation_scenarios ( + uuid TEXT PRIMARY KEY, + company_uuid TEXT NOT NULL REFERENCES companies(uuid), + name TEXT NOT NULL, + params TEXT NOT NULL CHECK (json_valid(params)), + notes TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + synced INTEGER NOT NULL DEFAULT 0 CHECK (synced IN (0, 1)), + deleted INTEGER NOT NULL DEFAULT 0 CHECK (deleted IN (0, 1)) + ); + + CREATE UNIQUE INDEX idx_simulation_scenarios_name + ON simulation_scenarios(company_uuid, name COLLATE NOCASE) WHERE deleted = 0; + `) +} diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 08ec07d..ae35431 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -4,6 +4,7 @@ import { up as up002 } from './002_financial_model' import { up as up003 } from './003_import_documents_no_unique_hash' import { up as up004 } from './004_treasury' import { up as up005 } from './005_banks' +import { up as up006 } from './006_simulation_scenarios' export interface Migration { version: number @@ -21,7 +22,8 @@ export const MIGRATIONS: Migration[] = [ { version: 2, name: '002_financial_model', up: up002 }, { version: 3, name: '003_import_documents_no_unique_hash', up: up003 }, { version: 4, name: '004_treasury', up: up004 }, - { version: 5, name: '005_banks', up: up005 } + { version: 5, name: '005_banks', up: up005 }, + { version: 6, name: '006_simulation_scenarios', up: up006 } ] export function runMigrations(db: Database): number { diff --git a/src/main/server/routes/companies.routes.ts b/src/main/server/routes/companies.routes.ts index dc21edd..fcca3b4 100644 --- a/src/main/server/routes/companies.routes.ts +++ b/src/main/server/routes/companies.routes.ts @@ -10,6 +10,7 @@ import { import { requireAuth, requireRole } from '../middleware/auth' import { analysisRouter } from './analysis.routes' import { banksRouter } from './banks.routes' +import { simulationRouter } from './simulation.routes' import { treasuryRouter } from './treasury.routes' /** Anagrafica Aziende — AGENTS.md §10.1. */ @@ -21,6 +22,7 @@ companiesRouter.use(requireAuth) companiesRouter.use('/:uuid', analysisRouter) companiesRouter.use('/:uuid', treasuryRouter) companiesRouter.use('/:uuid', banksRouter) +companiesRouter.use('/:uuid', simulationRouter) function uuidParam(req: Request): string { const value = req.params['uuid'] diff --git a/src/main/server/routes/simulation.routes.ts b/src/main/server/routes/simulation.routes.ts new file mode 100644 index 0000000..42ea63c --- /dev/null +++ b/src/main/server/routes/simulation.routes.ts @@ -0,0 +1,69 @@ +import { Router, type Request } from 'express' +import type { Scenario } from '@shared/types' +import { requireAuth, requireRole } from '../middleware/auth' +import { HttpError } from '../http-error' +import { + deleteScenario, + exportScenario, + listScenarios, + saveScenario, + simulationBase +} from '../services/simulation.service' + +/** + * Analisi & Simulazioni di un'azienda — AGENTS.md §10.8. + * + * Simulare non cambia nessun dato, quindi anche l'operatore Azienda può farlo + * ed esportare il risultato; salvare uno scenario resta al Consulente. + */ +export const simulationRouter: Router = Router({ mergeParams: true }) + +simulationRouter.use(requireAuth) + +function param(req: Request, name: string): string { + const value = req.params[name] + return Array.isArray(value) ? (value[0] ?? '') : (value ?? '') +} + +function assertCanRead(req: Request): string { + const companyUuid = param(req, 'uuid') + if (req.auth!.role === 'company' && req.auth!.company_uuid !== companyUuid) { + throw new HttpError(403, 'Operazione non consentita per questo ruolo.') + } + return companyUuid +} + +simulationRouter.get('/periods/:periodUuid/simulation-base', (req, res) => { + res.json( + simulationBase( + assertCanRead(req), + param(req, 'periodUuid'), + (req.query.scenario as Scenario) ?? 'actual' + ) + ) +}) + +simulationRouter.get('/simulations', (req, res) => { + res.json(listScenarios(assertCanRead(req))) +}) + +simulationRouter.post('/simulations/export', async (req, res) => { + const companyUuid = assertCanRead(req) + const { filePath, periodUuid, scenario, params, name } = req.body ?? {} + if (!filePath || !periodUuid) throw new HttpError(400, 'Mancano il file o il periodo di base.') + res.status(201).json( + await exportScenario(companyUuid, { filePath, periodUuid, scenario, params, name }) + ) +}) + +simulationRouter.post('/simulations', requireRole('consultant'), (req, res) => { + res.status(201).json(saveScenario(param(req, 'uuid'), req.body ?? {})) +}) + +simulationRouter.put('/simulations/:id', requireRole('consultant'), (req, res) => { + res.json(saveScenario(param(req, 'uuid'), req.body ?? {}, param(req, 'id'))) +}) + +simulationRouter.delete('/simulations/:id', requireRole('consultant'), (req, res) => { + res.json(deleteScenario(param(req, 'uuid'), param(req, 'id'))) +}) diff --git a/src/main/server/services/simulation.service.ts b/src/main/server/services/simulation.service.ts new file mode 100644 index 0000000..9e81523 --- /dev/null +++ b/src/main/server/services/simulation.service.ts @@ -0,0 +1,307 @@ +import ExcelJS from 'exceljs' +import { writeFile } from 'node:fs/promises' +import { + cashToday, + impactRows, + INCOME_SECTIONS, + PARAMETRI_ZERO, + simulate, + type EngineAccount, + type SimulationBase, + type SimulationParams +} from '@shared/engine' +import type { Scenario, SimulationScenario } from '@shared/types' +import { getDatabase } from '../../db' +import { newUuid, nowIso } from '../../lib/ids' +import { HttpError } from '../http-error' +import { engineAccounts, getPeriod, listPeriods } from './analysis.service' +import { listLoans } from './banks.service' +import { getCompany } from './companies.service' +import { listItems, openingCash, todayLocal } from './treasury.service' + +/** + * Analisi & Simulazioni — AGENTS.md §10.8. + * + * Il server prepara la base (12 mesi, stato patrimoniale, liquidità di oggi, + * finanziamenti); lo scenario lo calcola il motore condiviso, che gira anche + * nella schermata per rispondere subito a ogni variazione. + */ + +const MESI = [ + 'Gennaio', 'Febbraio', 'Marzo', 'Aprile', 'Maggio', 'Giugno', + 'Luglio', 'Agosto', 'Settembre', 'Ottobre', 'Novembre', 'Dicembre' +] + +/** Somma i saldi di più periodi, conto per conto. */ +function sommaConti(liste: EngineAccount[][]): EngineAccount[] { + const perCodice = new Map() + for (const lista of liste) { + for (const a of lista) { + const c = perCodice.get(a.code) + if (c) c.amount_cents += a.amount_cents + else perCodice.set(a.code, { ...a }) + } + } + return [...perCodice.values()] +} + +export function simulationBase( + companyUuid: string, + periodUuid: string, + scenario: Scenario = 'actual', + today = todayLocal() +): SimulationBase { + getCompany(companyUuid) + const period = getPeriod(companyUuid, periodUuid) + const tutti = engineAccounts(companyUuid, periodUuid, scenario) + if (tutti.length === 0) { + throw new HttpError(422, `${period.label} non ha saldi per lo scenario scelto.`) + } + const isIncome = (a: EngineAccount): boolean => INCOME_SECTIONS.includes(a.section_code) + + let incomeAccounts: EngineAccount[] + let label: string + if (period.month === null) { + incomeAccounts = tutti.filter(isIncome) + label = `Anno ${period.year}` + } else { + // Gli ultimi 12 mesi che terminano col periodo scelto: servono tutti. + const periodi = listPeriods(companyUuid) + const liste: EngineAccount[][] = [] + const mancanti: string[] = [] + for (let i = 11; i >= 0; i--) { + const indice = period.year * 12 + (period.month - 1) - i + const anno = Math.floor(indice / 12) + const mese = (indice % 12) + 1 + const p = periodi.find((x) => x.period_type === 'month' && x.year === anno && x.month === mese) + const conti = p ? engineAccounts(companyUuid, p.uuid, scenario).filter(isIncome) : [] + if (conti.length === 0) mancanti.push(`${MESI[mese - 1]} ${anno}`) + liste.push(conti) + } + if (mancanti.length > 0) { + throw new HttpError( + 422, + `La simulazione ragiona su un anno: servono i 12 mesi che terminano con ${period.label}, ` + + `ne mancano ${mancanti.length} (${mancanti.slice(0, 3).join(', ')}${mancanti.length > 3 ? '…' : ''}). ` + + 'Scegli un altro mese o un bilancio annuale.' + ) + } + incomeAccounts = sommaConti(liste) + label = `12 mesi fino a ${period.label}` + } + + return { + label, + incomeAccounts, + balanceAccounts: tutti.filter((a) => !isIncome(a)), + liquiditaOggi: cashToday(openingCash(companyUuid, today), listItems(companyUuid), today), + loans: listLoans(companyUuid), + today + } +} + +// --- scenari salvati ----------------------------------------------------------- + +/** Tiene solo i parametri noti, con i tipi giusti: il JSON arriva dal client. */ +export function cleanParams(input: unknown): SimulationParams { + const raw = (input ?? {}) as Record + const out = { ...PARAMETRI_ZERO } + for (const key of Object.keys(PARAMETRI_ZERO) as (keyof SimulationParams)[]) { + const v = raw[key] + const nullable = key === 'dsoTarget' || key === 'dioTarget' || key === 'dpoTarget' + if (nullable && (v === null || v === undefined || v === '')) { + ;(out as Record)[key] = null + } else if (typeof v === 'number' && Number.isFinite(v)) { + ;(out as Record)[key] = v + } else if (v !== undefined) { + throw new HttpError(400, `Parametro di simulazione non valido: ${key}.`) + } + } + return out +} + +export function listScenarios(companyUuid: string): SimulationScenario[] { + getCompany(companyUuid) + return getDatabase() + .prepare( + `SELECT * FROM simulation_scenarios WHERE company_uuid = ? AND deleted = 0 + ORDER BY name COLLATE NOCASE` + ) + .all(companyUuid) as SimulationScenario[] +} + +function getScenario(companyUuid: string, uuid: string): SimulationScenario { + const row = getDatabase() + .prepare('SELECT * FROM simulation_scenarios WHERE uuid = ? AND company_uuid = ? AND deleted = 0') + .get(uuid, companyUuid) as SimulationScenario | undefined + if (!row) throw new HttpError(404, 'Scenario non trovato.') + return row +} + +export function saveScenario( + companyUuid: string, + input: { name?: unknown; params?: unknown; notes?: unknown }, + uuid?: string +): SimulationScenario { + getCompany(companyUuid) + const current = uuid ? getScenario(companyUuid, uuid) : null + const name = typeof input.name === 'string' ? input.name.trim() : (current?.name ?? '') + if (!name) throw new HttpError(400, 'Dai un nome allo scenario.') + const params = JSON.stringify( + input.params === undefined && current ? JSON.parse(current.params) : cleanParams(input.params) + ) + const notes = typeof input.notes === 'string' ? input.notes.trim() || null : (current?.notes ?? null) + + const db = getDatabase() + const doppione = db + .prepare( + `SELECT uuid FROM simulation_scenarios + WHERE company_uuid = ? AND name = ? COLLATE NOCASE AND deleted = 0 AND uuid <> ?` + ) + .get(companyUuid, name, uuid ?? '') as { uuid: string } | undefined + if (doppione) throw new HttpError(409, `Esiste già uno scenario "${name}".`) + + const now = nowIso() + if (current) { + db.prepare( + `UPDATE simulation_scenarios SET name = ?, params = ?, notes = ?, updated_at = ?, synced = 0 + WHERE uuid = ?` + ).run(name, params, notes, now, current.uuid) + return getScenario(companyUuid, current.uuid) + } + const nuovo = newUuid() + db.prepare( + `INSERT INTO simulation_scenarios (uuid, company_uuid, name, params, notes, + created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0)` + ).run(nuovo, companyUuid, name, params, notes, now, now) + return getScenario(companyUuid, nuovo) +} + +export function deleteScenario(companyUuid: string, uuid: string): { uuid: string } { + getScenario(companyUuid, uuid) + getDatabase() + .prepare('UPDATE simulation_scenarios SET deleted = 1, updated_at = ?, synced = 0 WHERE uuid = ?') + .run(nowIso(), uuid) + return { uuid } +} + +// --- esportazione ---------------------------------------------------------------- + +/** + * "Esporta scenario": un foglio con le variazioni, il confronto e il dettaglio + * degli impatti, calcolato con lo stesso motore della schermata. + */ +export async function exportScenario( + companyUuid: string, + input: { periodUuid: string; scenario?: Scenario; params: unknown; name?: string; filePath: string } +): Promise<{ path: string }> { + const company = getCompany(companyUuid) + const params = cleanParams(input.params) + const base = simulationBase(companyUuid, input.periodUuid, input.scenario ?? 'actual') + const attuale = simulate(base, PARAMETRI_ZERO) + const simulato = simulate(base, params) + + const wb = new ExcelJS.Workbook() + wb.creator = 'DaProdFinanza' + const ws = wb.addWorksheet('Scenario') + ws.columns = [{ width: 42 }, { width: 18 }, { width: 18 }, { width: 18 }, { width: 48 }] + const euro = '#,##0 "€";[Red]-#,##0 "€"' + + const titolo = ws.addRow([`${company.name} — ${input.name?.trim() || 'Scenario'}`]) + titolo.font = { bold: true, size: 14 } + ws.addRow([`Base: ${base.label} · proiezione a 12 mesi da ${base.today.split('-').reverse().join('/')}`]) + ws.addRow([]) + + const intestazione = (testo: string): void => { + const r = ws.addRow([testo]) + r.font = { bold: true } + r.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFDBEAFE' } } + } + + intestazione('Variazioni') + const variazioni: [string, string | number][] = [ + ['Ricavi', `${params.ricaviPercent}%`], + ['Prezzo merci', `${params.costoMercePercent}%`], + ['Costi di produzione', `${params.costiVariabiliPercent}%`], + ['Dipendenti in più', params.nuoviDipendenti], + ['Costo annuo per dipendente', params.costoDipendenteCents / 100], + ['Altri costi fissi', `${params.altriCostiFissiPercent}%`], + ['DSO obiettivo (giorni)', params.dsoTarget ?? 'invariato'], + ['DIO obiettivo (giorni)', params.dioTarget ?? 'invariato'], + ['DPO obiettivo (giorni)', params.dpoTarget ?? 'invariato'], + ['Nuovo investimento', params.investimentoCents / 100], + ['Anni di ammortamento', params.investimentoAnni], + ['Nuovo finanziamento', params.finanziamentoCents / 100], + ['Durata (mesi)', params.finanziamentoMesi], + ['Tasso annuo', `${params.finanziamentoTasso}%`], + ['Preammortamento (mesi)', params.finanziamentoPreammortamentoMesi] + ] + for (const [k, v] of variazioni) { + const r = ws.addRow([k, v]) + if (typeof v === 'number' && /investimento|finanziamento|Costo annuo/i.test(k)) r.getCell(2).numFmt = euro + } + ws.addRow([]) + + intestazione('Confronto') + const h = ws.addRow(['Indicatore', 'Attuale', 'Scenario', 'Differenza']) + h.font = { bold: true } + const confronto: [string, number | null, number | null][] = [ + ['Ricavi', attuale.ricavi, simulato.ricavi], + ['EBITDA / MOL', attuale.ebitda, simulato.ebitda], + ['Utile netto', attuale.utile, simulato.utile], + ['Break-even', attuale.breakEven, simulato.breakEven], + ['Cash flow annuo', attuale.cashFlowAnnuo, simulato.cashFlowAnnuo], + ['Liquidità fra 12 mesi', attuale.liquiditaFinale, simulato.liquiditaFinale], + ['Posizione finanziaria netta fra 12 mesi', attuale.posizioneFinanziariaNetta, simulato.posizioneFinanziariaNetta] + ] + for (const [k, a, s] of confronto) { + const r = ws.addRow([ + k, + a === null ? null : a / 100, + s === null ? null : s / 100, + a === null || s === null ? null : (s - a) / 100 + ]) + for (const c of [2, 3, 4]) r.getCell(c).numFmt = euro + } + const margine = ws.addRow([ + 'Margine lordo %', + attuale.margineLordoPercent === null ? null : attuale.margineLordoPercent / 100, + simulato.margineLordoPercent === null ? null : simulato.margineLordoPercent / 100 + ]) + margine.getCell(2).numFmt = '0.0%' + margine.getCell(3).numFmt = '0.0%' + ws.addRow([]) + + intestazione('Dettaglio impatti') + const h2 = ws.addRow(['Voce', 'Attuale', 'Scenario', 'Differenza', 'Nota']) + h2.font = { bold: true } + for (const riga of impactRows(attuale, simulato, params)) { + const r = ws.addRow([ + riga.label, + riga.attuale / 100, + riga.simulato / 100, + (riga.simulato - riga.attuale) / 100, + riga.nota + ]) + for (const c of [2, 3, 4]) r.getCell(c).numFmt = euro + } + ws.addRow([]) + + intestazione('Liquidità mese per mese') + const h3 = ws.addRow(['Mese', 'Attuale', 'Scenario']) + h3.font = { bold: true } + attuale.curva.forEach((v, i) => { + const r = ws.addRow([i === 0 ? 'Oggi' : `+${i} ${i === 1 ? 'mese' : 'mesi'}`, v / 100, simulato.curva[i]! / 100]) + r.getCell(2).numFmt = euro + r.getCell(3).numFmt = euro + }) + + ws.addRow([]) + ws.addRow([ + 'Simulazione indicativa: i costi variabili seguono i ricavi, le imposte usano l\'aliquota effettiva della base, il cash flow è un rendiconto semplificato.' + ]).font = { italic: true, color: { argb: 'FF64748B' } } + + await writeFile(input.filePath, Buffer.from(await wb.xlsx.writeBuffer())) + return { path: input.filePath } +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 2335db9..9667c48 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ export type Vista = | 'capitale-circolante' | 'tesoreria' | 'banche' + | 'simulazioni' | 'import' interface Voce { @@ -68,6 +69,11 @@ const VISTE: Voce[] = [ label: 'Banche e Finanziamenti', icona: icona('M3 10h18M5 10v8M9.5 10v8M14.5 10v8M19 10v8M3 20h18M12 3l9 5H3z') }, + { + id: 'simulazioni', + label: 'Analisi & Simulazioni', + icona: icona('M4 18l5-6 4 3 7-9M15 6h5v5') + }, { id: 'import', label: 'Import dati', @@ -76,7 +82,6 @@ const VISTE: Voce[] = [ ] const IN_ARRIVO: { label: string; fase: string }[] = [ - { label: 'Analisi & Simulazioni', fase: 'Fase 7' } ] function Bottone({ @@ -160,11 +165,13 @@ export function Sidebar({ /> ))} -
-

- In arrivo -

-
+ {IN_ARRIVO.length > 0 && ( +
+

+ In arrivo +

+
+ )} {IN_ARRIVO.map((voce) => ( ) } + +export interface VoceConfronto { + nome: string + attuale: number + simulato: number +} + +/** Conto economico attuale e simulato, affiancati — §10.8. */ +export function ConfrontoBarre({ dati }: { dati: VoceConfronto[] }): React.JSX.Element { + return ( + + + + + + } cursor={{ fill: '#1d2636', opacity: 0.4 }} /> + + + + + + + ) +} + +export interface PuntoCassaSimulata { + label: string + attuale: number + simulato: number +} + +/** Liquidità mese per mese, attuale e simulata, con la soglia minima — §10.8. */ +export function ConfrontoCassa({ + dati, + soglia +}: { + dati: PuntoCassaSimulata[] + soglia: number | null +}): React.JSX.Element { + return ( + + + + + + } /> + + {soglia !== null && ( + + )} + + + + + ) +} diff --git a/src/renderer/src/pages/CompanyPage.tsx b/src/renderer/src/pages/CompanyPage.tsx index f639c1f..2d8bedc 100644 --- a/src/renderer/src/pages/CompanyPage.tsx +++ b/src/renderer/src/pages/CompanyPage.tsx @@ -6,13 +6,14 @@ import type { TreasuryView as TreasuryPayload, WorkingCapitalView as WorkingCapitalPayload } from '@shared/analysis' -import { DEFAULT_SCHEME, type Scheme } from '@shared/engine' -import type { Company, FiscalPeriod, Scenario } from '@shared/types' +import { DEFAULT_SCHEME, type Scheme, type SimulationBase } from '@shared/engine' +import type { Company, FiscalPeriod, Scenario, SimulationScenario } from '@shared/types' import { api } from '../lib/api' import type { Vista } from '../components/Sidebar' import { Alert, Button, Card, EmptyState, Select } from '../components/ui' import { BalanceSheetView } from './business/BalanceSheetView' import { BanksView } from './business/BanksView' +import { SimulationView } from './business/SimulationView' import { ImportPanel, TemplateButton } from './business/ImportPanel' import { IncomeStatementView } from './business/IncomeStatementView' import { OverviewView } from './business/OverviewView' @@ -53,6 +54,12 @@ export function CompanyPage({ const [tesoreria, setTesoreria] = useState(null) const [circolante, setCircolante] = useState(null) const [banche, setBanche] = useState(null) + const [simBase, setSimBase] = useState<{ + key: string + base: SimulationBase | null + error: string | null + } | null>(null) + const [scenari, setScenari] = useState([]) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) @@ -119,7 +126,8 @@ export function CompanyPage({ }, [company.uuid]) useEffect(() => { - if (vista === 'tesoreria' || vista === 'panoramica') caricaTesoreria() + // Anche le simulazioni la usano, per la soglia minima di liquidità. + if (vista === 'tesoreria' || vista === 'panoramica' || vista === 'simulazioni') caricaTesoreria() }, [vista, caricaTesoreria]) const caricaBanche = useCallback(async () => { @@ -134,6 +142,41 @@ export function CompanyPage({ if (vista === 'banche') caricaBanche() }, [vista, caricaBanche]) + const caricaScenari = useCallback(async () => { + try { + setScenari(await api.get(`/api/companies/${company.uuid}/simulations`)) + } catch { + setScenari([]) + } + }, [company.uuid]) + + // La base della simulazione dipende dal periodo e dallo scenario scelti. + useEffect(() => { + if (vista !== 'simulazioni' || !periodUuid) return + const key = `${periodUuid}|${scenario}` + let annullato = false + caricaScenari() + api + .get( + `/api/companies/${company.uuid}/periods/${periodUuid}/simulation-base?scenario=${scenario}` + ) + .then((base) => { + if (!annullato) setSimBase({ key, base, error: null }) + }) + .catch((err) => { + if (!annullato) { + setSimBase({ + key, + base: null, + error: err instanceof Error ? err.message : 'Base della simulazione non disponibile.' + }) + } + }) + return () => { + annullato = true + } + }, [company.uuid, vista, periodUuid, scenario, caricaScenari]) + useEffect(() => { if (vista !== 'capitale-circolante' || !periodUuid) return let annullato = false @@ -285,6 +328,27 @@ export function CompanyPage({ scheme={scheme} onScheme={setScheme} /> + ) : vista === 'simulazioni' ? ( + !simBase || simBase.key !== `${periodUuid}|${scenario}` ? ( +

Caricamento…

+ ) : simBase.base ? ( + onVista('tesoreria')} + /> + ) : ( + + + + ) ) : vista === 'capitale-circolante' ? ( circolante && circolante.period.uuid === periodUuid ? ( diff --git a/src/renderer/src/pages/business/SimulationView.tsx b/src/renderer/src/pages/business/SimulationView.tsx new file mode 100644 index 0000000..ef20e4d --- /dev/null +++ b/src/renderer/src/pages/business/SimulationView.tsx @@ -0,0 +1,662 @@ +import { useMemo, useState } from 'react' +import { + impactRows, + PARAMETRI_ZERO, + simulate, + type SimulationBase, + type SimulationParams, + type SimulationResult +} from '@shared/engine' +import type { SimulationScenario } from '@shared/types' +import { api } from '../../lib/api' +import { days, euro, euroInput, parseEuro, percent } from '../../lib/format' +import { Alert, Button, Card, Select, TextInput } from '../../components/ui' +import { ConfrontoBarre, ConfrontoCassa } from '../../components/charts' + +/** + * Analisi & Simulazioni — AGENTS.md §10.8. + * + * Lo scenario si ricalcola nella schermata, con lo stesso motore del server: + * ogni leva mossa cambia i risultati subito. Si salva solo l'elenco delle + * variazioni, mai i risultati, che dipendono dai numeri del giorno. + */ + +const MESI_BREVI = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'] + +function parametriDa(scenario: SimulationScenario): SimulationParams { + try { + return { ...PARAMETRI_ZERO, ...(JSON.parse(scenario.params) as Partial) } + } catch { + return PARAMETRI_ZERO + } +} + +/** Un numero con la virgola, o null se il campo è vuoto o non è un numero. */ +function numero(testo: string): number | null { + const t = testo.trim().replace(',', '.') + if (!t) return null + const n = Number(t) + return Number.isFinite(n) ? n : null +} + +function Leva({ + label, + valore, + min, + max, + step = 1, + unita, + onChange +}: { + label: string + valore: number + min: number + max: number + step?: number + unita: string + onChange: (v: number) => void +}): React.JSX.Element { + const [testo, setTesto] = useState(null) + const mostrato = testo ?? String(valore).replace('.', ',') + return ( +
+
+ {label} + + { + setTesto(e.target.value) + const n = numero(e.target.value) + if (n !== null) onChange(n) + }} + onBlur={() => setTesto(null)} + inputMode="decimal" + className={`w-16 rounded-md border border-ink-700 bg-ink-900 px-2 py-1 text-right text-sm tabular-nums outline-none focus:border-brand-500 ${ + valore > 0 ? 'text-positive' : valore < 0 ? 'text-negative' : 'text-ink-100' + }`} + /> + {unita} + +
+ { + setTesto(null) + onChange(Number(e.target.value)) + }} + className="mt-1.5 w-full accent-brand-500" + /> +
+ ) +} + +function CampoEuro({ + label, + valore, + onChange +}: { + label: string + valore: number + onChange: (cents: number) => void +}): React.JSX.Element { + const [testo, setTesto] = useState(null) + return ( + + ) +} + +function CampoNumero({ + label, + valore, + unita, + placeholder, + onChange +}: { + label: string + valore: number | null + unita: string + placeholder?: string + onChange: (v: number | null) => void +}): React.JSX.Element { + const [testo, setTesto] = useState(null) + return ( + + ) +} + +function Gruppo({ titolo, children }: { titolo: string; children: React.ReactNode }): React.JSX.Element { + return ( +
+

{titolo}

+ {children} +
+ ) +} + +type Verso = 'su' | 'giu' | null + +function CardConfronto({ + label, + attuale, + simulato, + formato, + buono, + minimo = 100, + formatoDelta = formato +}: { + label: string + attuale: number | null + simulato: number | null + formato: (v: number | null) => string + /** Da che parte è un miglioramento. */ + buono: Verso + /** Sotto questa differenza non si mostra la variazione (1 € di default). */ + minimo?: number + /** Come scrivere la differenza, se diverso dal valore (punti percentuali). */ + formatoDelta?: (v: number) => string +}): React.JSX.Element { + const delta = attuale === null || simulato === null ? null : simulato - attuale + const piccolo = delta === null || Math.abs(delta) < minimo + const tono = + buono === null || piccolo + ? 'text-ink-300' + : (delta! > 0) === (buono === 'su') + ? 'text-positive' + : 'text-negative' + return ( +
+

{label}

+

{formato(simulato)}

+

+ attuale {formato(attuale)} + {!piccolo && ( + + {delta! > 0 ? '▲' : '▼'} {formatoDelta(Math.abs(delta!))} + + )} +

+
+ ) +} + +const CONFRONTI: { + label: string + valore: (r: SimulationResult) => number | null + buono: Verso + percento?: boolean +}[] = [ + { label: 'Ricavi', valore: (r) => r.ricavi, buono: 'su' }, + { label: 'Margine lordo', valore: (r) => r.margineLordoPercent, buono: 'su', percento: true }, + { label: 'EBITDA / MOL', valore: (r) => r.ebitda, buono: 'su' }, + { label: 'Utile netto', valore: (r) => r.utile, buono: 'su' }, + { label: 'Break-even', valore: (r) => r.breakEven, buono: 'giu' }, + { label: 'Cash flow annuo', valore: (r) => r.cashFlowAnnuo, buono: 'su' }, + { label: 'Liquidità fra 12 mesi', valore: (r) => r.liquiditaFinale, buono: 'su' }, + { label: 'Posizione finanziaria netta', valore: (r) => r.posizioneFinanziariaNetta, buono: 'giu' } +] + +export function SimulationView({ + base, + companyUuid, + periodUuid, + scenario, + scenari, + soglia, + canEdit, + onScenariChanged, + onVaiTesoreria +}: { + base: SimulationBase + companyUuid: string + periodUuid: string + scenario: string + scenari: SimulationScenario[] + soglia: number | null + canEdit: boolean + onScenariChanged: () => void + onVaiTesoreria: () => void +}): React.JSX.Element { + const [params, setParams] = useState(PARAMETRI_ZERO) + const [scelto, setScelto] = useState('') + const [nome, setNome] = useState('') + const [messaggio, setMessaggio] = useState<{ ok: boolean; testo: string } | null>(null) + const [busy, setBusy] = useState(false) + + const set = (k: K, v: SimulationParams[K]): void => + setParams((p) => ({ ...p, [k]: v })) + + const attuale = useMemo(() => simulate(base, PARAMETRI_ZERO), [base]) + const simulato = useMemo(() => simulate(base, params), [base, params]) + const righe = useMemo(() => impactRows(attuale, simulato, params), [attuale, simulato, params]) + + const oggi = new Date(`${base.today}T00:00:00`) + const cassa = attuale.curva.map((v, i) => { + const d = new Date(oggi.getFullYear(), oggi.getMonth() + i, 1) + return { + label: i === 0 ? 'Oggi' : `${MESI_BREVI[d.getMonth()]} '${String(d.getFullYear()).slice(-2)}`, + attuale: v, + simulato: simulato.curva[i]! + } + }) + + const fissi = (r: typeof attuale): number => + r.aggregates.costiPersonale + r.aggregates.costiCommerciali + r.aggregates.costiGenerali + const barre = [ + { nome: 'Ricavi', attuale: attuale.ricavi, simulato: simulato.ricavi }, + { + nome: 'Variabili', + attuale: attuale.aggregates.costiVariabili, + simulato: simulato.aggregates.costiVariabili + }, + { nome: 'Fissi', attuale: fissi(attuale), simulato: fissi(simulato) }, + { nome: 'EBITDA', attuale: attuale.ebitda, simulato: simulato.ebitda }, + { nome: 'Utile', attuale: attuale.utile, simulato: simulato.utile } + ] + + const toccato = JSON.stringify(params) !== JSON.stringify(PARAMETRI_ZERO) + const corrente = scenari.find((s) => s.uuid === scelto) ?? null + + const scegli = (uuid: string): void => { + setScelto(uuid) + setMessaggio(null) + const s = scenari.find((x) => x.uuid === uuid) + if (s) { + setParams(parametriDa(s)) + setNome(s.name) + } else { + setParams(PARAMETRI_ZERO) + setNome('') + } + } + + const salva = async (): Promise => { + if (!nome.trim()) { + setMessaggio({ ok: false, testo: 'Dai un nome allo scenario prima di salvarlo.' }) + return + } + setBusy(true) + try { + const body = { name: nome, params } + const s = corrente + ? await api.put( + `/api/companies/${companyUuid}/simulations/${corrente.uuid}`, + body + ) + : await api.post(`/api/companies/${companyUuid}/simulations`, body) + setScelto(s.uuid) + setMessaggio({ ok: true, testo: `Scenario "${s.name}" salvato.` }) + onScenariChanged() + } catch (err) { + setMessaggio({ ok: false, testo: err instanceof Error ? err.message : 'Salvataggio non riuscito.' }) + } finally { + setBusy(false) + } + } + + const elimina = async (): Promise => { + if (!corrente || !confirm(`Eliminare lo scenario "${corrente.name}"?`)) return + try { + await api.delete(`/api/companies/${companyUuid}/simulations/${corrente.uuid}`) + scegli('') + onScenariChanged() + } catch (err) { + setMessaggio({ ok: false, testo: err instanceof Error ? err.message : 'Eliminazione non riuscita.' }) + } + } + + const esporta = async (): Promise => { + const titolo = nome.trim() || 'Scenario' + const path = await window.daprod.saveExcelFile(`Simulazione - ${titolo.replace(/[\\/:*?"<>|]/g, '')}.xlsx`) + if (!path) return + setBusy(true) + try { + const r = await api.post<{ path: string }>(`/api/companies/${companyUuid}/simulations/export`, { + filePath: path, + periodUuid, + scenario, + params, + name: titolo + }) + await window.daprod.openExcelFile(r.path) + setMessaggio({ ok: true, testo: 'Scenario esportato in Excel.' }) + } catch (err) { + setMessaggio({ ok: false, testo: err instanceof Error ? err.message : 'Esportazione non riuscita.' }) + } finally { + setBusy(false) + } + } + + const eur = (v: number | null): string => euro(v) + const pct = (v: number | null): string => percent(v) + const nf = simulato.nuovoFinanziamento + + return ( +
+ {/* --- leve ------------------------------------------------------------ */} +
+ +
+ + {canEdit && ( + <> + setNome(e.target.value)} + placeholder="Nome, es. Apertura seconda sede" + /> +
+ + {corrente && ( + + )} +
+ + )} + {messaggio && ( +

+ {messaggio.testo} +

+ )} +
+
+ + setParams(PARAMETRI_ZERO)} + className="text-xs text-brand-300 hover:text-brand-200 disabled:text-ink-600" + > + Ripristina valori attuali + + } + > + + set('ricaviPercent', v)} + /> + set('costoMercePercent', v)} + /> + set('costiVariabiliPercent', v)} + /> + set('nuoviDipendenti', Math.round(v))} + /> + {params.nuoviDipendenti !== 0 && ( + set('costoDipendenteCents', v)} + /> + )} + set('altriCostiFissiPercent', v)} + /> + + + + set('dsoTarget', v)} + /> + set('dioTarget', v)} + /> + set('dpoTarget', v)} + /> + + + + set('investimentoCents', v)} + /> + {params.investimentoCents > 0 && ( + set('investimentoAnni', v ?? 1)} + /> + )} + set('finanziamentoCents', v)} + /> + {params.finanziamentoCents > 0 && ( + <> + set('finanziamentoMesi', v ?? 1)} + /> + set('finanziamentoTasso', v ?? 0)} + /> + set('finanziamentoPreammortamentoMesi', v ?? 0)} + /> + + )} + + +
+ + {/* --- risultati ------------------------------------------------------- */} +
+
+

+ Base: {base.label} · proiezione sui 12 mesi da oggi +

+
+ + +
+
+ +
+ {CONFRONTI.map((c) => ( + `${v.toFixed(1).replace('.', ',')} punti` : undefined + } + /> + ))} +
+ + {simulato.liquiditaFinale < 0 || simulato.curva.some((v) => v < (soglia ?? 0)) ? ( + + Nello scenario la liquidità scende sotto{' '} + {soglia ? `la soglia minima di ${euro(soglia)}` : 'zero'} durante l'anno. + + ) : null} + +
+ +
+ +
+
+ +
+ +
+
+
+ + + + + + + + + + + + + + {righe.map((r) => { + const d = r.simulato - r.attuale + const risultato = r.key === 'ebitda' || r.key === 'utile' || r.key === 'cashflow' + return ( + + + + + + + + ) + })} + +
VoceAttualeScenarioDifferenzaDa dove arriva
{r.label}{euro(r.attuale)}{euro(r.simulato)} + {d === 0 ? '—' : `${d > 0 ? '+' : ''}${euro(d)}`} + {r.nota}
+
+ + {(params.investimentoCents > 0 || nf) && ( + +
+ {[ + { l: 'Rata mensile', v: nf ? euro(nf.rata, true) : '—' }, + { l: 'Interessi totali', v: nf ? euro(nf.interessiTotali) : '—' }, + { l: 'Interessi nei 12 mesi', v: nf ? euro(nf.interessi12Mesi) : '—' }, + { l: 'Ammortamento annuo', v: simulato.ammortamentoInvestimento ? euro(simulato.ammortamentoInvestimento) : '—' } + ].map((x) => ( +
+

{x.l}

+

{x.v}

+
+ ))} +
+
+ )} + +

+ Simulazione indicativa. I costi variabili seguono i ricavi; crediti, magazzino e fornitori seguono i + volumi; le imposte usano l'aliquota effettiva della base ({percent(simulato.aliquotaImposte * 100)}); il + cash flow è EBITDA meno imposte, variazione del circolante, investimenti e rate, più i nuovi + finanziamenti. +

+
+
+ ) +} diff --git a/src/shared/engine/index.ts b/src/shared/engine/index.ts index 2b03f57..0d8ab8d 100644 --- a/src/shared/engine/index.ts +++ b/src/shared/engine/index.ts @@ -5,3 +5,4 @@ export * from './ratios' export * from './working-capital' export * from './treasury' export * from './loans' +export * from './simulation' diff --git a/src/shared/engine/simulation.test.ts b/src/shared/engine/simulation.test.ts new file mode 100644 index 0000000..344c0cf --- /dev/null +++ b/src/shared/engine/simulation.test.ts @@ -0,0 +1,213 @@ +import { describe, expect, it } from 'vitest' +import { DETAIL, SECTION, type EngineAccount } from './aggregates' +import { schedule, type LoanInput } from './loans' +import { impactRows, PARAMETRI_ZERO, simulate, type SimulationBase } from './simulation' + +/** + * Simulazioni su un'azienda di prova con numeri tondi: un anno da 1.000.000 € + * di ricavi, 400.000 di materie prime, 100.000 di produzione, 250.000 di + * personale, 100.000 di altri costi fissi. EBITDA 150.000. + */ + +const e = (euro: number): number => Math.round(euro * 100) + +function conto( + code: string, + section: string, + euro: number, + extra: Partial = {} +): EngineAccount { + return { + code, + name: code, + section_code: section, + account_type: 'COSTO', + detail_tag: null, + amount_cents: e(euro), + ...extra + } +} + +const INCOME: EngineAccount[] = [ + conto('ric', SECTION.ricaviOperativi, 1_000_000, { account_type: 'RICAVO' }), + conto('mp', SECTION.costiMateriePrime, 400_000), + conto('prod', SECTION.costiProduzione, 100_000), + conto('pers', SECTION.costiPersonale, 250_000), + conto('comm', SECTION.costiCommerciali, 40_000), + conto('gen', SECTION.costiGenerali, 60_000), + conto('amm', SECTION.ammortamentiOperativi, 30_000), + conto('int', SECTION.oneriFinanziari, 20_000), + // Utile ante imposte 100.000, imposte 25.000: aliquota effettiva 25%. + conto('tax', SECTION.imposte, 25_000) +] + +const ATTIVO = { account_type: "ATTIVITA'" as const } +const PASSIVO = { account_type: "PASSIVITA'" as const } + +const BALANCE: EngineAccount[] = [ + conto('imm', SECTION.immobilizzazioniMateriali, 300_000, ATTIVO), + conto('mag', SECTION.rimanenzeFinaliMagazzino, 50_000, ATTIVO), + // DSO = 100.000 / (1.000.000 / 365) = 36,5 giorni + conto('cli', SECTION.liquiditaDifferite, 100_000, { + ...ATTIVO, + detail_tag: DETAIL.creditiCommerciali + }), + conto('cassa', SECTION.liquiditaImmediate, 80_000, ATTIVO), + conto('pn', SECTION.patrimonioNetto, 250_000, PASSIVO), + conto('mutuo', SECTION.debitiMedioLungo, 200_000, PASSIVO), + conto('forn', SECTION.debitiBreve, 80_000, { + ...PASSIVO, + detail_tag: DETAIL.debitiFornitoriVariabili + }) +] + +const MUTUO: LoanInput = { + uuid: 'm', + kind: 'mutuo', + label: 'Mutuo', + principal_cents: e(200_000), + annual_rate_percent: 4, + first_due_date: '2026-10-31', + installments: 120, + frequency: 'monthly', + grace_installments: 0, + amortization: 'francese', + balloon_cents: 0 +} + +const BASE: SimulationBase = { + label: 'Anno di prova', + incomeAccounts: INCOME, + balanceAccounts: BALANCE, + liquiditaOggi: e(80_000), + loans: [MUTUO], + today: '2026-10-15' +} + +const rataMutuo = schedule(MUTUO)[0]!.total + +describe('simulazione', () => { + const attuale = simulate(BASE, PARAMETRI_ZERO) + + it('senza variazioni riproduce la base', () => { + expect(attuale.ricavi).toBe(e(1_000_000)) + expect(attuale.ebitda).toBe(e(150_000)) + expect(attuale.utile).toBe(e(75_000)) + expect(attuale.aliquotaImposte).toBeCloseTo(0.25) + expect(attuale.variazioneCircolante).toBe(0) + // Dodici rate del mutuo, dalla prima del 31 ottobre. + expect(attuale.servizioDebito).toBe(12 * rataMutuo) + expect(attuale.cashFlowAnnuo).toBe(e(150_000) - e(25_000) - 12 * rataMutuo) + expect(attuale.liquiditaFinale).toBe(e(80_000) + attuale.cashFlowAnnuo) + }) + + it('la curva mensile arriva alla liquidità finale', () => { + expect(attuale.curva).toHaveLength(13) + expect(attuale.curva[0]).toBe(e(80_000)) + expect(Math.abs(attuale.curva[12]! - attuale.liquiditaFinale)).toBeLessThanOrEqual(1) + }) + + it('+10% di ricavi: i costi variabili seguono, i fissi no', () => { + const s = simulate(BASE, { ...PARAMETRI_ZERO, ricaviPercent: 10 }) + expect(s.aggregates.costiMateriePrime).toBe(e(440_000)) + expect(s.aggregates.costiProduzione).toBe(e(110_000)) + expect(s.aggregates.costiPersonale).toBe(e(250_000)) + // +100.000 di ricavi, +50.000 di variabili. + expect(s.ebitda).toBe(e(200_000)) + // Imposte al 25% sul nuovo utile ante imposte (150.000). + expect(s.aggregates.imposte).toBe(e(37_500)) + // A giorni invariati crediti, magazzino e fornitori crescono del 10%. + expect(s.variazioneCircolante).toBe(e(10_000) + e(5_000) - e(8_000)) + }) + + it("il prezzo delle merci si somma all'effetto volume", () => { + const s = simulate(BASE, { ...PARAMETRI_ZERO, ricaviPercent: 10, costoMercePercent: 5 }) + expect(s.aggregates.costiMateriePrime).toBe(e(462_000)) + }) + + it("due dipendenti in più pesano sul personale e sull'EBITDA", () => { + const s = simulate(BASE, { + ...PARAMETRI_ZERO, + nuoviDipendenti: 2, + costoDipendenteCents: e(35_000) + }) + expect(s.aggregates.costiPersonale).toBe(e(320_000)) + expect(s.ebitda).toBe(e(80_000)) + }) + + it('gli altri costi fissi sono commerciali e generali', () => { + const s = simulate(BASE, { ...PARAMETRI_ZERO, altriCostiFissiPercent: -10 }) + expect(s.aggregates.costiCommerciali + s.aggregates.costiGenerali).toBe(e(90_000)) + }) + + it('un investimento esce subito dalla cassa e si ammortizza a quote costanti', () => { + const s = simulate(BASE, { + ...PARAMETRI_ZERO, + investimentoCents: e(50_000), + investimentoAnni: 5 + }) + expect(s.ammortamentoInvestimento).toBe(e(10_000)) + expect(s.aggregates.ammortamenti).toBe(e(40_000)) + expect(s.ebitda).toBe(e(150_000)) + expect(s.curva[0]).toBe(e(80_000)) + // Meno l'investimento, più le minori imposte sull'ammortamento (25% di 10.000). + expect(s.cashFlowAnnuo).toBe(attuale.cashFlowAnnuo - e(50_000) + e(2_500)) + }) + + it('un finanziamento entra in cassa, porta interessi e rate', () => { + const s = simulate(BASE, { + ...PARAMETRI_ZERO, + finanziamentoCents: e(60_000), + finanziamentoMesi: 60, + finanziamentoTasso: 6 + }) + const piano = schedule({ + ...MUTUO, + uuid: 'n', + principal_cents: e(60_000), + annual_rate_percent: 6, + installments: 60, + first_due_date: '2026-11-15' + }) + const primi12 = piano.slice(0, 12) + const interessi12 = primi12.reduce((t, r) => t + r.interest, 0) + const rate12 = primi12.reduce((t, r) => t + r.total, 0) + const capitale12 = primi12.reduce((t, r) => t + r.capital, 0) + + expect(s.nuovoFinanziamento?.rata).toBe(piano[0]!.total) + expect(s.aggregates.oneriFinanziari).toBe(e(20_000) + interessi12) + expect(s.servizioDebito).toBe(12 * rataMutuo + rate12) + // Il debito cresce del residuo del nuovo finanziamento, la liquidità del + // cash flow in più: la PFN si muove della differenza. + const deltaPfn = s.posizioneFinanziariaNetta - attuale.posizioneFinanziariaNetta + const deltaCassa = s.liquiditaFinale - attuale.liquiditaFinale + expect(deltaPfn).toBe(e(60_000) - capitale12 - deltaCassa) + }) + + it('i giorni obiettivo spostano il circolante', () => { + // DSO da 36,5 a 30 giorni: i crediti scendono. + const s = simulate(BASE, { ...PARAMETRI_ZERO, dsoTarget: 30 }) + expect(s.variazioneCircolante).toBe(Math.round((30 * e(1_000_000)) / 365) - e(100_000)) + expect(s.cashFlowAnnuo).toBeGreaterThan(attuale.cashFlowAnnuo) + }) + + it('le note del dettaglio impatti spiegano la differenza', () => { + const p = { ...PARAMETRI_ZERO, ricaviPercent: 10, nuoviDipendenti: 1 } + const righe = impactRows(attuale, simulate(BASE, p), p) + expect(righe.find((r) => r.key === 'ricavi')?.nota).toBe('Ricavi +10%') + expect(righe.find((r) => r.key === 'personale')?.nota).toBe('+1 dipendente') + expect(righe.find((r) => r.key === 'variabili')?.nota).toBe('Seguono i volumi') + }) + + it('un cursore fuori scala non rompe il calcolo', () => { + expect(() => + simulate(BASE, { + ...PARAMETRI_ZERO, + finanziamentoCents: e(10_000), + finanziamentoTasso: 99, + finanziamentoMesi: 5000, + finanziamentoPreammortamentoMesi: 9999 + }) + ).not.toThrow() + }) +}) diff --git a/src/shared/engine/simulation.ts b/src/shared/engine/simulation.ts new file mode 100644 index 0000000..299fc20 --- /dev/null +++ b/src/shared/engine/simulation.ts @@ -0,0 +1,490 @@ +import { SECTION, type EngineAccount } from './aggregates' +import { balanceSheet } from './balance-sheet' +import { incomeAggregates, type IncomeAggregates } from './income-statement' +import { schedule, type LoanInput } from './loans' +import { ratios } from './ratios' +import { addMonths, parseDate } from './treasury' + +/** + * Analisi & Simulazioni — "cosa succede se…" (AGENTS.md §10.8). + * + * È puro calcolo derivato: nessun dato nuovo, solo variazioni applicate ai + * conti di una base e ripassate negli stessi motori del conto economico, + * dello stato patrimoniale e degli indici. Così lo scenario è coerente per + * costruzione con il resto del programma. + * + * **La base** sono gli ultimi 12 mesi (o un bilancio annuale): una simulazione + * ragiona su un anno, e un mese solo porterebbe dentro la sua stagionalità. + * + * **La proiezione** guarda ai 12 mesi da oggi, ed è la stessa funzione per la + * situazione attuale (variazioni a zero) e per lo scenario: il confronto è fra + * due proiezioni fatte allo stesso modo, non fra un consuntivo e una stima. + * + * Scelte dichiarate, da validare col consulente (il modello non tratta le + * simulazioni): + * + * 1. **I costi variabili seguono i ricavi.** Un +10% di ricavi porta un +10% di + * materie prime e costi di produzione; le variazioni di "costo merci" e + * "costi variabili" sono di prezzo, e si sommano all'effetto volume. + * Rimanenze iniziali e finali restano ferme. + * 2. **"Altri costi fissi"** sono i costi commerciali e generali: il personale + * ha la sua leva (dipendenti in più o in meno) e gli ammortamenti seguono + * gli investimenti. + * 3. **Le imposte** applicano all'utile simulato l'aliquota effettiva della + * base; se la base non ha utile, il 24% dell'IRES. + * 4. **Il capitale circolante** segue i volumi: crediti con i ricavi, magazzino e + * fornitori con gli acquisti. Un obiettivo di giorni sposta la voce in + * proporzione (DSO da 60 a 45 → crediti −25%). La variazione assorbe (o + * libera) cassa nei primi tre mesi. + * 5. **Il cash flow annuo** è un rendiconto semplificato: EBITDA − imposte − + * variazione del circolante − investimenti + nuovi finanziamenti − rate dei + * 12 mesi (esistenti e nuove). Proventi e oneri straordinari restano fuori: + * non si ripetono. + * 6. **Un nuovo investimento** si paga subito e si ammortizza a quote costanti; + * **un nuovo finanziamento** si incassa subito, prima rata dopo un mese, + * ammortamento francese. + */ + +export interface SimulationParams { + /** Variazione % dei ricavi operativi. */ + ricaviPercent: number + /** Variazione % di prezzo delle materie prime, oltre all'effetto volume. */ + costoMercePercent: number + /** Variazione % di prezzo dei costi di produzione, oltre all'effetto volume. */ + costiVariabiliPercent: number + /** Dipendenti in più (positivo) o in meno (negativo). */ + nuoviDipendenti: number + /** Costo aziendale annuo di un dipendente, in centesimi. */ + costoDipendenteCents: number + /** Variazione % dei costi commerciali e generali. */ + altriCostiFissiPercent: number + /** Giorni obiettivo; null = invariati. */ + dsoTarget: number | null + dioTarget: number | null + dpoTarget: number | null + investimentoCents: number + /** Anni di ammortamento dell'investimento. */ + investimentoAnni: number + finanziamentoCents: number + finanziamentoMesi: number + finanziamentoTasso: number + finanziamentoPreammortamentoMesi: number +} + +export const PARAMETRI_ZERO: SimulationParams = { + ricaviPercent: 0, + costoMercePercent: 0, + costiVariabiliPercent: 0, + nuoviDipendenti: 0, + costoDipendenteCents: 3_500_000, + altriCostiFissiPercent: 0, + dsoTarget: null, + dioTarget: null, + dpoTarget: null, + investimentoCents: 0, + investimentoAnni: 5, + finanziamentoCents: 0, + finanziamentoMesi: 60, + finanziamentoTasso: 5, + finanziamentoPreammortamentoMesi: 0 +} + +export interface SimulationBase { + /** Etichetta della base: "12 mesi a Agosto 2026", "Anno 2025". */ + label: string + /** Conti economici della base, già su 12 mesi. */ + incomeAccounts: EngineAccount[] + /** Conti patrimoniali alla fine della base. */ + balanceAccounts: EngineAccount[] + /** Liquidità di oggi, dalla tesoreria. */ + liquiditaOggi: number + /** Finanziamenti in essere. */ + loans: LoanInput[] + today: string +} + +export interface SimulationResult { + aggregates: IncomeAggregates + ricavi: number + margineLordoPercent: number | null + ebitda: number + utile: number + breakEven: number | null + dso: number | null + dio: number | null + dpo: number | null + /** Variazione del capitale circolante rispetto alla base (+ = assorbe cassa). */ + variazioneCircolante: number + /** Rate dei 12 mesi: esistenti + nuovo finanziamento. */ + servizioDebito: number + cashFlowAnnuo: number + liquiditaFinale: number + posizioneFinanziariaNetta: number + /** Liquidità mese per mese: indice 0 = oggi, 12 = fra un anno. */ + curva: number[] + nuovoFinanziamento: { + rata: number + interessiTotali: number + interessi12Mesi: number + capitale12Mesi: number + } | null + ammortamentoInvestimento: number + aliquotaImposte: number +} + +const INCIDENZA_IRES = 0.24 + +const scala = (cents: number, fattore: number): number => Math.round(cents * fattore) + +/** + * Una voce del circolante nello scenario: segue i volumi del flusso a cui è + * legata e, se c'è un obiettivo, si sposta in proporzione ai giorni. Senza un + * valore di partenza per i giorni, l'obiettivo si applica al flusso giornaliero. + */ +function riscala( + valore: number, + giorniBase: number | null, + giorniObiettivo: number | null, + flussoBase: number, + flussoSim: number, + opzioni: { flussoGiorni?: number } = {} +): number { + const volume = flussoBase > 0 ? flussoSim / flussoBase : 1 + if (giorniObiettivo === null) return Math.round(valore * volume) + if (giorniBase !== null && giorniBase > 0) { + return Math.round(valore * (giorniObiettivo / giorniBase) * volume) + } + return Math.round((giorniObiettivo * (opzioni.flussoGiorni ?? flussoSim)) / 365) +} + +function nuovoPrestito(p: SimulationParams, today: string): LoanInput | null { + if (p.finanziamentoCents <= 0 || p.finanziamentoMesi < 1) return null + const rate = Math.min(600, Math.round(p.finanziamentoMesi)) + const grace = Math.min(Math.max(0, Math.round(p.finanziamentoPreammortamentoMesi)), rate - 1) + return { + uuid: 'simulazione', + kind: 'finanziamento', + label: 'Nuovo finanziamento', + principal_cents: Math.round(p.finanziamentoCents), + // Limiti del motore dei finanziamenti: un cursore non deve rompere il calcolo. + annual_rate_percent: Math.min(50, Math.max(0, p.finanziamentoTasso)), + first_due_date: addMonths(today, 1), + installments: rate, + frequency: 'monthly', + grace_installments: grace, + amortization: 'francese', + balloon_cents: 0 + } +} + +/** Le rate di un piano nei 12 mesi da oggi, divise per mese (1..12). */ +function rateMensili(plan: ReturnType, today: string) { + const inizio = parseDate(today) + const mesi = Array.from({ length: 13 }, () => ({ totale: 0, capitale: 0, interessi: 0 })) + for (let m = 1; m <= 12; m++) { + const da = m === 1 ? inizio : parseDate(addMonths(today, m - 1)) + const a = parseDate(addMonths(today, m)) + for (const r of plan) { + const t = parseDate(r.date) + if (t > da && t <= a) { + mesi[m]!.totale += r.total + mesi[m]!.capitale += r.capital + mesi[m]!.interessi += r.interest + } + } + } + return mesi +} + +export function simulate(base: SimulationBase, p: SimulationParams): SimulationResult { + const baseIncome = incomeAggregates(base.incomeAccounts) + const fattoreRicavi = 1 + p.ricaviPercent / 100 + const prestito = nuovoPrestito(p, base.today) + const pianoNuovo = prestito ? schedule(prestito) : [] + const mesiNuovo = rateMensili(pianoNuovo, base.today) + const interessiNuovo12 = mesiNuovo.reduce((s, m) => s + m.interessi, 0) + const ammortamentoInvestimento = + p.investimentoCents > 0 && p.investimentoAnni > 0 + ? Math.round(p.investimentoCents / p.investimentoAnni) + : 0 + + // --- conto economico simulato, conto per conto ------------------------------- + const conti: EngineAccount[] = base.incomeAccounts.map((a) => { + switch (a.section_code) { + case SECTION.ricaviOperativi: + return { ...a, amount_cents: scala(a.amount_cents, fattoreRicavi) } + case SECTION.costiMateriePrime: + return { + ...a, + amount_cents: scala(a.amount_cents, fattoreRicavi * (1 + p.costoMercePercent / 100)) + } + case SECTION.costiProduzione: + return { + ...a, + amount_cents: scala(a.amount_cents, fattoreRicavi * (1 + p.costiVariabiliPercent / 100)) + } + case SECTION.costiCommerciali: + case SECTION.costiGenerali: + return { ...a, amount_cents: scala(a.amount_cents, 1 + p.altriCostiFissiPercent / 100) } + default: + return a + } + }) + const extra = (section: string, cents: number, name: string): void => { + if (cents !== 0) { + conti.push({ + code: `sim-${section}`, + name, + section_code: section, + account_type: 'COSTO', + detail_tag: null, + amount_cents: cents + }) + } + } + extra( + SECTION.costiPersonale, + Math.round(p.nuoviDipendenti * p.costoDipendenteCents), + 'Variazione organico' + ) + extra(SECTION.ammortamentiOperativi, ammortamentoInvestimento, 'Ammortamento nuovo investimento') + extra(SECTION.oneriFinanziari, interessiNuovo12, 'Interessi nuovo finanziamento') + + // Imposte ricalcolate sull'utile simulato con l'aliquota della base. + const senzaImposte = conti.filter((a) => a.section_code !== SECTION.imposte) + const ante = incomeAggregates(senzaImposte).utileAnteImposte + const aliquota = + baseIncome.utileAnteImposte > 0 ? baseIncome.imposte / baseIncome.utileAnteImposte : INCIDENZA_IRES + const imposte = Math.max(0, Math.round(ante * aliquota)) + const contiFinali = [ + ...senzaImposte, + { + code: 'sim-imposte', + name: 'Imposte', + section_code: SECTION.imposte, + account_type: 'COSTO' as const, + detail_tag: null, + amount_cents: imposte + } + ] + const income = incomeAggregates(contiFinali) + + // --- capitale circolante ------------------------------------------------------ + const balance = balanceSheet(base.balanceAccounts) + const baseRatios = ratios({ accounts: base.balanceAccounts, income: baseIncome, balance, days: 365 }) + const simRatios = ratios({ accounts: base.balanceAccounts, income, balance, days: 365 }) + const acquistiBase = baseIncome.costiMateriePrime + baseIncome.costiProduzione + const acquistiSim = income.costiMateriePrime + income.costiProduzione + + const dso = p.dsoTarget ?? baseRatios.dso + const dio = p.dioTarget ?? baseRatios.dio + const dpo = p.dpoTarget ?? baseRatios.dpo + const crediti = riscala( + balance.creditiCommerciali, + baseRatios.dso, + p.dsoTarget, + baseIncome.ricaviOperativi, + income.ricaviOperativi + ) + // Il magazzino segue gli acquisti, non il costo del venduto: nel modello + // quest'ultimo comprende personale diretto e ammortamenti, e un forno nuovo + // non fa crescere le scorte. + const magazzino = riscala(balance.magazzino, baseRatios.dio, p.dioTarget, acquistiBase, acquistiSim, { + flussoGiorni: income.costoDelVenduto + }) + const fornitori = riscala( + balance.debitiFornitori, + baseRatios.dpo, + p.dpoTarget, + acquistiBase, + acquistiSim + ) + const variazioneCircolante = + crediti - balance.creditiCommerciali + (magazzino - balance.magazzino) - (fornitori - balance.debitiFornitori) + + // --- cassa ---------------------------------------------------------------------- + const mesiEsistenti = Array.from({ length: 13 }, () => ({ totale: 0, capitale: 0, interessi: 0 })) + for (const loan of base.loans) { + rateMensili(schedule(loan), base.today).forEach((m, i) => { + mesiEsistenti[i]!.totale += m.totale + mesiEsistenti[i]!.capitale += m.capitale + mesiEsistenti[i]!.interessi += m.interessi + }) + } + const rateEsistenti = mesiEsistenti.reduce((s, m) => s + m.totale, 0) + const capitaleEsistente = mesiEsistenti.reduce((s, m) => s + m.capitale, 0) + const rateNuove = mesiNuovo.reduce((s, m) => s + m.totale, 0) + const capitaleNuovo = mesiNuovo.reduce((s, m) => s + m.capitale, 0) + + const gestione = income.ebitda - income.imposte + const cashFlowAnnuo = + gestione - + variazioneCircolante - + p.investimentoCents + + (prestito ? prestito.principal_cents : 0) - + rateEsistenti - + rateNuove + + const curva: number[] = [base.liquiditaOggi] + let saldo = base.liquiditaOggi - p.investimentoCents + (prestito ? prestito.principal_cents : 0) + for (let m = 1; m <= 12; m++) { + saldo += gestione / 12 + if (m <= 3) saldo -= variazioneCircolante / 3 + saldo -= mesiEsistenti[m]!.totale + mesiNuovo[m]!.totale + curva.push(Math.round(saldo)) + } + const liquiditaFinale = base.liquiditaOggi + cashFlowAnnuo + + // PFN fra 12 mesi: debiti finanziari di oggi, meno il capitale rimborsato, + // più il nuovo debito residuo, meno la liquidità finale. + const debitiFinanziari = baseRatios.posizioneFinanziariaNetta + balance.liquiditaImmediate + const posizioneFinanziariaNetta = + debitiFinanziari - + capitaleEsistente + + (prestito ? prestito.principal_cents - capitaleNuovo : 0) - + liquiditaFinale + + const primaRataPiena = pianoNuovo.find((r) => !r.grace) ?? pianoNuovo[0] + + return { + aggregates: income, + ricavi: income.ricaviNetti, + margineLordoPercent: income.ricaviNetti ? (income.grossProfit / income.ricaviNetti) * 100 : null, + ebitda: income.ebitda, + utile: income.utile, + breakEven: simRatios.bepCents, + dso, + dio, + dpo, + variazioneCircolante, + servizioDebito: rateEsistenti + rateNuove, + cashFlowAnnuo, + liquiditaFinale, + posizioneFinanziariaNetta, + curva, + nuovoFinanziamento: prestito + ? { + rata: primaRataPiena?.total ?? 0, + interessiTotali: pianoNuovo.reduce((s, r) => s + r.interest, 0), + interessi12Mesi: interessiNuovo12, + capitale12Mesi: capitaleNuovo + } + : null, + ammortamentoInvestimento, + aliquotaImposte: aliquota + } +} + +export interface ImpactRow { + key: string + label: string + attuale: number + simulato: number + nota: string +} + +/** Le righe della tabella "dettaglio impatti", con la nota che spiega da dove arriva la differenza. */ +export function impactRows( + attuale: SimulationResult, + simulato: SimulationResult, + p: SimulationParams +): ImpactRow[] { + const a = attuale.aggregates + const s = simulato.aggregates + const pct = (v: number): string => + `${v > 0 ? '+' : ''}${v.toLocaleString('it-IT', { maximumFractionDigits: 1 })}%` + const note = (parti: (string | false)[]): string => parti.filter(Boolean).join(' · ') + + return [ + { + key: 'ricavi', + label: 'Ricavi', + attuale: a.ricaviNetti, + simulato: s.ricaviNetti, + nota: note([p.ricaviPercent !== 0 && `Ricavi ${pct(p.ricaviPercent)}`]) + }, + { + key: 'variabili', + label: 'Costi variabili', + attuale: a.costiVariabili, + simulato: s.costiVariabili, + nota: note([ + p.ricaviPercent !== 0 && 'Seguono i volumi', + p.costoMercePercent !== 0 && `Prezzo merci ${pct(p.costoMercePercent)}`, + p.costiVariabiliPercent !== 0 && `Costi di produzione ${pct(p.costiVariabiliPercent)}` + ]) + }, + { + key: 'personale', + label: 'Personale', + attuale: a.costiPersonale, + simulato: s.costiPersonale, + nota: note([ + p.nuoviDipendenti !== 0 && + `${p.nuoviDipendenti > 0 ? '+' : ''}${p.nuoviDipendenti} dipendent${Math.abs(p.nuoviDipendenti) === 1 ? 'e' : 'i'}` + ]) + }, + { + key: 'fissi', + label: 'Altri costi fissi', + attuale: a.costiCommerciali + a.costiGenerali, + simulato: s.costiCommerciali + s.costiGenerali, + nota: note([ + p.altriCostiFissiPercent !== 0 && + `Costi commerciali e generali ${pct(p.altriCostiFissiPercent)}` + ]) + }, + { key: 'ebitda', label: 'EBITDA / MOL', attuale: a.ebitda, simulato: s.ebitda, nota: '' }, + { + key: 'ammortamenti', + label: 'Ammortamenti', + attuale: a.ammortamenti, + simulato: s.ammortamenti, + nota: note([simulato.ammortamentoInvestimento > 0 && 'Quota del nuovo investimento']) + }, + { + key: 'oneri', + label: 'Oneri finanziari', + attuale: a.oneriFinanziari, + simulato: s.oneriFinanziari, + nota: note([simulato.nuovoFinanziamento !== null && 'Interessi del nuovo finanziamento nei 12 mesi']) + }, + { + key: 'imposte', + label: 'Imposte', + attuale: a.imposte, + simulato: s.imposte, + nota: `Aliquota effettiva ${pct(Math.round(simulato.aliquotaImposte * 1000) / 10).replace('+', '')}` + }, + { key: 'utile', label: 'Utile netto', attuale: a.utile, simulato: s.utile, nota: '' }, + { + key: 'circolante', + label: 'Assorbimento di cassa del circolante', + attuale: attuale.variazioneCircolante, + simulato: simulato.variazioneCircolante, + nota: note([ + p.dsoTarget !== null && `DSO a ${p.dsoTarget} gg`, + p.dioTarget !== null && `DIO a ${p.dioTarget} gg`, + p.dpoTarget !== null && `DPO a ${p.dpoTarget} gg`, + p.ricaviPercent !== 0 && 'Crediti, magazzino e fornitori seguono i volumi' + ]) + }, + { + key: 'debito', + label: 'Rate dei finanziamenti (12 mesi)', + attuale: attuale.servizioDebito, + simulato: simulato.servizioDebito, + nota: note([simulato.nuovoFinanziamento !== null && 'Comprese le rate del nuovo finanziamento']) + }, + { + key: 'cashflow', + label: 'Cash flow annuo', + attuale: attuale.cashFlowAnnuo, + simulato: simulato.cashFlowAnnuo, + nota: note([ + p.investimentoCents > 0 && 'Investimento pagato subito', + p.finanziamentoCents > 0 && 'Finanziamento incassato subito' + ]) + } + ] +} diff --git a/src/shared/types.ts b/src/shared/types.ts index d9b2d98..126599f 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -296,3 +296,13 @@ export interface Loan extends BaseRecord { balloon_cents: number notes: string | null } + +// --- Simulazioni (Fase 7) ------------------------------------------------- + +export interface SimulationScenario extends BaseRecord { + company_uuid: string + name: string + /** JSON di `SimulationParams`: i risultati si ricalcolano sempre. */ + params: string + notes: string | null +}