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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/main/db/migrations/002_financial_model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import type { Database } from 'better-sqlite3-multiple-ciphers'
/** §1 — i cinque TIPI di conto. */
const ACCOUNT_TYPES = ["RICAVO", "COSTO", "ATTIVITA'", "ATTIVITA' NEGATIVO", "PASSIVITA'"]

interface SectionSeed {
export interface SectionSeed {
code: string
label: string
/** Prospetto di destinazione: conto economico o stato patrimoniale. */
Expand All @@ -48,7 +48,7 @@ interface SectionSeed {
detail_tags: string[]
}

const SECTIONS: SectionSeed[] = [
export const SECTIONS: SectionSeed[] = [
// --- §2.1 Ricavi ---------------------------------------------------------
{
code: 'ricavi_operativi',
Expand Down
23 changes: 23 additions & 0 deletions src/main/import/chart-of-accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,29 @@ const DETAIL_TAG_BY_HEADER: Record<string, string> = {
'debiti verso enti previdenziali': 'Debiti v/Enti Previdenziali'
}

/**
* Intestazione con cui il modello scaricabile scrive ogni sotto-classificazione.
* Ognuna deve essere una chiave di DETAIL_TAG_BY_HEADER, una volta normalizzata:
* il modello si deve rileggere senza perdite (lo verifica un test).
*/
export const DETAIL_TAG_TEMPLATE_HEADERS: Record<string, string> = {
'Crediti Commerciali': 'Crediti Commerciali',
'Crediti Diversi': 'Crediti Diversi',
'Erario c/IVA': 'Erario c/IVA',
'Utile a nuovo': 'Utile a nuovo',
Utile: 'Utile',
'Fondo TFR': 'Fondo TFR',
'Debiti Diversi': 'Debiti Diversi',
'Debiti v/Fornitori (costi variabili)': 'Debiti verso Fornitori costi variabili',
'Debiti v/Fornitori (costi fissi)': 'Debiti verso Fornitori costi fissi',
'Debiti v/Enti Previdenziali': 'Debiti v/Enti Previdenziali'
}

/** Riconosce una sotto-classificazione dall'intestazione di colonna. */
export function detailTagFromHeader(header: string): string | null {
return DETAIL_TAG_BY_HEADER[norm(header)] ?? null
}

/** Intestazioni delle colonne che servono, con le varianti già incontrate. */
const COLUMN_ALIASES: Record<string, string[]> = {
type: ['tipo'],
Expand Down
98 changes: 98 additions & 0 deletions src/main/import/template.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import ExcelJS from 'exceljs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { SECTIONS } from '../db/migrations/002_financial_model'
import { DETAIL_TAG_TEMPLATE_HEADERS, detailTagFromHeader, readPreview } from './chart-of-accounts'
import { buildTemplate, type TemplateAccount } from './template'

/**
* Il modello scaricabile ha senso solo se l'import lo rilegge senza perdite:
* tutte le sezioni riconosciute, nessuna riga da sistemare, sotto-classificazioni
* intatte.
*/

const ACCOUNTS: TemplateAccount[] = [
{ code: '60.01', name: 'Vendite', section_code: 'ricavi_operativi', account_type: 'RICAVO', detail_tag: null, direct_cost_pct: null },
{ code: '60.20', name: 'Rimanenze finali CE', section_code: 'rimanenze_finali_ricavo', account_type: 'RICAVO', detail_tag: null, direct_cost_pct: null },
{ code: '70.40', name: 'Stipendi', section_code: 'costi_personale', account_type: 'COSTO', detail_tag: null, direct_cost_pct: 60 },
{ code: '11.01', name: 'Impianti', section_code: 'immobilizzazioni_materiali', account_type: "ATTIVITA'", detail_tag: null, direct_cost_pct: null },
{ code: '11.91', name: 'Fondo ammortamento impianti', section_code: 'immobilizzazioni_materiali', account_type: "ATTIVITA' NEGATIVO", detail_tag: null, direct_cost_pct: null },
{ code: '20.01', name: 'Magazzino', section_code: 'rimanenze_finali_magazzino', account_type: "ATTIVITA'", detail_tag: null, direct_cost_pct: null },
{ code: '21.01', name: 'Clienti', section_code: 'liquidita_differite', account_type: "ATTIVITA'", detail_tag: 'Crediti Commerciali', direct_cost_pct: null },
{ code: '40.01', name: 'Fornitori merci', section_code: 'debiti_breve', account_type: "PASSIVITA'", detail_tag: 'Debiti v/Fornitori (costi variabili)', direct_cost_pct: null },
{ code: '32.01', name: 'TFR', section_code: 'debiti_medio_lungo', account_type: "PASSIVITA'", detail_tag: 'Fondo TFR', direct_cost_pct: null }
]

let dir: string
let path: string

beforeAll(async () => {
dir = await mkdtemp(join(tmpdir(), 'daprodfinanza-template-'))
path = join(dir, 'modello.xlsx')
await writeFile(path, await buildTemplate(SECTIONS, ACCOUNTS, { companyName: 'Prova' }))
})

afterAll(async () => {
await rm(dir, { recursive: true, force: true })
})

describe('modello Excel scaricabile', () => {
it('ogni sotto-classificazione ha un\'intestazione che l\'import riconosce', () => {
for (const [tag, header] of Object.entries(DETAIL_TAG_TEMPLATE_HEADERS)) {
expect(detailTagFromHeader(header)).toBe(tag)
}
const allDetailTags = new Set(SECTIONS.flatMap((s) => s.detail_tags))
for (const tag of allDetailTags) expect(DETAIL_TAG_TEMPLATE_HEADERS[tag]).toBeDefined()
})

it('si rilegge con tutte le sezioni e nessuna riga da sistemare', async () => {
const preview = await readPreview(path)
expect(preview.sections).toHaveLength(SECTIONS.length)
expect(preview.sections.every((s) => s.section_code !== null)).toBe(true)
expect(preview.unmapped).toEqual([])
expect(preview.duplicates).toEqual([])
expect(preview.valueColumn).toBe('VALORE T')
expect(preview.templateRows).toBe(SECTIONS.length * 3)
})

it('riporta i conti esistenti con sezione, tipo e sotto-classificazione', async () => {
const preview = await readPreview(path)
expect(preview.accounts).toHaveLength(ACCOUNTS.length)
for (const expected of ACCOUNTS) {
const found = preview.accounts.find((a) => a.code === expected.code)
expect(found, expected.code).toMatchObject({
section_code: expected.section_code,
account_type: expected.account_type,
detail_tag: expected.detail_tag,
amount_cents: null
})
}
expect(preview.accounts.find((a) => a.code === '70.40')?.direct_cost_pct).toBe(60)
})

it('una volta compilato, i valori arrivano all\'import', async () => {
const workbook = new ExcelJS.Workbook()
await workbook.xlsx.readFile(path)
const sheet = workbook.getWorksheet('PIANO DEI CONTI')!
let aggiunto = false
sheet.eachRow((row) => {
if (row.getCell(2).value === '60.01') row.getCell(4).value = 12345.67
// Un conto nuovo su una riga vuota della sezione.
if (row.getCell(1).value === 'COSTO' && !row.getCell(2).value && !aggiunto) {
row.getCell(2).value = '70.99'
row.getCell(3).value = 'Consulenze'
row.getCell(4).value = 800
aggiunto = true
}
})
const filled = join(dir, 'compilato.xlsx')
await workbook.xlsx.writeFile(filled)

const preview = await readPreview(filled)
expect(preview.accounts.find((a) => a.code === '60.01')?.amount_cents).toBe(1_234_567)
expect(preview.accounts.find((a) => a.code === '70.99')?.amount_cents).toBe(80_000)
expect(preview.unmapped).toEqual([])
})
})
169 changes: 169 additions & 0 deletions src/main/import/template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import ExcelJS from 'exceljs'
import type { AccountType } from '@shared/types'
import { DETAIL_TAG_TEMPLATE_HEADERS } from './chart-of-accounts'

/**
* Modello Excel scaricabile — la porta d'ingresso per chi non ha già il file
* del consulente.
*
* Produce esattamente la forma che `readPreview()` sa leggere: stesse
* intestazioni, sezioni riconosciute dal nome, sotto-classificazioni a "X".
* Se l'azienda ha già un piano dei conti, lo riporta: il consulente deve solo
* scrivere i valori del periodo, non ricostruire la struttura ogni mese.
*/

export interface TemplateSection {
code: string
label: string
statement: 'CE' | 'SP'
account_type: string
detail_tags: string[]
}

export interface TemplateAccount {
code: string
name: string
section_code: string
account_type: AccountType
detail_tag: string | null
direct_cost_pct: number | null
}

/** Righe vuote per sezione, col TIPO già scritto, dove aggiungere conti. */
const EMPTY_ROWS_PER_SECTION = 3

const TYPES: AccountType[] = ['RICAVO', 'COSTO', "ATTIVITA'", "ATTIVITA' NEGATIVO", "PASSIVITA'"]

/**
* Nel file del consulente "Rimanenze Finali" compare due volte, una nel conto
* economico e una nell'attivo, con la stessa etichetta: la distingue il TIPO.
* Le etichette del database portano la precisazione fra parentesi, che il
* file non ha.
*/
function fileLabel(label: string): string {
return label.replace(/\s*\(.*\)\s*$/, '')
}

export async function buildTemplate(
sections: TemplateSection[],
accounts: TemplateAccount[],
options: { companyName?: string } = {}
): Promise<Buffer> {
const workbook = new ExcelJS.Workbook()
workbook.creator = 'DaProdFinanza'
workbook.created = new Date()

const sheet = workbook.addWorksheet('PIANO DEI CONTI', {
views: [{ state: 'frozen', ySplit: 1, xSplit: 3 }]
})

const detailTags = Object.keys(DETAIL_TAG_TEMPLATE_HEADERS)
const headers = [
'TIPO',
'Codice Conto Mastro',
'Descrizione',
'VALORE T',
'% COSTO DIRETTO',
...detailTags.map((tag) => DETAIL_TAG_TEMPLATE_HEADERS[tag]!)
]
const valueCol = 4
const pctCol = 5
const firstDetailCol = 6

sheet.columns = headers.map((_, i) => ({
width: i === 2 ? 42 : i === 1 ? 20 : i < 5 ? 16 : 14
}))

const header = sheet.addRow(headers)
header.font = { bold: true, color: { argb: 'FFFFFFFF' } }
header.alignment = { vertical: 'middle', wrapText: true }
header.height = 32
header.eachCell((cell) => {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF1E293B' } }
})

const typeList = `"${TYPES.join(',')}"`

const addAccountRow = (
type: string,
values: { code?: string; name?: string; pct?: number | null; tag?: string | null },
allowedTags: string[]
): void => {
const row = sheet.addRow([type, values.code ?? null, values.name ?? null])
row.getCell(valueCol).numFmt = '#,##0.00'
if (values.pct !== null && values.pct !== undefined) row.getCell(pctCol).value = values.pct

row.getCell(1).dataValidation = {
type: 'list',
allowBlank: false,
formulae: [typeList],
showErrorMessage: true,
errorTitle: 'TIPO non valido',
error: 'Scegli uno dei cinque tipi di conto.'
}

// Le colonne di sotto-classificazione hanno senso solo dove la sezione le
// prevede: le altre si lasciano grigie, per non invitare a marcarle.
detailTags.forEach((tag, i) => {
const cell = row.getCell(firstDetailCol + i)
if (allowedTags.includes(tag)) {
cell.alignment = { horizontal: 'center' }
cell.dataValidation = { type: 'list', allowBlank: true, formulae: ['"X"'] }
if (values.tag === tag) cell.value = 'X'
} else {
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE2E8F0' } }
}
})
}

for (const section of sections) {
const titleRow = sheet.addRow([null, null, fileLabel(section.label)])
titleRow.font = { bold: true }
titleRow.getCell(3).fill = {
type: 'pattern',
pattern: 'solid',
fgColor: { argb: section.statement === 'CE' ? 'FFDBEAFE' : 'FFDCFCE7' }
}

for (const account of accounts.filter((a) => a.section_code === section.code)) {
addAccountRow(
account.account_type,
{
code: account.code,
name: account.name,
pct: account.direct_cost_pct,
tag: account.detail_tag
},
section.detail_tags
)
}
for (let i = 0; i < EMPTY_ROWS_PER_SECTION; i++) {
addAccountRow(section.account_type, {}, section.detail_tags)
}
}

const guide = workbook.addWorksheet('ISTRUZIONI')
guide.getColumn(1).width = 110
const lines = [
`Modello del piano dei conti${options.companyName ? ` — ${options.companyName}` : ''}`,
'',
'Come si compila',
'1. Nel foglio PIANO DEI CONTI ogni riga è un conto. Le righe in grassetto sono le sezioni: non vanno modificate.',
'2. Per aggiungere un conto usa le righe vuote sotto la sezione giusta: scrivi codice e descrizione.',
'3. Nella colonna VALORE T scrivi il saldo del periodo. Il periodo (mese o anno) e lo scenario si scelgono al momento dell\'import.',
"4. I costi si scrivono positivi. I fondi (ammortamento, svalutazione) vanno nella loro sezione con TIPO \"ATTIVITA' NEGATIVO\".",
'5. Nelle colonne a destra marca con una X la sotto-classificazione del conto, dove la sezione lo prevede (celle bianche).',
' Servono a distinguere crediti commerciali, debiti verso fornitori, TFR: senza, giorni di incasso e pagamento e posizione finanziaria non si calcolano.',
'6. La colonna % COSTO DIRETTO è facoltativa: se vuota vale quella della sezione.',
'',
'Le righe vuote vengono ignorate. Prima di scrivere qualsiasi cosa, DaProdFinanza mostra un riepilogo di quello che ha letto.',
'Lo stesso file si può riusare: basta cambiare i valori e importarlo su un altro periodo.'
]
lines.forEach((text, i) => {
const row = guide.addRow([text])
if (i === 0) row.font = { bold: true, size: 14 }
if (text === 'Come si compila') row.font = { bold: true }
})

return Buffer.from(await workbook.xlsx.writeBuffer())
}
16 changes: 16 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,22 @@ function registerIpc(): void {
})
return result.canceled ? null : (result.filePaths[0] ?? null)
})

// "Salva con nome" per il modello Excel da compilare.
ipcMain.handle('dialog:save-excel', async (_event, suggestedName: string) => {
const result = await dialog.showSaveDialog({
title: 'Salva il modello Excel',
defaultPath: join(app.getPath('documents'), suggestedName),
filters: [{ name: 'Cartella di lavoro Excel', extensions: ['xlsx'] }]
})
return result.canceled ? null : (result.filePath ?? null)
})

// Apre un file appena creato con il programma predefinito (Excel).
ipcMain.handle('shell:open-file', async (_event, path: string) => {
if (typeof path !== 'string' || !path.toLowerCase().endsWith('.xlsx')) return
await shell.openPath(path)
})
}

app.whenReady().then(async () => {
Expand Down
13 changes: 12 additions & 1 deletion src/main/server/routes/analysis.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { Router, type Request } from 'express'
import type { Scheme } from '@shared/engine'
import type { Scenario } from '@shared/types'
import { analyse, listPeriods, series } from '../services/analysis.service'
import { applyChartOfAccounts, previewChartOfAccounts } from '../services/import.service'
import {
applyChartOfAccounts,
previewChartOfAccounts,
writeChartOfAccountsTemplate
} from '../services/import.service'
import { requireAuth, requireRole } from '../middleware/auth'
import { HttpError } from '../http-error'

Expand Down Expand Up @@ -71,3 +75,10 @@ analysisRouter.post('/import/chart-of-accounts', requireRole('consultant'), asyn
})
)
})

/** Modello Excel da compilare, salvato dove il consulente ha scelto. */
analysisRouter.post('/import/chart-of-accounts/template', requireRole('consultant'), async (req, res) => {
const { filePath } = req.body ?? {}
if (!filePath) throw new HttpError(400, 'Manca il percorso dove salvare il modello.')
res.status(201).json(await writeChartOfAccountsTemplate(param(req, 'uuid'), filePath))
})
Loading