From 08b94aa61d8f1395e60fae50f2555825e1581c4f Mon Sep 17 00:00:00 2001 From: cammo22 Date: Wed, 16 Sep 2026 12:42:46 +0200 Subject: [PATCH] Fase 5: capitale circolante, tesoreria e scadenziario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Capitale Circolante (AGENTS.md §10.5) - Motore: componenti, variazioni, media mobile a 3 mesi su mesi consecutivi e note automatiche a regole (soglie dichiarate in SOGLIE_NOTE, da validare col consulente: §9 del modello non le fissa). - Card confrontate con la fine dell'anno precedente, indici DSO/DIO/DPO/CCC con lo stesso periodo dell'anno prima e sparkline, grafici a 24 e 12 mesi, tabella delle componenti con le note. Tesoreria / Cash Flow e Scadenziario (§10.6) - Migrazione 004: treasury_items (scadenze, previsioni manuali anche mensili, in futuro rate dei finanziamenti; incassi parziali) e company_treasury_settings (soglia minima, ultimo saldo di banca). - Motore della previsione: somma di movimenti datati, liquidità di partenza dall'ultimo saldo noto più i movimenti registrati dopo, scadenze passate e aperte portate a oggi, orizzonti 7/30/60/90 giorni e 6 mesi, primo giorno sotto soglia cercato giorno per giorno. Condizioni RD/DF/FM del DICTIONARY. - Schermata con KPI, tabella a orizzonti, grafico storico + previsione con soglia e marcatore OGGI, scadenziario con incasso/pagamento anche parziale, previsioni manuali, moduli di inserimento. L'operatore Azienda legge e non scrive finché §14 punto 4 resta aperto. - La Panoramica avvisa di tensione finanziaria e pagamenti scaduti e mostra liquidità di oggi, cash flow a 30 giorni e la previsione nel grafico. Demo - Scadenziario e previsioni della Pizzeria con date relative al primo avvio; un database demo creato da una versione precedente li riceve una volta. - Nel 2026 crediti e magazzino crescono, così le note hanno di che parlare. Correzioni - La serie storica non mescola più mesi e bilancio annuale, e applica il limite dopo aver scartato i periodi senza saldi. - Definite le tonalità ink-500, ink-200 e brand-200 usate dall'interfaccia: i testi secondari uscivano bianchi. Verifiche: 54 test, verify:schema da 22 a 35 controlli, percorso completo provato nell'app (incasso parziale, fattura a 60 gg fine mese, database demo 0.0.4 aggiornato, 403 per l'operatore Azienda). Documentazione aggiornata. Co-Authored-By: Claude Opus 5 --- AGENTS.md | 54 +- README.md | 35 +- docs/MODELLO_FINANZIARIO.md | 3 +- scripts/verify-schema.cjs | 62 ++ src/main/db/demo-data.ts | 9 +- src/main/db/demo-treasury.ts | 166 +++++ src/main/db/migrations/004_treasury.ts | 78 +++ src/main/db/migrations/index.ts | 4 +- src/main/db/seed.ts | 31 +- src/main/server/routes/companies.routes.ts | 2 + src/main/server/routes/treasury.routes.ts | 69 +++ src/main/server/services/analysis.service.ts | 24 +- src/main/server/services/treasury.service.ts | 368 +++++++++++ .../services/working-capital.service.ts | 126 ++++ src/renderer/src/components/Sidebar.tsx | 14 +- src/renderer/src/components/charts.tsx | 212 ++++++- src/renderer/src/index.css | 3 + src/renderer/src/lib/api.ts | 1 + src/renderer/src/lib/format.ts | 34 ++ src/renderer/src/pages/CompanyPage.tsx | 70 ++- .../src/pages/business/OverviewView.tsx | 68 ++- .../src/pages/business/TreasuryForms.tsx | 435 +++++++++++++ .../src/pages/business/TreasuryView.tsx | 571 ++++++++++++++++++ .../src/pages/business/WorkingCapitalView.tsx | 338 +++++++++++ src/shared/analysis.ts | 51 +- src/shared/engine/index.ts | 2 + src/shared/engine/treasury.test.ts | 219 +++++++ src/shared/engine/treasury.ts | 359 +++++++++++ src/shared/engine/working-capital.ts | 204 +++++++ src/shared/enums.ts | 40 ++ src/shared/types.ts | 34 ++ 31 files changed, 3641 insertions(+), 45 deletions(-) create mode 100644 src/main/db/demo-treasury.ts create mode 100644 src/main/db/migrations/004_treasury.ts create mode 100644 src/main/server/routes/treasury.routes.ts create mode 100644 src/main/server/services/treasury.service.ts create mode 100644 src/main/server/services/working-capital.service.ts create mode 100644 src/renderer/src/pages/business/TreasuryForms.tsx create mode 100644 src/renderer/src/pages/business/TreasuryView.tsx create mode 100644 src/renderer/src/pages/business/WorkingCapitalView.tsx create mode 100644 src/shared/engine/treasury.test.ts create mode 100644 src/shared/engine/treasury.ts create mode 100644 src/shared/engine/working-capital.ts diff --git a/AGENTS.md b/AGENTS.md index 1050248..beba20f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -264,12 +264,12 @@ La nota *"se i numeri sono questi cosa devo fare per crescere?"* suggerisce un l | **2** | Schema DB completo del motore finanziario (piano dei conti, tag, saldi, periodi) | Schema applicato, migrazioni versionate | ✅ **Fatta** (sessione 1) | | **3** | Motore di riclassificazione + indici (da `docs/MODELLO_FINANZIARIO.md`), **import Excel §11.1** | Import di un vero file cliente → Conto Economico riclassificato corretto | 🟡 **Quasi**: motore, indici e import fatti e verificati. Manca il file di un cliente **con i saldi**: quello consegnato è un modello vuoto (vedi `docs/MODELLO_FINANZIARIO.md` §8-bis) | | **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 | ⬜ Prossima | -| **6** | UI Banche e Finanziamenti (§10.7) + collegamento rate→Cash Flow | Fidi/finanziamenti con impatto visibile in Tesoreria | ⬜ | +| **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 | ⬜ Prossima | | **7** | Analisi & Simulazioni (§10.8) | Scenario what-if salvabile e confrontabile | ⬜ | | **8** | Sync Consulente↔Azienda via Tailscale (§6) + status bar (§7) | Due installazioni reali che si scambiano dati | ⬜ | | **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 e portable funzionanti (v0.0.1). Mancano le due varianti separate e Tailscale bundled, che hanno senso solo dopo la Fase 8 | +| **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. **Decisione del cliente (2026-09-16): nessuna release fino a codice finito; alla fine una sola release con tre eseguibili — installer, portable, demo** | | **11+** | Integrazioni Fase futura: connettore IRIS, Cassetto Fiscale, Open Banking, pianificazione fiscale, marginalità multi-dimensionale, assistente numeri | Una alla volta, dopo validazione col cliente | ⬜ | ### 13-bis. Stato alla fine della sessione 1 (Fasi 0 → 4) @@ -424,7 +424,53 @@ Tre garanzie, perché una demo con credenziali note non deve mai confondersi con ⚠️ **Resta aperto, da chiarire col consulente**: ROE e ROI confrontano un flusso (utile, EBIT) con uno stock (patrimonio, capitale investito). §5 non dice di annualizzarli, quindi su un mese valgono circa un dodicesimo del valore annuale. Il bilancio annuale li mostra corretti. -**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. +### 13-ter. Sessione 2 — modello Excel e Fase 5 + +**Modello Excel scaricabile.** Provando la versione portatile, un'azienda appena creata restava bloccata: l'unica porta d'ingresso dei dati è l'import, che vuole il file del consulente. Ora *Import dati* (e la schermata vuota di un'azienda senza bilancio) offre **Scarica il modello Excel**: stesse intestazioni e sezioni che l'import riconosce, TIPO a tendina, sotto-classificazioni a "X" solo dove la sezione le prevede, un foglio ISTRUZIONI, e i conti già presenti dell'azienda. Un test genera il modello dalle 24 sezioni della migrazione 002, lo compila e lo reimporta senza perdite (`src/main/import/template.test.ts`). + +**Fase 5 — Capitale Circolante (§10.5)** + +| Pezzo | Dove | +|---|---| +| Componenti, variazioni, media mobile, note automatiche | `src/shared/engine/working-capital.ts` | +| Confronti e storia a 24 mesi | `src/main/server/services/working-capital.service.ts` | +| Schermata | `src/renderer/src/pages/business/WorkingCapitalView.tsx` | + +Le card confrontano con **la fine dell'anno precedente** (dicembre, o il bilancio annuale se dicembre manca), la tabella degli indici con **lo stesso periodo dell'anno prima**: sono i due confronti dei mockup. La media mobile del CCC è a **3 mesi** e si calcola solo su mesi consecutivi. + +Le **note automatiche** sono regole, come chiede §6 del modello, con soglie che il modello non fissa (§9): un indice di ciclo si segnala da **5 giorni** di differenza, una componente da **±10%**, i crediti scaduti da oltre 60 giorni quando pesano almeno il **10%** dei crediti aperti. Sono in `SOGLIE_NOTE`, un punto solo. ⚠️ Da validare col consulente. Un aumento di DPO è una nota **neutra**: pagare più tardi aiuta la cassa ma può essere un segnale di tensione. + +**Fase 5 — Tesoreria / Cash Flow e Scadenziario (§10.6)** + +Migrazione `004_treasury`: + +| Tabella | Cosa tiene | +|---|---| +| `treasury_items` | Ogni movimento atteso. `source` distingue le fatture dello **scadenziario**, le **previsioni manuali** (anche mensili ricorrenti) e — dalla Fase 6 — le rate dei **finanziamenti**. Documento, condizioni e modalità di pagamento dal DICTIONARY (§8 del modello). Incassi parziali in `paid_cents`: lo stato "Pagata / Non pagata / Pagamento parziale" si ricava, non si memorizza | +| `company_treasury_settings` | Soglia minima di liquidità e ultimo saldo di banca noto | + +`docs/MODELLO_FINANZIARIO.md` **non definisce la previsione di cassa**: le regole di `src/shared/engine/treasury.ts` sono scelte di implementazione, dichiarate nel codice e da validare col consulente. + +1. **La previsione è una somma di movimenti datati**, nessuna proiezione statistica: ogni euro si ritrova in una riga. +2. **Liquidità di partenza** = il più recente fra il saldo inserito a mano e le liquidità immediate dell'ultimo bilancio a consuntivo, più incassi e pagamenti registrati dopo quella data. +3. **Le scadenze passate e non saldate entrano oggi**, marcate come scadute: un credito scaduto è ancora un incasso atteso. **Le previsioni manuali passate invece si scartano**: erano stime, il consuntivo le ha superate. +4. **Condizioni di pagamento** (RD, DF, FM — il file non le spiega): RD e DF = data documento + giorni; FM = data documento + giorni, poi a fine mese. È la lettura d'uso comune. +5. **Orizzonti** 7, 30, 60, 90 giorni e 6 mesi, cumulati da oggi. La **tensione finanziaria** è il primo giorno in cui la liquidità prevista scende sotto la soglia minima (sotto zero, se la soglia non c'è), cercato giorno per giorno: un campionamento settimanale potrebbe saltarlo. È anche l'avviso "Tensione finanziaria tra N giorni" della Panoramica (§10.2), insieme a quello sui pagamenti scaduti. + +La **Panoramica** ora mostra liquidità di oggi, cash flow a 30 giorni e il grafico con storico e previsione, soglia minima e marcatore OGGI. "Affidamenti disponibili" resta un trattino fino alla Fase 6. + +**Permessi**: l'operatore Azienda vede tesoreria e scadenziario ma non li modifica. È la scelta prudente finché resta aperto §14 punto 4 (autonomia dell'app Azienda), che cambia il modello di sync. + +**Dati della demo**: scadenziario e previsioni della Pizzeria DaProd hanno **date relative al giorno del primo avvio** (`src/main/db/demo-treasury.ts`): una previsione guarda avanti da oggi, e una demo aperta fra sei mesi con tutte le scadenze nel passato mostrerebbe solo arretrati. Ci sono di proposito un credito scaduto da oltre 60 giorni, un incasso parziale e un pagamento scaduto. Un database demo creato da una versione precedente riceve la tesoreria al primo avvio, una volta sola. La **rata del mutuo** è per ora una previsione manuale: col modulo Banche diventerà un finanziamento vero. + +**Due correzioni emerse lungo la strada** + +- **Serie storica**: mescolava mesi e bilancio annuale sullo stesso asse (un punto annuale in mezzo ai mesi vale dodici volte gli altri), e applicava il limite di 24 punti prima di scartare i periodi senza saldi, così i mesi di solo budget rubavano posto alla storia. +- **Palette**: alcune tonalità usate dall'interfaccia (`ink-500`, `ink-200`, `brand-200`) non erano definite, e i testi secondari uscivano bianchi. + +**Verifiche**: 54 test (`npm run test`), fra cui la previsione di cassa calcolata a mano giorno per giorno; `npm run verify:schema` passa da 22 a 35 controlli con i vincoli della 004; percorso completo provato nell'app: incasso parziale che aggiorna liquidità e previsione, fattura "60 giorni fine mese" con scadenza calcolata, aggiornamento di un database demo della 0.0.4. + +**Da fare in Fase 6**: Banche e Finanziamenti (§10.7), con le rate che entrano nella previsione come `source = 'finanziamento'`, gli affidamenti disponibili nella Tesoreria e il DSCR finalmente calcolabile. --- diff --git a/README.md b/README.md index 179d1bc..8c32a99 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%204%2F10-3ddbff) +![stato](https://img.shields.io/badge/stato-in%20sviluppo%20%C2%B7%20fase%205%2F10-3ddbff) ![piattaforma](https://img.shields.io/badge/Windows-x64-3ddbff) [![licenza](https://img.shields.io/badge/licenza-MIT-5cff9d)](LICENSE) @@ -140,15 +140,33 @@ 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 entrano i dati + +Due strade, che si usano insieme. + +**Il bilancio, da Excel.** Dentro un'azienda, alla voce *Import dati*, c'è il pulsante +**Scarica il modello Excel**: un file con tutte le sezioni già pronte, e i conti +dell'azienda se ce ne sono già. Si scrive il saldo di ogni conto, si ricarica il file, +si sceglie a quale mese e a quale scenario appartiene (consuntivo, budget, forecast). +Prima di scrivere qualsiasi cosa, il programma mostra un riepilogo di quello che ha +letto. Il consulente che ha già il suo file può caricare direttamente quello. + +**Le scadenze e le previsioni, a mano.** Nella *Tesoreria* si inseriscono le fatture da +incassare e da pagare, con le condizioni di pagamento (*30 giorni fine mese* e simili: +la scadenza si calcola da sola), e le voci che si ripetono — stipendi, affitto, incassi +di cassa. Quando una fattura viene pagata, anche solo in parte, lo si registra con un +clic, e la liquidità di oggi si aggiorna. + --- ## A che punto siamo Il programma si avvia, riconosce chi entra, gestisce l'anagrafica dei clienti e delle loro aziende, importa il piano dei conti da Excel e ne calcola il bilancio -riclassificato con tutti gli indici — e adesso lo **mostra**: panoramica con avvisi -automatici, conto economico riclassificato e stato patrimoniale con tutti gli indici. -Quello che manca è la parte di cassa: previsione, scadenziario, banche, simulazioni. +riclassificato con tutti gli indici, e lo mostra in cinque schermate: panoramica con +avvisi automatici, conto economico, stato patrimoniale, capitale circolante con le sue +note automatiche, e tesoreria con scadenziario e previsione di cassa a sei mesi. +Mancano banche e finanziamenti, le simulazioni e il collegamento fra i due programmi. | | Fase | Stato | |---|---|---| @@ -157,9 +175,11 @@ Quello che manca è la parte di cassa: previsione, scadenziario, banche, simulaz | 2 | Struttura dati del motore di calcolo | ✅ Fatta | | 3 | Riclassificazione, indici, import Excel | 🟡 Quasi — serve un file cliente **con i saldi** per la verifica finale | | 4 | Le prime tre schermate di analisi | ✅ Fatta | -| 5-7 | Cassa, banche, simulazioni | ⬜ Prossima | +| 5 | Capitale circolante, tesoreria, scadenziario | ✅ Fatta | +| 6 | Banche e finanziamenti | ⬜ Prossima | +| 7 | Analisi e simulazioni | ⬜ | | 8-9 | Collegamento fra i due programmi | ⬜ | -| 10 | Installatori | 🟡 Già disponibili, da rifinire | +| 10 | I tre eseguibili finali: installer, portable, demo | 🟡 Già provati, si pubblicano a codice finito | La roadmap completa, con il dettaglio di cosa c'è dentro ogni fase, è in [`AGENTS.md` §13](./AGENTS.md). @@ -168,7 +188,8 @@ La roadmap completa, con il dettaglio di cosa c'è dentro ogni fase, è in Il modo più rapido per vederlo all'opera è la **versione demo**: `DaProdFinanza-Demo-x.y.z-portable.exe`. Si lancia senza installare niente e parte -già con una pizzeria di esempio e due anni di bilanci caricati. Si entra come +già con una pizzeria di esempio, due anni di bilanci, lo scadenziario e le previsioni +di cassa. Si entra come consulente con **`cammo` / `1234`**, oppure come azienda con **`Pizzeria DaProd` / `1234`**. I suoi dati restano in una cartella a parte e non si mescolano mai con quelli di un'installazione vera. diff --git a/docs/MODELLO_FINANZIARIO.md b/docs/MODELLO_FINANZIARIO.md index c4c55f2..7ba2df3 100644 --- a/docs/MODELLO_FINANZIARIO.md +++ b/docs/MODELLO_FINANZIARIO.md @@ -291,5 +291,6 @@ Nel foglio `PIANO DEI CONTI` ogni sezione dichiara a quali aggregati contribuisc - Aliquote fiscali per forma giuridica/regione (per la pianificazione fiscale, `AGENTS.md` §11.6). - Soglie di alert per la Panoramica (es. a che punto "Margine operativo -22%" diventa un alert rosso) — negli screenshot i valori sono plausibili ma le soglie esatte non sono documentate nel file Excel. -- Schema esatto delle "note automatiche" nel tab Capitale Circolante (§6) — dedotto dagli screenshot, non specificato nel file. +- Schema esatto delle "note automatiche" nel tab Capitale Circolante (§6) — dedotto dagli screenshot, non specificato nel file. *Implementate con soglie ipotizzate (5 giorni sugli indici di ciclo, ±10% sulle componenti, 10% di crediti scaduti oltre 60 giorni): vedi `AGENTS.md` §13-ter.* +- Regole della previsione di cassa (tab Tesoreria): il file non la tratta. L'app somma movimenti datati — scadenze aperte, previsioni manuali, rate — partendo dall'ultimo saldo noto; le scadenze passate e aperte entrano oggi. Anche il significato delle condizioni di pagamento RD/DF/FM (§8) è una lettura d'uso comune da confermare. Dettaglio in `AGENTS.md` §13-ter. - Formato del file XML di fatture elettroniche da importare (vedi `AGENTS.md` §11.2 e §14). diff --git a/scripts/verify-schema.cjs b/scripts/verify-schema.cjs index cc8b3e7..e7c5cb2 100644 --- a/scripts/verify-schema.cjs +++ b/scripts/verify-schema.cjs @@ -151,6 +151,68 @@ app.whenReady().then(() => { check('stesso conto, periodo e scenario', () => insertBalance(randomUUID(), accountUuid, monthUuid, 'actual', 999), 'rifiutato') check('scenario inventato', () => insertBalance(randomUUID(), accountUuid, yearUuid, 'consuntivo', 100), 'rifiutato') check('saldo su un conto inesistente', () => insertBalance(randomUUID(), randomUUID(), monthUuid, 'actual', 100), 'rifiutato') + + const insertItem = (fields) => { + const row = { + direction: 'in', + source: 'scadenziario', + due_date: `${ANNO}-01-31`, + amount_cents: 10000, + paid_cents: 0, + paid_date: null, + recurrence: 'none', + payment_terms: null, + ...fields + } + db.prepare( + `INSERT INTO treasury_items (uuid, company_uuid, direction, source, category, description, + payment_terms, due_date, amount_cents, paid_cents, paid_date, + recurrence, created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, ?, 'Clienti', 'prova', ?, ?, ?, ?, ?, ?, ?, ?, 0, 0)` + ).run( + randomUUID(), company.uuid, row.direction, row.source, row.payment_terms, row.due_date, + row.amount_cents, row.paid_cents, row.paid_date, row.recurrence, now, now + ) + } + + console.log('\n Tesoreria e scadenziario') + check('fattura da incassare', () => insertItem({}), 'accettato') + check('incasso parziale con data', () => insertItem({ paid_cents: 4000, paid_date: `${ANNO}-02-01` }), 'accettato') + check('previsione manuale ricorrente', () => insertItem({ source: 'manuale', direction: 'out', recurrence: 'monthly' }), 'accettato') + check('importo zero', () => insertItem({ amount_cents: 0 }), 'rifiutato') + check('incassato più del totale', () => insertItem({ paid_cents: 20000, paid_date: `${ANNO}-02-01` }), 'rifiutato') + check('data di incasso senza importo incassato', () => insertItem({ paid_date: `${ANNO}-02-01` }), 'rifiutato') + check('fattura dello scadenziario ricorrente', () => insertItem({ recurrence: 'monthly' }), 'rifiutato') + check('verso inventato', () => insertItem({ direction: 'entrata' }), 'rifiutato') + check('condizione di pagamento fuori dizionario', () => insertItem({ payment_terms: 'XX' }), 'rifiutato') + + // Un'azienda temporanea, perché quella esistente può avere già le sue + // impostazioni e l'indice univoco le proteggerebbe. + const clientUuid = randomUUID() + const tempCompany = randomUUID() + db.prepare( + `INSERT INTO clients (uuid, code, name, archived, created_at, updated_at, synced, deleted) + VALUES (?, ?, 'Cliente di prova', 0, ?, ?, 0, 0)` + ).run(clientUuid, codice('CLI'), now, now) + db.prepare( + `INSERT INTO companies (uuid, client_uuid, code, name, archived, created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, 'Azienda di prova', 0, ?, ?, 0, 0)` + ).run(tempCompany, clientUuid, codice('AZ'), now, now) + + const insertSettings = (cash, date, min = 100) => + db + .prepare( + `INSERT INTO company_treasury_settings (uuid, company_uuid, min_liquidity_cents, + opening_cash_cents, opening_cash_date, + created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0)` + ) + .run(randomUUID(), tempCompany, min, cash, date, now, now) + + check('saldo di cassa senza data', () => insertSettings(5000, null), 'rifiutato') + check('soglia minima negativa', () => insertSettings(null, null, -1), 'rifiutato') + check('impostazioni valide', () => insertSettings(5000, `${ANNO}-01-01`), 'accettato') + check('seconde impostazioni per la stessa azienda', () => insertSettings(null, null), 'rifiutato') } finally { db.exec('ROLLBACK') db.close() diff --git a/src/main/db/demo-data.ts b/src/main/db/demo-data.ts index 2b04bdd..ea9d28b 100644 --- a/src/main/db/demo-data.ts +++ b/src/main/db/demo-data.ts @@ -142,13 +142,18 @@ function conImposte(valori: Valori): { valori: Valori; utile: number } { function stock(year: number, month: number, utileCumulato: number): Valori { const s = STAGIONE[month - 1] const mesi = mesiDaInizio(year, month) + // Nel 2026 i crediti verso piattaforme e catering crescono più dei ricavi e + // il magazzino un po' meno: la vista del capitale circolante ha così + // qualcosa da raccontare rispetto all'anno prima. + const crediti = year >= 2026 ? 1.45 : 1 + const scorte = year >= 2026 ? 1.12 : 1 const attivo: Valori = { '10.01': 15_000, '11.01': 180_000, '11.09': 60_000 + 1_800 * mesi, - '20.01': 9_000 * (0.85 + 0.15 * s), - '21.01': 6_500 * s, + '20.01': 9_000 * (0.85 + 0.15 * s) * scorte, + '21.01': 6_500 * s * crediti, '21.02': 1_500, '21.03': 2_000, '22.01': 38_000 + 0.55 * utileCumulato diff --git a/src/main/db/demo-treasury.ts b/src/main/db/demo-treasury.ts new file mode 100644 index 0000000..bf12817 --- /dev/null +++ b/src/main/db/demo-treasury.ts @@ -0,0 +1,166 @@ +import { addDays, formatDate, parseDate } from '@shared/engine' +import type { TreasuryItemInput } from '@shared/types' +import { createItem, openingCash, todayLocal, updateSettings } from '../server/services/treasury.service' + +/** + * Scadenziario e previsioni della Pizzeria DaProd dimostrativa (Fase 5). + * + * A differenza dei bilanci, qui le date sono **relative al giorno in cui la + * demo parte**: una previsione di cassa guarda avanti da oggi, e una demo + * aperta fra sei mesi con tutte le scadenze nel passato mostrerebbe solo + * arretrati. Ci sono di proposito un credito scaduto da oltre 60 giorni, un + * incasso parziale e un pagamento scaduto: sono i casi che la vista deve saper + * raccontare. + */ + +const euro = (value: number): number => Math.round(value * 100) + +/** Il giorno `day` del mese di `today`: il punto di partenza di una ricorrenza. */ +function giornoDelMese(today: string, day: number): string { + const t = new Date(parseDate(today)) + return formatDate(Date.UTC(t.getUTCFullYear(), t.getUTCMonth(), day)) +} + +export function seedDemoTreasury(companyUuid: string, today = todayLocal()): void { + const d = (days: number): string => addDays(today, days) + + const scadenze: TreasuryItemInput[] = [ + // --- crediti ------------------------------------------------------------ + { + direction: 'in', category: 'Clienti', counterparty: 'Famiglia Rossi', + description: 'Catering matrimonio', document_ref: 'FT 2026/041', + payment_method: 'Bonifico', due_date: d(-75), amount_cents: euro(4_800) + }, + { + direction: 'in', category: 'Clienti', counterparty: 'Studio Bianchi', + description: 'Catering aziendale di fine estate', document_ref: 'FT 2026/058', + payment_method: 'Bonifico', due_date: d(-20), amount_cents: euro(2_200), + paid_cents: euro(1_000), paid_date: d(-5) + }, + { + direction: 'in', category: 'Clienti', counterparty: 'Parrocchia San Rocco', + description: 'Catering battesimo', document_ref: 'FT 2026/061', + payment_method: 'Bonifico', due_date: d(-10), amount_cents: euro(1_500), + paid_cents: euro(1_500), paid_date: d(-8) + }, + { + direction: 'in', category: 'Clienti', counterparty: 'Just Eat', + description: 'Liquidazione ordini delivery', payment_method: 'Bonifico', + due_date: d(5), amount_cents: euro(3_200) + }, + { + direction: 'in', category: 'Clienti', counterparty: 'Deliveroo', + description: 'Liquidazione ordini delivery', payment_method: 'Bonifico', + due_date: d(12), amount_cents: euro(2_900) + }, + { + direction: 'in', category: 'Clienti', counterparty: 'Glovo', + description: 'Liquidazione ordini delivery', payment_method: 'Bonifico', + due_date: d(26), amount_cents: euro(1_900) + }, + { + direction: 'in', category: 'Clienti', counterparty: 'Comune di Pizzolandia', + description: 'Stand alla sagra d\'autunno', document_ref: 'FT 2026/066', + payment_method: 'Bonifico', due_date: d(48), amount_cents: euro(6_500) + }, + + // --- debiti ------------------------------------------------------------- + { + direction: 'out', category: 'Fornitori', counterparty: 'Assistenza Forni Srl', + description: 'Manutenzione straordinaria forno', document_ref: 'FT 318', + payment_method: 'Bonifico', due_date: d(-12), amount_cents: euro(1_200) + }, + { + direction: 'out', category: 'Fornitori', counterparty: 'Imballaggi Sud', + description: 'Cartoni pizza e confezioni', document_ref: 'FT 1102', + payment_method: 'RI.BA.', due_date: d(-6), amount_cents: euro(900), + paid_cents: euro(900), paid_date: d(-6) + }, + { + direction: 'out', category: 'Fornitori', counterparty: 'Molino Rossi', + description: 'Farine', document_ref: 'FT 2211', + payment_method: 'RI.BA.', due_date: d(8), amount_cents: euro(3_900) + }, + { + direction: 'out', category: 'Fornitori', counterparty: 'Caseificio Campano', + description: 'Mozzarella fior di latte', document_ref: 'FT 874', + payment_method: 'Bonifico', due_date: d(15), amount_cents: euro(5_600) + }, + { + direction: 'out', category: 'Fornitori', counterparty: 'Bevande Srl', + description: 'Bevande e birre', document_ref: 'FT 5530', + payment_method: 'RI.BA.', due_date: d(22), amount_cents: euro(2_700) + }, + { + direction: 'out', category: 'Fornitori', counterparty: 'Molino Rossi', + description: 'Farine', document_ref: 'FT 2298', + payment_method: 'RI.BA.', due_date: d(38), amount_cents: euro(3_700) + }, + { + direction: 'out', category: 'Fornitori', counterparty: 'Caseificio Campano', + description: 'Mozzarella fior di latte', document_ref: 'FT 912', + payment_method: 'Bonifico', due_date: d(45), amount_cents: euro(5_400) + } + ] + + const previsioni: TreasuryItemInput[] = [ + // Ricorrenti: le voci che si ripetono ogni mese. + { + direction: 'in', category: 'Corrispettivi', + description: 'Versamento incassi sala e asporto (prima quindicina)', + due_date: giornoDelMese(today, 5), amount_cents: euro(31_000), recurrence: 'monthly' + }, + { + direction: 'in', category: 'Corrispettivi', + description: 'Versamento incassi sala e asporto (seconda quindicina)', + due_date: giornoDelMese(today, 20), amount_cents: euro(31_000), recurrence: 'monthly' + }, + { + direction: 'out', category: 'Affitti e locazioni', description: 'Affitto del locale', + due_date: giornoDelMese(today, 5), amount_cents: euro(6_000), recurrence: 'monthly' + }, + { + direction: 'out', category: 'Personale', description: 'Stipendi pizzaioli e sala', + due_date: giornoDelMese(today, 10), amount_cents: euro(19_500), recurrence: 'monthly' + }, + { + direction: 'out', category: 'Imposte e contributi', description: 'F24 contributi e ritenute', + due_date: giornoDelMese(today, 16), amount_cents: euro(7_800), recurrence: 'monthly' + }, + { + direction: 'out', category: 'Utenze', description: 'Luce, gas e acqua', + due_date: giornoDelMese(today, 20), amount_cents: euro(2_200), recurrence: 'monthly' + }, + { + direction: 'out', category: 'Fornitori', + description: 'Altri acquisti di ingredienti non ancora fatturati (stima)', + due_date: giornoDelMese(today, 28), amount_cents: euro(14_000), recurrence: 'monthly' + }, + { + direction: 'out', category: 'Rate finanziamenti', description: 'Rata mutuo ristrutturazione', + due_date: giornoDelMese(today, 28), amount_cents: euro(1_850), recurrence: 'monthly' + }, + // Una tantum. + { + direction: 'in', category: 'Altre entrate', description: 'Rimborso assicurazione per il guasto al forno', + due_date: d(33), amount_cents: euro(2_500) + }, + { + direction: 'out', category: 'Imposte e contributi', description: 'Acconto IRES e IRAP', + due_date: d(70), amount_cents: euro(16_000) + } + ] + + for (const item of scadenze) createItem(companyUuid, { source: 'scadenziario', ...item }) + for (const item of previsioni) createItem(companyUuid, { source: 'manuale', ...item }) + + // Saldo di banca di due giorni fa, coerente con l'ultimo bilancio: la cassa + // di fine periodo, al netto di quello che è successo dopo. + const bilancio = openingCash(companyUuid, today) + const saldo = bilancio.origin === 'bilancio' ? bilancio.cents - euro(4_300) : euro(60_000) + updateSettings(companyUuid, { + min_liquidity_cents: euro(25_000), + opening_cash_cents: saldo, + opening_cash_date: d(-2) + }) +} diff --git a/src/main/db/migrations/004_treasury.ts b/src/main/db/migrations/004_treasury.ts new file mode 100644 index 0000000..2f9fb4c --- /dev/null +++ b/src/main/db/migrations/004_treasury.ts @@ -0,0 +1,78 @@ +import type { Database } from 'better-sqlite3-multiple-ciphers' + +/** + * Fase 5 — tesoreria e scadenziario (AGENTS.md §10.6). + * + * Il bilancio dice quanta liquidità c'era a fine periodo; la tesoreria dice + * quanta ce ne sarà. Servono dati che il piano dei conti non ha: le singole + * scadenze e le previsioni del consulente. + * + * - `treasury_items`: una riga per ogni movimento atteso. Le scadenze dello + * scadenziario (fatture da incassare o da pagare) e le previsioni manuali + * stanno nella stessa tabella perché la previsione le somma allo stesso + * modo; `source` le distingue. Le rate dei finanziamenti (Fase 6) useranno + * `source = 'finanziamento'`. + * - `company_treasury_settings`: la soglia minima di liquidità e l'ultimo saldo + * di cassa noto, se il consulente lo inserisce a mano. + * + * Importi in centesimi interi, come nella 002. Date `YYYY-MM-DD`. + */ +export function up(db: Database): void { + db.exec(` + CREATE TABLE treasury_items ( + uuid TEXT PRIMARY KEY, + company_uuid TEXT NOT NULL REFERENCES companies(uuid), + direction TEXT NOT NULL CHECK (direction IN ('in', 'out')), + source TEXT NOT NULL CHECK (source IN ('scadenziario', 'manuale', 'finanziamento')), + category TEXT NOT NULL, + description TEXT NOT NULL, + counterparty TEXT, + -- Documento d'origine (numero e data fattura) e condizioni di pagamento + -- del foglio DICTIONARY (docs/MODELLO_FINANZIARIO.md §8). + document_ref TEXT, + document_date TEXT, + payment_terms TEXT CHECK (payment_terms IS NULL OR payment_terms IN ('RD', 'DF', 'FM')), + payment_days INTEGER CHECK (payment_days IS NULL OR payment_days >= 0), + payment_method TEXT, + due_date TEXT NOT NULL, + amount_cents INTEGER NOT NULL CHECK (amount_cents > 0), + -- Pagamento parziale (§8: "Pagata / Non pagata / Pagamento parziale"): + -- lo stato si ricava da qui, non si memorizza due volte. + paid_cents INTEGER NOT NULL DEFAULT 0 CHECK (paid_cents >= 0 AND paid_cents <= amount_cents), + paid_date TEXT, + recurrence TEXT NOT NULL DEFAULT 'none' CHECK (recurrence IN ('none', 'monthly')), + recurrence_until TEXT, + 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)), + + -- Una fattura è un documento singolo: non si ripete. + CHECK (recurrence = 'none' OR source <> 'scadenziario'), + -- Una data di incasso senza importo incassato non vuol dire niente. + CHECK (paid_date IS NULL OR paid_cents > 0) + ); + + CREATE INDEX idx_treasury_items_company_due + ON treasury_items(company_uuid, due_date) WHERE deleted = 0; + + CREATE TABLE company_treasury_settings ( + uuid TEXT PRIMARY KEY, + company_uuid TEXT NOT NULL REFERENCES companies(uuid), + min_liquidity_cents INTEGER CHECK (min_liquidity_cents IS NULL OR min_liquidity_cents >= 0), + opening_cash_cents INTEGER, + opening_cash_date 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)), + + -- Un saldo senza data non si può usare come punto di partenza. + CHECK ((opening_cash_cents IS NULL) = (opening_cash_date IS NULL)) + ); + + CREATE UNIQUE INDEX idx_company_treasury_settings + ON company_treasury_settings(company_uuid) WHERE deleted = 0; + `) +} diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index b630ad5..b20a217 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -2,6 +2,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' +import { up as up004 } from './004_treasury' export interface Migration { version: number @@ -17,7 +18,8 @@ export interface Migration { export const MIGRATIONS: Migration[] = [ { version: 1, name: '001_initial', up: up001 }, { version: 2, name: '002_financial_model', up: up002 }, - { version: 3, name: '003_import_documents_no_unique_hash', up: up003 } + { version: 3, name: '003_import_documents_no_unique_hash', up: up003 }, + { version: 4, name: '004_treasury', up: up004 } ] export function runMigrations(db: Database): number { diff --git a/src/main/db/seed.ts b/src/main/db/seed.ts index ae4cda8..34c3a10 100644 --- a/src/main/db/seed.ts +++ b/src/main/db/seed.ts @@ -4,6 +4,7 @@ import { createCompany } from '../server/services/companies.service' import { insertUser } from '../server/services/auth.service' import { DEMO_BUILD } from '../build-flags' import { seedDemoFinancials } from './demo-data' +import { seedDemoTreasury } from './demo-treasury' import { getDatabase } from './index' /** @@ -48,7 +49,10 @@ export function seedDemoData(): void { const row = getDatabase() .prepare('SELECT count(*) AS n FROM users WHERE deleted = 0') .get() as { n: number } - if (row.n > 0) return + if (row.n > 0) { + aggiornaDemoEsistente() + return + } const client = createClient({ name: 'Gruppo DaProd', @@ -69,6 +73,8 @@ export function seedDemoData(): void { // I bilanci: 2025 completo, 2026 fino ad agosto, budget 2026. seedDemoFinancials(company.uuid) + // Scadenziario e previsioni di cassa, con date relative a oggi. + seedDemoTreasury(company.uuid) insertUser( { @@ -94,3 +100,26 @@ export function seedDemoData(): void { console.log('[db] seed dimostrativo creato (cammo / Pizzeria DaProd)') } + +/** + * Un database dimostrativo creato da una versione precedente non ha le + * tabelle arrivate dopo: le si riempie una volta sola, senza toccare il resto. + */ +function aggiornaDemoEsistente(): void { + const db = getDatabase() + const azienda = db + .prepare(`SELECT uuid FROM companies WHERE vat_number = '01234567890' AND deleted = 0`) + .get() as { uuid: string } | undefined + if (!azienda) return + + const tesoreria = db + .prepare( + `SELECT (SELECT count(*) FROM treasury_items WHERE company_uuid = ?) + + (SELECT count(*) FROM company_treasury_settings WHERE company_uuid = ?) AS n` + ) + .get(azienda.uuid, azienda.uuid) as { n: number } + if (tesoreria.n === 0) { + seedDemoTreasury(azienda.uuid) + console.log('[db] seed dimostrativo: aggiunta la tesoreria della Pizzeria DaProd') + } +} diff --git a/src/main/server/routes/companies.routes.ts b/src/main/server/routes/companies.routes.ts index 8402c10..718273b 100644 --- a/src/main/server/routes/companies.routes.ts +++ b/src/main/server/routes/companies.routes.ts @@ -9,6 +9,7 @@ import { } from '../services/companies.service' import { requireAuth, requireRole } from '../middleware/auth' import { analysisRouter } from './analysis.routes' +import { treasuryRouter } from './treasury.routes' /** Anagrafica Aziende — AGENTS.md §10.1. */ export const companiesRouter: Router = Router() @@ -17,6 +18,7 @@ companiesRouter.use(requireAuth) // Periodi, analisi e import vivono sotto la singola azienda. companiesRouter.use('/:uuid', analysisRouter) +companiesRouter.use('/:uuid', treasuryRouter) function uuidParam(req: Request): string { const value = req.params['uuid'] diff --git a/src/main/server/routes/treasury.routes.ts b/src/main/server/routes/treasury.routes.ts new file mode 100644 index 0000000..a39f6cb --- /dev/null +++ b/src/main/server/routes/treasury.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 { + createItem, + deleteItem, + registerPayment, + treasuryView, + updateItem, + updateSettings +} from '../services/treasury.service' +import { workingCapitalView } from '../services/working-capital.service' + +/** + * Capitale circolante, tesoreria e scadenziario di un'azienda — §10.5-§10.6. + * + * L'operatore Azienda legge; scrivere scadenze e previsioni resta al + * Consulente finché non è chiarito AGENTS.md §14 punto 4 (autonomia dell'app + * Azienda), che cambia il modello di sincronizzazione. + */ +export const treasuryRouter: Router = Router({ mergeParams: true }) + +treasuryRouter.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 +} + +treasuryRouter.get('/periods/:periodUuid/working-capital', (req, res) => { + res.json( + workingCapitalView(assertCanRead(req), param(req, 'periodUuid'), { + scenario: (req.query.scenario as Scenario) ?? undefined + }) + ) +}) + +treasuryRouter.get('/treasury', (req, res) => { + res.json(treasuryView(assertCanRead(req))) +}) + +treasuryRouter.post('/treasury/items', requireRole('consultant'), (req, res) => { + res.status(201).json(createItem(param(req, 'uuid'), req.body ?? {})) +}) + +treasuryRouter.put('/treasury/items/:itemUuid', requireRole('consultant'), (req, res) => { + res.json(updateItem(param(req, 'uuid'), param(req, 'itemUuid'), req.body ?? {})) +}) + +treasuryRouter.post('/treasury/items/:itemUuid/payments', requireRole('consultant'), (req, res) => { + res.json(registerPayment(param(req, 'uuid'), param(req, 'itemUuid'), req.body ?? {})) +}) + +treasuryRouter.delete('/treasury/items/:itemUuid', requireRole('consultant'), (req, res) => { + res.json(deleteItem(param(req, 'uuid'), param(req, 'itemUuid'))) +}) + +treasuryRouter.put('/treasury/settings', requireRole('consultant'), (req, res) => { + res.json(updateSettings(param(req, 'uuid'), req.body ?? {})) +}) diff --git a/src/main/server/services/analysis.service.ts b/src/main/server/services/analysis.service.ts index 92edaf1..73b6ca9 100644 --- a/src/main/server/services/analysis.service.ts +++ b/src/main/server/services/analysis.service.ts @@ -44,7 +44,7 @@ export function listPeriods(companyUuid: string): FiscalPeriod[] { })) } -function getPeriod(companyUuid: string, periodUuid: string): FiscalPeriod { +export function getPeriod(companyUuid: string, periodUuid: string): FiscalPeriod { const period = getDatabase() .prepare('SELECT * FROM fiscal_periods WHERE uuid = ? AND company_uuid = ? AND deleted = 0') .get(periodUuid, companyUuid) as FiscalPeriod | undefined @@ -78,20 +78,30 @@ export function engineAccounts( */ export function series( companyUuid: string, - options: { scenario?: Scenario; limit?: number } = {} + options: { scenario?: Scenario; limit?: number; until?: FiscalPeriod } = {} ): SeriesPoint[] { getCompany(companyUuid) const scenario = options.scenario ?? 'actual' const limit = options.limit ?? 24 - const periods = listPeriods(companyUuid) - .filter((p) => p.period_type === 'month') - .concat(listPeriods(companyUuid).filter((p) => p.period_type === 'year')) + // Mesi e anni non si mescolano sullo stesso asse: un punto annuale in mezzo + // ai mesi varrebbe dodici volte gli altri. Gli anni si usano solo se non c'è + // nessun mese. Il limite si applica dopo aver scartato i periodi senza saldi + // per lo scenario, altrimenti i mesi di solo budget rubano posto alla storia. + const tutti = listPeriods(companyUuid).filter((p) => p.scenarios?.includes(scenario)) + const mesi = tutti.filter((p) => p.period_type === 'month') + const until = options.until + const scelti = (mesi.length > 0 ? mesi : tutti.filter((p) => p.period_type === 'year')) + .filter( + (p) => + !until || + p.year * 100 + (p.month ?? 12) <= until.year * 100 + (until.month ?? 12) + ) .slice(0, limit) // Dal più vecchio al più recente: è l'ordine in cui si legge un grafico. .sort((a, b) => a.year - b.year || (a.month ?? 0) - (b.month ?? 0)) - return periods + return scelti .map((period) => { const accounts = engineAccounts(companyUuid, period.uuid, scenario) if (accounts.length === 0) return null @@ -194,7 +204,7 @@ function aggregatesOf(companyUuid: string, periodUuid: string, scenario: Scenari return accounts.length === 0 ? null : incomeStatement(accounts, DEFAULT_SCHEME).aggregates } -function findPeriod( +export function findPeriod( periods: FiscalPeriod[], year: number, month: number | null diff --git a/src/main/server/services/treasury.service.ts b/src/main/server/services/treasury.service.ts new file mode 100644 index 0000000..917c2c4 --- /dev/null +++ b/src/main/server/services/treasury.service.ts @@ -0,0 +1,368 @@ +import { + balanceSheet, + dueDate, + forecast, + periodDays, + receivablesAging, + type OpeningCash, + type PaymentTerms +} from '@shared/engine' +import type { TreasuryView } from '@shared/analysis' +import type { TreasuryItem, TreasuryItemInput, TreasurySettings } from '@shared/types' +import { getDatabase } from '../../db' +import { newUuid, nowIso } from '../../lib/ids' +import { HttpError } from '../http-error' +import { engineAccounts, listPeriods, series } from './analysis.service' +import { getCompany } from './companies.service' + +/** + * Tesoreria e scadenziario — AGENTS.md §10.6. + * + * Qui si leggono e scrivono le righe; la previsione la calcola il motore + * (`@shared/engine/treasury`), che resta una funzione pura. + */ + +/** La data di oggi nel fuso della macchina: un pagamento "di oggi" è di oggi in Italia. */ +export function todayLocal(now = new Date()): string { + const y = now.getFullYear() + const m = String(now.getMonth() + 1).padStart(2, '0') + const d = String(now.getDate()).padStart(2, '0') + return `${y}-${m}-${d}` +} + +const DATA = /^\d{4}-\d{2}-\d{2}$/ + +function data(value: unknown, campo: string, obbligatoria = false): string | null { + if (value === null || value === undefined || value === '') { + if (obbligatoria) throw new HttpError(400, `${campo}: data obbligatoria.`) + return null + } + if (typeof value !== 'string' || !DATA.test(value) || Number.isNaN(Date.parse(value))) { + throw new HttpError(400, `${campo}: data non valida (atteso AAAA-MM-GG).`) + } + return value +} + +function testo(value: unknown): string | null { + if (typeof value !== 'string') return null + const t = value.trim() + return t ? t : null +} + +function centesimi(value: unknown, campo: string): number { + if (typeof value !== 'number' || !Number.isInteger(value)) { + throw new HttpError(400, `${campo}: importo non valido.`) + } + return value +} + +// --- righe --------------------------------------------------------------------- + +export function listItems(companyUuid: string): TreasuryItem[] { + getCompany(companyUuid) + return getDatabase() + .prepare( + `SELECT * FROM treasury_items + WHERE company_uuid = ? AND deleted = 0 + ORDER BY due_date, created_at` + ) + .all(companyUuid) as TreasuryItem[] +} + +function getItem(companyUuid: string, uuid: string): TreasuryItem { + const item = getDatabase() + .prepare('SELECT * FROM treasury_items WHERE uuid = ? AND company_uuid = ? AND deleted = 0') + .get(uuid, companyUuid) as TreasuryItem | undefined + if (!item) throw new HttpError(404, 'Movimento non trovato.') + return item +} + +/** Valida e completa una riga: da un input parziale a una riga coerente. */ +function normalize(input: TreasuryItemInput, base?: TreasuryItem): Omit { + const merged = { ...base, ...input } + + const direction = merged.direction + if (direction !== 'in' && direction !== 'out') { + throw new HttpError(400, 'Indicare se è un incasso o un pagamento.') + } + const source = merged.source + // Le rate dei finanziamenti le genera il modulo Banche, non si scrivono a mano. + if (source !== 'scadenziario' && source !== 'manuale') { + throw new HttpError(400, 'Origine del movimento non valida.') + } + + const category = testo(merged.category) + if (!category) throw new HttpError(400, 'La categoria è obbligatoria.') + const description = testo(merged.description) + if (!description) throw new HttpError(400, 'La descrizione è obbligatoria.') + + const terms = merged.payment_terms ?? null + if (terms !== null && !['RD', 'DF', 'FM'].includes(terms)) { + throw new HttpError(400, 'Condizione di pagamento non valida.') + } + const days = merged.payment_days ?? null + if (days !== null && (!Number.isInteger(days) || days < 0)) { + throw new HttpError(400, 'I giorni di pagamento devono essere un numero intero positivo.') + } + + const documentDate = data(merged.document_date, 'Data documento') + // La scadenza si ricava dalle condizioni se non è indicata a mano. + let due = data(merged.due_date, 'Scadenza') + if (!due && documentDate && terms) { + due = dueDate(documentDate, terms as PaymentTerms, days ?? 0) + } + if (!due) throw new HttpError(400, 'Indicare la data di scadenza o la data del documento con le condizioni.') + + const amount = centesimi(merged.amount_cents, 'Importo') + if (amount <= 0) throw new HttpError(400, "L'importo deve essere maggiore di zero.") + const paid = centesimi(merged.paid_cents ?? 0, 'Importo pagato') + if (paid < 0 || paid > amount) { + throw new HttpError(400, "L'importo già pagato deve stare fra zero e il totale.") + } + const paidDate = paid > 0 ? (data(merged.paid_date, 'Data pagamento') ?? todayLocal()) : null + + const recurrence = merged.recurrence ?? 'none' + if (recurrence !== 'none' && recurrence !== 'monthly') { + throw new HttpError(400, 'Ricorrenza non valida.') + } + if (recurrence !== 'none' && source === 'scadenziario') { + throw new HttpError(400, 'Una fattura dello scadenziario non si ripete: usa una previsione manuale.') + } + const until = recurrence === 'none' ? null : data(merged.recurrence_until, 'Fine ricorrenza') + if (until && until < due) { + throw new HttpError(400, 'La ricorrenza finisce prima di cominciare.') + } + + return { + direction, + source, + category, + description, + counterparty: testo(merged.counterparty), + document_ref: testo(merged.document_ref), + document_date: documentDate, + payment_terms: terms as TreasuryItem['payment_terms'], + payment_days: days, + payment_method: testo(merged.payment_method), + due_date: due, + amount_cents: amount, + paid_cents: recurrence === 'none' ? paid : 0, + paid_date: recurrence === 'none' ? paidDate : null, + recurrence, + recurrence_until: until, + notes: testo(merged.notes) + } +} + +export function createItem(companyUuid: string, input: TreasuryItemInput): TreasuryItem { + getCompany(companyUuid) + const row = normalize(input) + const uuid = newUuid() + const now = nowIso() + getDatabase() + .prepare( + `INSERT INTO treasury_items (uuid, company_uuid, direction, source, category, description, + counterparty, document_ref, document_date, payment_terms, payment_days, payment_method, + due_date, amount_cents, paid_cents, paid_date, recurrence, recurrence_until, notes, + created_at, updated_at, synced, deleted) + VALUES (@uuid, @company_uuid, @direction, @source, @category, @description, + @counterparty, @document_ref, @document_date, @payment_terms, @payment_days, @payment_method, + @due_date, @amount_cents, @paid_cents, @paid_date, @recurrence, @recurrence_until, @notes, + @now, @now, 0, 0)` + ) + .run({ ...row, uuid, company_uuid: companyUuid, now }) + return getItem(companyUuid, uuid) +} + +export function updateItem(companyUuid: string, uuid: string, input: TreasuryItemInput): TreasuryItem { + const current = getItem(companyUuid, uuid) + if (current.source === 'finanziamento') { + throw new HttpError(409, 'Le rate dei finanziamenti si modificano dal modulo Banche.') + } + const row = normalize(input, current) + getDatabase() + .prepare( + `UPDATE treasury_items SET direction = @direction, source = @source, category = @category, + description = @description, counterparty = @counterparty, document_ref = @document_ref, + document_date = @document_date, payment_terms = @payment_terms, payment_days = @payment_days, + payment_method = @payment_method, due_date = @due_date, amount_cents = @amount_cents, + paid_cents = @paid_cents, paid_date = @paid_date, recurrence = @recurrence, + recurrence_until = @recurrence_until, notes = @notes, updated_at = @now, synced = 0 + WHERE uuid = @uuid` + ) + .run({ ...row, uuid, now: nowIso() }) + return getItem(companyUuid, uuid) +} + +/** + * Registra un incasso o un pagamento, anche parziale. Senza importo salda il + * residuo: è il caso più comune, "segna come pagata". + */ +export function registerPayment( + companyUuid: string, + uuid: string, + input: { amount_cents?: number; date?: string } +): TreasuryItem { + const item = getItem(companyUuid, uuid) + if (item.recurrence !== 'none') { + throw new HttpError(400, 'Una previsione ricorrente non si salda: modificala o eliminala.') + } + const residuo = item.amount_cents - item.paid_cents + if (residuo <= 0) throw new HttpError(409, 'Il movimento risulta già saldato.') + const importo = input.amount_cents === undefined ? residuo : centesimi(input.amount_cents, 'Importo') + if (importo <= 0 || importo > residuo) { + throw new HttpError(400, `L'importo deve stare fra 0,01 € e il residuo di ${(residuo / 100).toFixed(2)} €.`) + } + const date = data(input.date, 'Data') ?? todayLocal() + getDatabase() + .prepare( + `UPDATE treasury_items SET paid_cents = paid_cents + ?, paid_date = ?, updated_at = ?, synced = 0 + WHERE uuid = ?` + ) + .run(importo, date, nowIso(), uuid) + return getItem(companyUuid, uuid) +} + +export function deleteItem(companyUuid: string, uuid: string): { uuid: string } { + const item = getItem(companyUuid, uuid) + if (item.source === 'finanziamento') { + throw new HttpError(409, 'Le rate dei finanziamenti si eliminano dal modulo Banche.') + } + getDatabase() + .prepare('UPDATE treasury_items SET deleted = 1, updated_at = ?, synced = 0 WHERE uuid = ?') + .run(nowIso(), uuid) + return { uuid } +} + +// --- impostazioni -------------------------------------------------------------- + +export function getSettings(companyUuid: string): TreasurySettings { + getCompany(companyUuid) + const row = getDatabase() + .prepare( + `SELECT min_liquidity_cents, opening_cash_cents, opening_cash_date + FROM company_treasury_settings WHERE company_uuid = ? AND deleted = 0` + ) + .get(companyUuid) as TreasurySettings | undefined + return row ?? { min_liquidity_cents: null, opening_cash_cents: null, opening_cash_date: null } +} + +export function updateSettings(companyUuid: string, input: Partial): TreasurySettings { + const merged = { ...getSettings(companyUuid), ...input } + + const min = merged.min_liquidity_cents ?? null + if (min !== null && (!Number.isInteger(min) || min < 0)) { + throw new HttpError(400, 'La soglia minima di liquidità deve essere un importo positivo.') + } + const cash = merged.opening_cash_cents ?? null + if (cash !== null && !Number.isInteger(cash)) { + throw new HttpError(400, 'Saldo di cassa non valido.') + } + const cashDate = cash === null ? null : (data(merged.opening_cash_date, 'Data del saldo') ?? todayLocal()) + if (cashDate && cashDate > todayLocal()) { + throw new HttpError(400, 'Il saldo di cassa non può avere una data futura.') + } + + const db = getDatabase() + const now = nowIso() + const exists = db + .prepare('SELECT uuid FROM company_treasury_settings WHERE company_uuid = ? AND deleted = 0') + .get(companyUuid) as { uuid: string } | undefined + + if (exists) { + db.prepare( + `UPDATE company_treasury_settings + SET min_liquidity_cents = ?, opening_cash_cents = ?, opening_cash_date = ?, + updated_at = ?, synced = 0 + WHERE uuid = ?` + ).run(min, cash, cashDate, now, exists.uuid) + } else { + db.prepare( + `INSERT INTO company_treasury_settings (uuid, company_uuid, min_liquidity_cents, + opening_cash_cents, opening_cash_date, created_at, updated_at, synced, deleted) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0)` + ).run(newUuid(), companyUuid, min, cash, cashDate, now, now) + } + return getSettings(companyUuid) +} + +// --- previsione ---------------------------------------------------------------- + +/** Ultimo giorno di un periodo contabile. */ +function periodEnd(year: number, month: number | null): string { + const m = month ?? 12 + const d = month === null ? 31 : periodDays(year, month) + return `${year}-${String(m).padStart(2, '0')}-${String(d).padStart(2, '0')}` +} + +/** + * Il saldo di partenza: il più recente fra quello inserito a mano e le + * liquidità immediate dell'ultimo bilancio a consuntivo. Se non c'è né l'uno né + * l'altro, la previsione parte da zero e lo dice. + */ +export function openingCash(companyUuid: string, today = todayLocal()): OpeningCash { + const settings = getSettings(companyUuid) + + let fromBalance: OpeningCash | null = null + for (const period of listPeriods(companyUuid)) { + if (!period.scenarios?.includes('actual')) continue + const end = periodEnd(period.year, period.month) + if (end > today) continue + const accounts = engineAccounts(companyUuid, period.uuid, 'actual') + if (accounts.length === 0) continue + fromBalance = { + cents: balanceSheet(accounts).liquiditaImmediate, + date: end, + origin: 'bilancio', + label: `liquidità immediate del bilancio di ${period.label}` + } + break + } + + const manual: OpeningCash | null = + settings.opening_cash_cents !== null && settings.opening_cash_date + ? { + cents: settings.opening_cash_cents, + date: settings.opening_cash_date, + origin: 'manuale', + label: `saldo inserito al ${settings.opening_cash_date.split('-').reverse().join('/')}` + } + : null + + if (manual && (!fromBalance || manual.date >= fromBalance.date)) return manual + if (fromBalance) return fromBalance + return { cents: 0, date: today, origin: 'manuale', label: 'nessun saldo disponibile: si parte da zero' } +} + +export function treasuryView(companyUuid: string, today = todayLocal()): TreasuryView { + const items = listItems(companyUuid) + const settings = getSettings(companyUuid) + const opening = openingCash(companyUuid, today) + const result = forecast({ + opening, + items, + today, + minLiquidityCents: settings.min_liquidity_cents + }) + + // Lo storico della liquidità: le liquidità immediate dei mesi a consuntivo. + const storico = series(companyUuid, { scenario: 'actual', limit: 12 }) + const periodi = listPeriods(companyUuid) + const consuntivo = storico.map((p) => { + const period = periodi.find((x) => x.uuid === p.period_uuid)! + return { label: p.label, date: periodEnd(period.year, period.month), liquidita: p.liquidita } + }) + + return { + ...result, + items, + settings, + consuntivo, + aging: receivablesAging(items, today) + } +} + +/** Crediti aperti e scaduti oltre 60 giorni, per le note del circolante. */ +export function agingFor(companyUuid: string, today = todayLocal()) { + return receivablesAging(listItems(companyUuid), today) +} diff --git a/src/main/server/services/working-capital.service.ts b/src/main/server/services/working-capital.service.ts new file mode 100644 index 0000000..dcfd180 --- /dev/null +++ b/src/main/server/services/working-capital.service.ts @@ -0,0 +1,126 @@ +import { + balanceSheet, + DEFAULT_SCHEME, + incomeStatement, + movingAverage, + periodDays, + ratios, + snapshot, + workingCapitalNotes, + type WorkingCapitalSnapshot +} from '@shared/engine' +import type { WorkingCapitalPoint, WorkingCapitalView } from '@shared/analysis' +import type { FiscalPeriod, Scenario } from '@shared/types' +import { engineAccounts, findPeriod, getPeriod, listPeriods } from './analysis.service' +import { agingFor } from './treasury.service' + +/** + * Vista Capitale Circolante — AGENTS.md §10.5. + * + * Tutto quello che mostra è già nel motore: qui si scelgono i periodi da + * confrontare e si mette in fila la storia. + */ + +function snapshotOf( + companyUuid: string, + period: FiscalPeriod, + scenario: Scenario +): WorkingCapitalSnapshot | null { + const accounts = engineAccounts(companyUuid, period.uuid, scenario) + if (accounts.length === 0) return null + const income = incomeStatement(accounts, DEFAULT_SCHEME).aggregates + const balance = balanceSheet(accounts) + const r = ratios({ accounts, income, balance, days: periodDays(period.year, period.month) }) + return snapshot(balance, r) +} + +export function workingCapitalView( + companyUuid: string, + periodUuid: string, + options: { scenario?: Scenario } = {} +): WorkingCapitalView { + const scenario = options.scenario ?? 'actual' + const period = getPeriod(companyUuid, periodUuid) + const periods = listPeriods(companyUuid) + + const current = snapshotOf(companyUuid, period, scenario) ?? { + creditiCommerciali: 0, + magazzino: 0, + altriCrediti: 0, + debitiFornitori: 0, + altriDebitiCorrenti: 0, + capitaleCircolanteNetto: 0, + dso: null, + dio: null, + dpo: null, + ccc: null + } + + // Stesso periodo dell'anno prima, a consuntivo — come nel conto economico. + const annoPrima = findPeriod(periods, period.year - 1, period.month) + const previousYearSnapshot = annoPrima ? snapshotOf(companyUuid, annoPrima, 'actual') : null + + // Fine dell'anno precedente: dicembre se c'è, altrimenti il bilancio annuale. + const fineAnno = + findPeriod(periods, period.year - 1, 12) ?? findPeriod(periods, period.year - 1, null) + const previousYearEndSnapshot = fineAnno ? snapshotOf(companyUuid, fineAnno, 'actual') : null + + // Storia: solo mesi, fino al periodo scelto, al massimo 24. + const mesi = periods + .filter( + (p) => + p.period_type === 'month' && + p.scenarios?.includes(scenario) && + p.year * 100 + (p.month ?? 0) <= period.year * 100 + (period.month ?? 12) + ) + .slice(0, 24) + .reverse() + + const punti = mesi + .map((p) => ({ p, s: snapshotOf(companyUuid, p, scenario) })) + .filter((x): x is { p: FiscalPeriod; s: WorkingCapitalSnapshot } => x.s !== null) + // La media mobile si calcola su mesi consecutivi: se ne manca uno, i mesi + // che lo scavalcherebbero restano senza media invece di mescolare periodi + // lontani. + const indice = (p: FiscalPeriod): number => p.year * 12 + (p.month ?? 1) - 1 + const primo = punti.length > 0 ? indice(punti[0]!.p) : 0 + const continua: (number | null)[] = [] + for (const { p, s } of punti) continua[indice(p) - primo] = s.ccc + const medieContinue = movingAverage(Array.from(continua, (v) => v ?? null), 3) + const medie = punti.map(({ p }) => medieContinue[indice(p) - primo] ?? null) + + const series: WorkingCapitalPoint[] = punti.map(({ p, s }, i) => ({ + period_uuid: p.uuid, + label: p.label, + dso: s.dso, + dio: s.dio, + dpo: s.dpo, + ccc: s.ccc, + cccMedia: medie[i] ?? null, + ccn: s.capitaleCircolanteNetto + })) + + const aging = agingFor(companyUuid) + + return { + period, + scenario, + current, + previousYear: + annoPrima && previousYearSnapshot + ? { label: annoPrima.label, snapshot: previousYearSnapshot } + : null, + previousYearEnd: + fineAnno && previousYearEndSnapshot + ? { label: fineAnno.label, snapshot: previousYearEndSnapshot } + : null, + series, + notes: workingCapitalNotes({ + current, + previousYear: previousYearSnapshot, + receivablesOpenCents: aging.openCents, + receivablesOverdue60Cents: aging.overdueCents + }), + aging + } +} diff --git a/src/renderer/src/components/Sidebar.tsx b/src/renderer/src/components/Sidebar.tsx index 6c89e6b..ceacb03 100644 --- a/src/renderer/src/components/Sidebar.tsx +++ b/src/renderer/src/components/Sidebar.tsx @@ -14,6 +14,8 @@ export type Vista = | 'panoramica' | 'conto-economico' | 'stato-patrimoniale' + | 'capitale-circolante' + | 'tesoreria' | 'import' interface Voce { @@ -50,6 +52,16 @@ const VISTE: Voce[] = [ label: 'Stato Patrimoniale', icona: icona('M3 9.5 12 4l9 5.5M5 10v9M19 10v9M9 10v9M15 10v9M3 20h18') }, + { + id: 'capitale-circolante', + label: 'Capitale Circolante', + icona: icona('M4 12a8 8 0 0 1 13.7-5.6M20 12a8 8 0 0 1-13.7 5.6M18 3v3.5h-3.5M6 21v-3.5h3.5') + }, + { + id: 'tesoreria', + label: 'Tesoreria / Cash Flow', + icona: icona('M3 7h18v12H3zM3 11h18M7 15h3M16 4H6') + }, { id: 'import', label: 'Import dati', @@ -58,8 +70,6 @@ const VISTE: Voce[] = [ ] const IN_ARRIVO: { label: string; fase: string }[] = [ - { label: 'Capitale Circolante', fase: 'Fase 5' }, - { label: 'Tesoreria / Cash Flow', fase: 'Fase 5' }, { label: 'Banche e Finanziamenti', fase: 'Fase 6' }, { label: 'Analisi & Simulazioni', fase: 'Fase 7' } ] diff --git a/src/renderer/src/components/charts.tsx b/src/renderer/src/components/charts.tsx index 6e4408a..1c34357 100644 --- a/src/renderer/src/components/charts.tsx +++ b/src/renderer/src/components/charts.tsx @@ -8,12 +8,13 @@ import { LineChart, Pie, PieChart, + ReferenceLine, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts' -import { euro, percent } from '../lib/format' +import { days, euro, percent } from '../lib/format' /** * I grafici delle schermate di analisi — AGENTS.md §10.2 e §10.3. @@ -32,7 +33,12 @@ export const COLORI = { ebitda: '#a78bfa', utile: '#60a5fa', liquidita: '#34d399', - previsione: '#fbbf24' + previsione: '#fbbf24', + dso: '#60a5fa', + dio: '#fbbf24', + dpo: '#f472b6', + ccc: '#a78bfa', + soglia: '#f87171' } as const /** Palette della composizione costi: tinte distinte, leggibili sul fondo scuro. */ @@ -309,3 +315,205 @@ export function BarraBreakEven({ ) } + +function TooltipGiorni({ active, payload, label }: TooltipProps): React.JSX.Element | null { + if (!active || !payload?.length) return null + return ( + +

{label}

+ {payload.map((voce) => ( +

+ + {voce.name} + + {voce.value === null || voce.value === undefined ? '—' : days(voce.value)} + +

+ ))} +
+ ) +} + +export interface PuntoCiclo { + label: string + dso: number | null + dio: number | null + dpo: number | null + ccc: number | null + cccMedia: number | null +} + +/** DSO, DIO, DPO e CCC sovrapposti — §10.5. */ +export function SerieCiclo({ dati }: { dati: PuntoCiclo[] }): React.JSX.Element { + return ( + + + + + Math.floor(min) - 1, (max: number) => Math.ceil(max) + 1]} + /> + } /> + + + + + + + + ) +} + +/** CCC con media mobile a 3 mesi — §10.5. */ +export function SerieCcc({ dati }: { dati: PuntoCiclo[] }): React.JSX.Element { + return ( + + + + + Math.floor(min) - 1, (max: number) => Math.ceil(max) + 1]} + /> + } /> + + + + + + ) +} + +/** Mini-grafico da tabella: solo la forma dell'andamento. */ +export function Sparkline({ + valori, + colore +}: { + valori: (number | null)[] + colore: string +}): React.JSX.Element { + const dati = valori.map((v, i) => ({ i, v })) + return ( +
+ + + + + + +
+ ) +} + +export interface PuntoTesoreria { + label: string + consuntivo: number | null + previsione: number | null +} + +/** + * Liquidità: storico a consuntivo e previsione, con la soglia minima e il + * marcatore di oggi — §10.2 e §10.6. I due tratti si toccano sul punto "Oggi". + */ +export function GraficoTesoreria({ + dati, + soglia, + altezza = 240 +}: { + dati: PuntoTesoreria[] + soglia: number | null + altezza?: number +}): React.JSX.Element { + return ( + + + + + + } /> + + {soglia !== null && ( + + )} + + + + + + ) +} diff --git a/src/renderer/src/index.css b/src/renderer/src/index.css index 8f5fd4c..168b49a 100644 --- a/src/renderer/src/index.css +++ b/src/renderer/src/index.css @@ -12,13 +12,16 @@ --color-ink-800: #141b29; --color-ink-700: #1d2636; --color-ink-600: #2a3548; + --color-ink-500: #4b5a70; --color-ink-400: #64748b; --color-ink-300: #8b99ad; + --color-ink-200: #c3cedd; --color-ink-100: #e6edf7; --color-brand-500: #3b82f6; --color-brand-400: #60a5fa; --color-brand-300: #3ddbff; + --color-brand-200: #9ee9ff; --color-positive: #34d399; --color-negative: #f87171; diff --git a/src/renderer/src/lib/api.ts b/src/renderer/src/lib/api.ts index cb4ec3f..2e42a42 100644 --- a/src/renderer/src/lib/api.ts +++ b/src/renderer/src/lib/api.ts @@ -68,5 +68,6 @@ async function request(method: string, path: string, body?: unknown): Promise export const api = { get: (path: string) => request('GET', path), post: (path: string, body?: unknown) => request('POST', path, body ?? {}), + put: (path: string, body?: unknown) => request('PUT', path, body ?? {}), delete: (path: string) => request('DELETE', path) } diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index 6d34a78..eaf83d2 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -48,3 +48,37 @@ export function tone(value: number | null, soglia: number, higherIsBetter = true const buono = higherIsBetter ? value >= soglia : value <= soglia return buono ? 'text-positive' : 'text-negative' } + +/** + * Da quello che si scrive in un campo ("1.234,56", "1234.5", "1 200") a + * centesimi interi. null se non è un numero. + */ +export function parseEuro(text: string): number | null { + const pulito = text.replace(/[\s€]/g, '') + if (!pulito) return null + // All'italiana se la virgola fa da decimale, o se il punto separa le migliaia. + const italiano = /,\d{1,2}$/.test(pulito) || /^-?\d{1,3}(\.\d{3})+(,\d*)?$/.test(pulito) + const normalizzato = italiano ? pulito.replace(/\./g, '').replace(',', '.') : pulito.replace(/,/g, '') + const valore = Number(normalizzato) + return Number.isFinite(valore) ? Math.round(valore * 100) : null +} + +/** Centesimi → testo modificabile in un campo, senza separatori di migliaia. */ +export function euroInput(cents: number | null | undefined): string { + if (cents === null || cents === undefined) return '' + return (cents / 100).toFixed(2).replace('.', ',').replace(/,00$/, '') +} + +/** "2026-09-16" → "16/09/2026". */ +export function dataIt(iso: string | null | undefined): string { + if (!iso) return '—' + const [y, m, d] = iso.slice(0, 10).split('-') + return `${d}/${m}/${y}` +} + +/** "2026-09-16" → "16 set". */ +export function dataBreve(iso: string): string { + const mesi = ['gen', 'feb', 'mar', 'apr', 'mag', 'giu', 'lug', 'ago', 'set', 'ott', 'nov', 'dic'] + const [, m, d] = iso.slice(0, 10).split('-') + return `${Number(d)} ${mesi[Number(m) - 1]}` +} diff --git a/src/renderer/src/pages/CompanyPage.tsx b/src/renderer/src/pages/CompanyPage.tsx index cc5d727..d3ee6c1 100644 --- a/src/renderer/src/pages/CompanyPage.tsx +++ b/src/renderer/src/pages/CompanyPage.tsx @@ -1,5 +1,10 @@ import { useCallback, useEffect, useState } from 'react' -import type { Analysis, SeriesPoint } from '@shared/analysis' +import type { + Analysis, + SeriesPoint, + 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 { api } from '../lib/api' @@ -9,6 +14,8 @@ import { BalanceSheetView } from './business/BalanceSheetView' import { ImportPanel, TemplateButton } from './business/ImportPanel' import { IncomeStatementView } from './business/IncomeStatementView' import { OverviewView } from './business/OverviewView' +import { TreasuryView } from './business/TreasuryView' +import { WorkingCapitalView } from './business/WorkingCapitalView' /** * Area di lavoro di una singola azienda — AGENTS.md §10.2-§10.9. @@ -41,6 +48,8 @@ export function CompanyPage({ const [scheme, setScheme] = useState(DEFAULT_SCHEME) const [analysis, setAnalysis] = useState(null) const [serie, setSerie] = useState([]) + const [tesoreria, setTesoreria] = useState(null) + const [circolante, setCircolante] = useState(null) const [error, setError] = useState(null) const [loading, setLoading] = useState(true) @@ -97,7 +106,42 @@ export function CompanyPage({ } }, [company.uuid, periodUuid, scenario, scheme]) + // La tesoreria guarda avanti da oggi: non dipende dal periodo scelto. + const caricaTesoreria = useCallback(async () => { + try { + setTesoreria(await api.get(`/api/companies/${company.uuid}/treasury`)) + } catch (err) { + setError(err instanceof Error ? err.message : 'Tesoreria non disponibile.') + } + }, [company.uuid]) + + useEffect(() => { + if (vista === 'tesoreria' || vista === 'panoramica') caricaTesoreria() + }, [vista, caricaTesoreria]) + + useEffect(() => { + if (vista !== 'capitale-circolante' || !periodUuid) return + let annullato = false + api + .get( + `/api/companies/${company.uuid}/periods/${periodUuid}/working-capital?scenario=${scenario}` + ) + .then((result) => { + if (!annullato) setCircolante(result) + }) + .catch((err) => { + if (!annullato) { + setError(err instanceof Error ? err.message : 'Capitale circolante non disponibile.') + } + }) + return () => { + annullato = true + } + }, [company.uuid, vista, periodUuid, scenario]) + const conDati = analysis !== null && analysis.accountCount > 0 + // Import e tesoreria non dipendono dal periodo scelto. + const senzaPeriodo = vista === 'import' || vista === 'tesoreria' return (
@@ -111,7 +155,7 @@ export function CompanyPage({

- {periods.length > 0 && vista !== 'import' && ( + {periods.length > 0 && !senzaPeriodo && (
Periodo { + setDirection(e.target.value as 'in' | 'out') + setCategory('') + }} + > + + + + + + + + + +
+ + setDescription(e.target.value)} + placeholder={scadenziario ? 'es. Fornitura farine' : 'es. Stipendi del mese'} + autoFocus + /> + +
+ + + setCounterparty(e.target.value)} /> + + + + setAmount(e.target.value)} + inputMode="decimal" + placeholder="0,00" + /> + + + {scadenziario && ( + <> + + setDocumentRef(e.target.value)} placeholder="es. FT 2026/041" /> + + + { + setDocumentDate(e.target.value) + ricalcola(e.target.value, terms, days) + }} + /> + + + + + + + + + )} + + + setDue(e.target.value)} /> + + + + + + + {!scadenziario && ( + <> + + {monthly && ( + + setUntil(e.target.value)} /> + + )} + + )} + +
+ + setNotes(e.target.value)} /> + +
+
+