diff --git a/modules/note_spese/actions.php b/modules/note_spese/actions.php
new file mode 100644
index 000000000..ead640a2d
--- /dev/null
+++ b/modules/note_spese/actions.php
@@ -0,0 +1,488 @@
+ 0;
+}
+
+function noteSpeseIsOutOfScopeCorporateExpense($value)
+{
+ $value = noteSpeseNormalizeText($value);
+ $keywords = [
+ 'f24', 'tributo', 'tributi', 'inps', 'inail', 'affitto', 'locazione',
+ 'canone locazione', 'assicurazione', 'assicurazioni', 'polizza', 'polizze',
+ 'spese bancarie', 'spesa bancaria', 'commissione bancaria', 'commissioni bancarie',
+ 'stipendio', 'stipendi', 'cedolino', 'cedolini', 'busta paga',
+ 'compenso amministratore', 'compenso amm',
+ ];
+
+ foreach ($keywords as $keyword) {
+ if (noteSpeseContains($value, $keyword)) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+function noteSpeseGetAllowedImportCategory($dbo, $value, $explicitCategory = false)
+{
+ $allowed = ['carburante', 'pedaggio', 'parcheggio', 'vitto', 'alloggio', 'trasporto', 'materiale_consumo', 'altro'];
+
+ // Se l'utente ha indicato esplicitamente una Tipologia esistente nel foglio,
+ // rispetta anche le Tipologie custom attive. Il controllo testuale sui costi
+ // aziendali si applica solo alla classificazione automatica: una scelta
+ // esplicita dell'utente non deve essere reinterpretata dal classificatore.
+ if ($explicitCategory) {
+ $value = trim((string) $value);
+ $lang = (int) Models\Locale::getDefault()->id;
+ if ($value !== '') {
+ $exact = $dbo->fetchOne(
+ 'SELECT t.`id`, t.`codice`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($lang).' '
+ .'WHERE t.`enabled` = 1 AND ('
+ .'LOWER(t.`codice`) = LOWER('.prepare($value).') OR '
+ .'LOWER(t.`descrizione`) = LOWER('.prepare($value).') OR '
+ .'LOWER(l.`title`) = LOWER('.prepare($value).')) LIMIT 1'
+ );
+ if (!empty($exact)) {
+ // Una Tipologia indicata esplicitamente dall'utente viene rispettata
+ // se esiste ed è attiva, incluse le Tipologie custom. Le tipologie
+ // aziendali fuori perimetro sono disattivate e quindi non entrano qui.
+ return $exact;
+ }
+ }
+ }
+
+ if (noteSpeseIsOutOfScopeCorporateExpense($value)) {
+ return null;
+ }
+
+ $category = noteSpeseGetCategory($dbo, $value);
+ if (empty($category)) {
+ return null;
+ }
+
+ return in_array((string) ($category['codice'] ?? ''), $allowed, true) ? $category : null;
+}
+
+switch (post('op')) {
+ case 'inline_update':
+ Permissions::check('rw');
+
+ header('Content-Type: application/json; charset=UTF-8');
+
+ $record_id = (int) $id_record;
+ $field = trim((string) post('field'));
+ $value = post('value');
+ $allowed_fields = ['data', 'descrizione', 'controparte', 'importo'];
+
+ if ($record_id <= 0 || !in_array($field, $allowed_fields, true)) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Modifica rapida non valida.')]);
+ break;
+ }
+
+ $current = $dbo->fetchOne('SELECT `id`, `data`, `descrizione`, `controparte`, `importo`, `id_stato` FROM `co_note_spese` WHERE `id` = '.prepare($record_id).' LIMIT 1');
+ if (empty($current)) {
+ http_response_code(404);
+ echo json_encode(['success' => false, 'message' => tr('Nota spesa non trovata.')]);
+ break;
+ }
+
+ $update = [];
+ $outside_period = false;
+
+ if ($field === 'data') {
+ $parsed = noteSpeseParseDate($value);
+ if (empty($parsed)) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Data non valida.')]);
+ break;
+ }
+
+ $update['data'] = $parsed;
+ $period_start = $_SESSION['period_start'] ?? date('Y-01-01');
+ $period_end = $_SESSION['period_end'] ?? date('Y-12-31');
+ $outside_period = !noteSpeseIsDateInPeriod($parsed, $period_start, $period_end);
+ } elseif ($field === 'importo') {
+ $parsed = noteSpeseParseAmount($value);
+ if ($parsed === null || $parsed <= 0) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Importo non valido.')]);
+ break;
+ }
+
+ $update['importo'] = number_format($parsed, 2, '.', '');
+ } elseif ($field === 'descrizione') {
+ $parsed = trim(strip_tags((string) $value));
+ if ($parsed === '' || noteSpeseStringLength($parsed) > 255) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Descrizione non valida.')]);
+ break;
+ }
+
+ $update['descrizione'] = $parsed;
+ } elseif ($field === 'controparte') {
+ $parsed = trim(strip_tags((string) $value));
+ if (noteSpeseStringLength($parsed) > 255) {
+ http_response_code(422);
+ echo json_encode(['success' => false, 'message' => tr('Controparte non valida.')]);
+ break;
+ }
+
+ $update['controparte'] = $parsed !== '' ? $parsed : null;
+ }
+
+ $changed = false;
+ if (!empty($update)) {
+ $new_value = reset($update);
+ $current_value = $current[$field] ?? null;
+
+ if ($field === 'importo') {
+ $changed = number_format((float) $current_value, 2, '.', '') !== number_format((float) $new_value, 2, '.', '');
+ } elseif ($field === 'controparte') {
+ $changed = trim((string) $current_value) !== trim((string) ($new_value ?? ''));
+ } else {
+ $changed = (string) $current_value !== (string) $new_value;
+ }
+ }
+
+ $requires_review = false;
+ if ($changed) {
+ $id_stato_confermato = noteSpeseGetStatusId($dbo, 'confermato');
+ $id_stato_verifica = noteSpeseGetStatusId($dbo, 'da_verificare');
+
+ if (!empty($id_stato_confermato) && !empty($id_stato_verifica) && (int) $current['id_stato'] === (int) $id_stato_confermato) {
+ $update['id_stato'] = $id_stato_verifica;
+ $requires_review = true;
+ }
+
+ $dbo->update('co_note_spese', $update, ['id' => $record_id]);
+ }
+
+ echo json_encode([
+ 'success' => true,
+ 'outside_period' => $outside_period,
+ 'requires_review' => $requires_review,
+ 'message' => $requires_review
+ ? tr('Nota spesa modificata e riportata Da verificare.')
+ : tr('Nota spesa aggiornata.'),
+ ]);
+ break;
+
+ case 'add':
+ Permissions::check('rw');
+
+ $data = noteSpeseParseDate(post('data'));
+ $id_tipologia = (int) post('id_tipologia');
+ $descrizione = trim(strip_tags((string) post('descrizione')));
+ $importo = noteSpeseParseAmount(post('importo'));
+ $controparte = trim(strip_tags((string) post('controparte')));
+ $id_operatore = (int) post('id_operatore');
+ $note = trim((string) post('note'));
+ $id_stato_verifica = noteSpeseGetStatusId($dbo, 'da_verificare');
+ $valid_category = $dbo->fetchOne('SELECT `id` FROM `co_note_spese_tipologie` WHERE `id` = '.prepare($id_tipologia).' AND `enabled` = 1 LIMIT 1');
+
+ if (!noteSpeseValidateBaseData($data, $id_tipologia, $descrizione, $importo, $controparte) || empty($id_stato_verifica) || empty($valid_category) || $id_operatore <= 0 || !noteSpeseOperatorExists($dbo, $id_operatore)) {
+ flash()->error(tr('Compilare correttamente data, tipologia, operatore, descrizione e importo.'));
+ break;
+ }
+
+ $duplicate = noteSpeseFindDuplicate($dbo, $data, $importo, $descrizione, $controparte, null, $id_operatore);
+ if (!empty($duplicate)) {
+ $note = noteSpeseAppendNote($note, tr('Possibile duplicato della spesa #_ID_.', ['_ID_' => (int) $duplicate['id']]));
+ }
+
+ $dbo->insert('co_note_spese', [
+ 'data' => $data,
+ 'id_tipologia' => $id_tipologia,
+ 'id_stato' => $id_stato_verifica,
+ 'descrizione' => $descrizione,
+ 'importo' => $importo,
+ 'controparte' => $controparte ?: null,
+ 'id_anagrafica' => null,
+ 'id_operatore' => $id_operatore,
+ 'origine' => 'manuale',
+ 'id_origine' => null,
+ 'note' => $note ?: null,
+ ]);
+
+ $id_record = $dbo->lastInsertedID();
+
+ if (!empty($duplicate)) {
+ flash()->warning(tr('Spesa aggiunta come Da verificare: esiste una possibile duplicazione con la spesa #_ID_.', ['_ID_' => (int) $duplicate['id']]));
+ } else {
+ flash()->info(tr('Spesa aggiunta come Da verificare.'));
+ }
+
+ $period_start = $_SESSION['period_start'] ?? date('Y-01-01');
+ $period_end = $_SESSION['period_end'] ?? date('Y-12-31');
+ if (!noteSpeseIsDateInPeriod($data, $period_start, $period_end)) {
+ flash()->warning(tr('La data della spesa è fuori dal periodo attualmente selezionato e la riga non comparirà nell’elenco corrente.'));
+ }
+ break;
+
+ case 'update':
+ Permissions::check('rw');
+
+ if (empty($id_record)) {
+ break;
+ }
+
+ $data = noteSpeseParseDate(post('data'));
+ $id_tipologia = (int) post('id_tipologia');
+ $id_stato = (int) post('id_stato');
+ $descrizione = trim(strip_tags((string) post('descrizione')));
+ $importo = noteSpeseParseAmount(post('importo'));
+ $controparte = trim(strip_tags((string) post('controparte')));
+ $id_operatore = (int) post('id_operatore');
+ $note = trim((string) post('note'));
+ $valid_status = $dbo->fetchOne('SELECT `id`, `name` FROM `co_note_spese_stati` WHERE `id` = '.prepare($id_stato).' LIMIT 1');
+ $current_record = $dbo->fetchOne('SELECT `data`, `id_tipologia`, `id_stato`, `descrizione`, `importo`, `controparte`, `id_operatore` FROM `co_note_spese` WHERE `id` = '.prepare($id_record).' LIMIT 1') ?: [];
+ $current_category_id = (int) ($current_record['id_tipologia'] ?? 0);
+ $current_operator_id = (int) ($current_record['id_operatore'] ?? 0);
+ $valid_category = $dbo->fetchOne(
+ 'SELECT `id`, `enabled` FROM `co_note_spese_tipologie` WHERE `id` = '.prepare($id_tipologia)
+ .' AND (`enabled` = 1 OR `id` = '.prepare($current_category_id).') LIMIT 1'
+ );
+
+ if (!noteSpeseValidateBaseData($data, $id_tipologia, $descrizione, $importo, $controparte) || empty($valid_status) || empty($valid_category) || $id_operatore <= 0 || !noteSpeseOperatorExists($dbo, $id_operatore, $current_operator_id)) {
+ flash()->error(tr('Compilare correttamente i dati della spesa, incluso l’Operatore.'));
+ break;
+ }
+
+ if (($valid_status['name'] ?? '') === 'confermato' && empty($valid_category['enabled'])) {
+ flash()->error(tr('Per confermare la Nota spesa selezionare una Tipologia attiva e coerente con il rimborso all’Operatore.'));
+ break;
+ }
+
+ $id_stato_confermato = noteSpeseGetStatusId($dbo, 'confermato');
+ $id_stato_verifica = noteSpeseGetStatusId($dbo, 'da_verificare');
+ $substantive_changed = (
+ (string) ($current_record['data'] ?? '') !== (string) $data
+ || (int) ($current_record['id_tipologia'] ?? 0) !== $id_tipologia
+ || trim((string) ($current_record['descrizione'] ?? '')) !== trim((string) $descrizione)
+ || number_format((float) ($current_record['importo'] ?? 0), 2, '.', '') !== number_format((float) $importo, 2, '.', '')
+ || trim((string) ($current_record['controparte'] ?? '')) !== trim((string) $controparte)
+ || (int) ($current_record['id_operatore'] ?? 0) !== $id_operatore
+ );
+
+ $reset_to_review = false;
+ if (
+ $substantive_changed
+ && !empty($id_stato_confermato)
+ && !empty($id_stato_verifica)
+ && (int) ($current_record['id_stato'] ?? 0) === (int) $id_stato_confermato
+ && $id_stato === (int) $id_stato_confermato
+ ) {
+ $id_stato = $id_stato_verifica;
+ $reset_to_review = true;
+ }
+
+ $dbo->update('co_note_spese', [
+ 'data' => $data,
+ 'id_tipologia' => $id_tipologia,
+ 'id_stato' => $id_stato,
+ 'descrizione' => $descrizione,
+ 'importo' => $importo,
+ 'controparte' => $controparte ?: null,
+ 'id_operatore' => $id_operatore,
+ 'note' => $note ?: null,
+ ], ['id' => $id_record]);
+
+ if ($reset_to_review) {
+ flash()->warning(tr('La spesa era Confermata: dopo la modifica è stata riportata Da verificare.'));
+ } else {
+ flash()->info(tr('Spesa aggiornata correttamente.'));
+ }
+
+ $period_start = $_SESSION['period_start'] ?? date('Y-01-01');
+ $period_end = $_SESSION['period_end'] ?? date('Y-12-31');
+ if (!noteSpeseIsDateInPeriod($data, $period_start, $period_end)) {
+ flash()->warning(tr('La data della spesa è fuori dal periodo attualmente selezionato.'));
+ }
+ break;
+
+ case 'delete':
+ Permissions::check('rw');
+
+ if (!empty($id_record)) {
+ if (noteSpeseDeleteRecord($dbo, $id_module, (int) $id_record)) {
+ flash()->info(tr('Spesa eliminata correttamente.'));
+ } else {
+ flash()->error(tr('Impossibile eliminare la spesa.'));
+ }
+ }
+ break;
+
+ case 'import_rifornimenti':
+ case 'import_scadenzario':
+ Permissions::check('rw');
+ flash()->warning(tr('Questa sorgente automatica non è più disponibile: una Nota spesa deve rappresentare un costo anticipato personalmente da un Operatore.'));
+ break;
+
+ case 'import_excel':
+ Permissions::check('rw');
+
+ $id_operatore = (int) post('id_operatore_excel');
+ if ($id_operatore <= 0 || !noteSpeseOperatorExists($dbo, $id_operatore)) {
+ flash()->error(tr('Selezionare un Operatore valido per le righe da importare.'));
+ break;
+ }
+
+ $raw = trim((string) post('righe_excel'));
+ if ($raw === '') {
+ flash()->warning(tr('Incollare almeno una riga.'));
+ break;
+ }
+
+ $id_stato = noteSpeseGetStatusId($dbo, 'da_verificare');
+ if (empty($id_stato)) {
+ flash()->error(tr('Stato Da verificare non disponibile.'));
+ break;
+ }
+
+ $rows = preg_split('/\R/u', $raw);
+ $imported = 0;
+ $skipped = 0;
+ $duplicates = 0;
+ $possible_duplicates = 0;
+ $auto_categories = 0;
+ $out_of_scope = 0;
+ $dbo->beginTransaction();
+
+ try {
+ foreach ($rows as $row) {
+ $row = trim($row);
+ if ($row === '') {
+ continue;
+ }
+
+ $columns = strpos($row, "\t") !== false ? explode("\t", $row) : str_getcsv($row, ';');
+ $columns = array_map(static fn ($value) => trim((string) $value), $columns);
+
+ if (!empty($columns[0]) && in_array(noteSpeseLower($columns[0]), ['data', 'date'], true)) {
+ continue;
+ }
+
+ $category_raw = '';
+ $counterparty = '';
+ $user_notes = '';
+
+ if (count($columns) === 3) {
+ [$date_raw, $description, $amount_raw] = $columns;
+ $category = noteSpeseGetAllowedImportCategory($dbo, $description);
+ ++$auto_categories;
+ } elseif (count($columns) >= 4) {
+ [$date_raw, $category_raw, $description, $amount_raw] = array_slice($columns, 0, 4);
+ $counterparty = $columns[4] ?? '';
+ $user_notes = $columns[5] ?? '';
+ $category = noteSpeseGetAllowedImportCategory($dbo, $category_raw, true);
+ if (empty($category)) {
+ $category = noteSpeseGetAllowedImportCategory($dbo, trim($category_raw.' '.$description.' '.$counterparty));
+ }
+ } else {
+ ++$skipped;
+ continue;
+ }
+
+ if (empty($category)) {
+ ++$out_of_scope;
+ continue;
+ }
+
+ $date = noteSpeseParseDate($date_raw);
+ $amount = noteSpeseParseAmount($amount_raw);
+ if ($amount !== null) {
+ $amount = abs($amount);
+ }
+ $description = trim(strip_tags((string) $description));
+ $counterparty = trim(strip_tags((string) $counterparty));
+
+ if (!noteSpeseValidateBaseData($date, (int) $category['id'], $description, $amount, $counterparty)) {
+ ++$skipped;
+ continue;
+ }
+
+ $duplicate = noteSpeseFindDuplicate($dbo, $date, $amount, $description, $counterparty, null, $id_operatore);
+ if (!empty($duplicate) && ($duplicate['origine'] ?? '') === 'excel') {
+ ++$duplicates;
+ continue;
+ }
+
+ $notes = [];
+ if ($category_raw !== '' && strcasecmp(trim($category_raw), (string) $category['descrizione']) !== 0 && strcasecmp(trim($category_raw), (string) $category['codice']) !== 0) {
+ $notes[] = tr('Categoria originale: _CATEGORY_', ['_CATEGORY_' => trim($category_raw)]);
+ }
+ if ($user_notes !== '') {
+ $notes[] = $user_notes;
+ }
+ if (!empty($duplicate)) {
+ ++$possible_duplicates;
+ $notes[] = tr('Possibile duplicato della spesa #_ID_.', ['_ID_' => (int) $duplicate['id']]);
+ }
+
+ $dbo->insert('co_note_spese', [
+ 'data' => $date,
+ 'id_tipologia' => $category['id'],
+ 'id_stato' => $id_stato,
+ 'descrizione' => trim($description),
+ 'importo' => $amount,
+ 'id_anagrafica' => null,
+ 'id_operatore' => $id_operatore,
+ 'controparte' => trim($counterparty) ?: null,
+ 'origine' => 'excel',
+ 'id_origine' => null,
+ 'note' => !empty($notes) ? implode("\n", $notes) : null,
+ ]);
+ ++$imported;
+ }
+
+ $dbo->commitTransaction();
+ } catch (Throwable $e) {
+ $dbo->rollbackTransaction();
+ throw $e;
+ }
+
+ flash()->info(tr('Importazione completata: _IMPORTED_ importate, _SKIPPED_ non valide, _DUPLICATES_ duplicate già importate ignorate.', [
+ '_IMPORTED_' => $imported,
+ '_SKIPPED_' => $skipped,
+ '_DUPLICATES_' => $duplicates,
+ ]));
+ if ($auto_categories > 0) {
+ flash()->info(tr('Per _COUNT_ righe la tipologia è stata proposta automaticamente dalla descrizione.', ['_COUNT_' => $auto_categories]));
+ }
+ if ($out_of_scope > 0) {
+ flash()->warning(tr('_COUNT_ righe sono state ignorate perché riconducibili a costi aziendali fuori dall’ambito Note spese.', ['_COUNT_' => $out_of_scope]));
+ }
+ if ($possible_duplicates > 0) {
+ flash()->warning(tr('_COUNT_ righe potrebbero duplicare spese già presenti e sono state segnalate nelle note.', ['_COUNT_' => $possible_duplicates]));
+ }
+ break;
+}
diff --git a/modules/note_spese/add.php b/modules/note_spese/add.php
new file mode 100644
index 000000000..972470306
--- /dev/null
+++ b/modules/note_spese/add.php
@@ -0,0 +1,149 @@
+id;
+
+$duplicate_id = (int) get('duplicate_id');
+$duplicate_source = [];
+if ($duplicate_id > 0) {
+ $duplicate_source = $dbo->fetchOne(
+ 'SELECT `data`, `id_tipologia`, `descrizione`, `importo`, `controparte`, `id_operatore`, `note` '
+ .'FROM `co_note_spese` WHERE `id` = '.prepare($duplicate_id).' LIMIT 1'
+ ) ?: [];
+}
+
+$is_duplicate = !empty($duplicate_source);
+$default_date = $is_duplicate ? (string) $duplicate_source['data'] : $default_date;
+$default_category = $is_duplicate ? (int) $duplicate_source['id_tipologia'] : null;
+$default_amount = $is_duplicate ? (float) $duplicate_source['importo'] : null;
+$default_description = $is_duplicate ? (string) $duplicate_source['descrizione'] : '';
+$default_counterparty = $is_duplicate ? (string) ($duplicate_source['controparte'] ?? '') : '';
+$default_notes = $is_duplicate ? (string) ($duplicate_source['note'] ?? '') : '';
+
+$current_user = auth_osm()->getUser();
+$current_operator = (int) ($current_user['id_anagrafica'] ?? 0);
+$source_operator = $is_duplicate ? (int) ($duplicate_source['id_operatore'] ?? 0) : 0;
+$operator_candidate = $source_operator > 0 ? $source_operator : $current_operator;
+$default_operator = noteSpeseOperatorExists($dbo, $operator_candidate) ? ($operator_candidate ?: null) : null;
+
+$category_query = 'SELECT t.`id`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.$lang.' '
+ .'WHERE t.`enabled` = 1 ORDER BY t.`ordine`, `descrizione`';
+$operator_query = noteSpeseOperatorSelectQuery($default_operator);
+?>
+
+
+
+
diff --git a/modules/note_spese/bulk.php b/modules/note_spese/bulk.php
new file mode 100644
index 000000000..50392cc14
--- /dev/null
+++ b/modules/note_spese/bulk.php
@@ -0,0 +1,211 @@
+error(tr('Stato di conferma non disponibile.'));
+ break;
+ }
+
+ // Una Nota spesa 1.9.0 è confermabile solo se identifica l'Operatore
+ // che ha anticipato personalmente il costo.
+ $missing_operator_rows = $dbo->fetchArray(
+ 'SELECT `id` FROM `co_note_spese` WHERE `id` IN ('.implode(',', $ids).') '
+ .'AND COALESCE(`id_operatore`, 0) = 0'
+ );
+ $missing_operator_ids = array_map('intval', array_column($missing_operator_rows, 'id'));
+
+ $inactive_category_rows = $dbo->fetchArray(
+ 'SELECT n.`id` FROM `co_note_spese` n '
+ .'LEFT JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'WHERE n.`id` IN ('.implode(',', $ids).') AND COALESCE(t.`enabled`, 0) != 1'
+ );
+ $inactive_category_ids = array_map('intval', array_column($inactive_category_rows, 'id'));
+
+ // I duplicati esatti restano da verificare: per confermarli è necessario
+ // aprire la singola spesa e verificarne la correttezza.
+ $duplicate_rows = $dbo->fetchArray(
+ 'SELECT DISTINCT a.`id` FROM `co_note_spese` a '
+ .'INNER JOIN `co_note_spese` b ON b.`id` != a.`id` '
+ .'AND b.`data` = a.`data` '
+ .'AND b.`importo` = a.`importo` '
+ .'AND LOWER(TRIM(b.`descrizione`)) = LOWER(TRIM(a.`descrizione`)) '
+ .'AND LOWER(TRIM(COALESCE(b.`controparte`, ""))) = LOWER(TRIM(COALESCE(a.`controparte`, ""))) '
+ .'AND COALESCE(b.`id_operatore`, 0) = COALESCE(a.`id_operatore`, 0) '
+ .'INNER JOIN `co_note_spese_stati` bst ON bst.`id` = b.`id_stato` AND bst.`name` != '.prepare('escluso').' '
+ .'WHERE a.`id` IN ('.implode(',', $ids).')'
+ );
+ $duplicate_ids = array_map('intval', array_column($duplicate_rows, 'id'));
+
+ $confirm_ids = array_values(array_diff($ids, $duplicate_ids, $missing_operator_ids, $inactive_category_ids));
+
+ if (!empty($confirm_ids)) {
+ $dbo->query('UPDATE `co_note_spese` SET `id_stato` = '.prepare($id_stato).' WHERE `id` IN ('.implode(',', $confirm_ids).')');
+ flash()->info(tr('_COUNT_ spese confermate.', ['_COUNT_' => count($confirm_ids)]));
+ }
+ if (!empty($duplicate_ids)) {
+ flash()->warning(tr('_COUNT_ possibili duplicati non sono stati confermati: aprire le singole righe per verificarli.', ['_COUNT_' => count($duplicate_ids)]));
+ }
+ if (!empty($missing_operator_ids)) {
+ flash()->warning(tr('_COUNT_ spese senza Operatore non sono state confermate: associare prima la persona che ha anticipato il costo.', ['_COUNT_' => count($missing_operator_ids)]));
+ }
+ if (!empty($inactive_category_ids)) {
+ flash()->warning(tr('_COUNT_ spese con Tipologia non più attiva non sono state confermate: riclassificarle prima della conferma.', ['_COUNT_' => count($inactive_category_ids)]));
+ }
+ break;
+
+ case 'review_bulk':
+ case 'exclude_bulk':
+ Permissions::check('rw');
+ if (empty($ids)) {
+ break;
+ }
+
+ $status_name = post('op') === 'review_bulk' ? 'da_verificare' : 'escluso';
+ $id_stato = noteSpeseGetStatusId($dbo, $status_name);
+ if (!empty($id_stato)) {
+ $dbo->query('UPDATE `co_note_spese` SET `id_stato` = '.prepare($id_stato).' WHERE `id` IN ('.implode(',', $ids).')');
+ flash()->info(tr('_COUNT_ spese aggiornate.', ['_COUNT_' => count($ids)]));
+ }
+ break;
+
+ case 'duplicate_bulk':
+ Permissions::check('rw');
+ if (empty($ids)) {
+ break;
+ }
+
+ $id_stato = noteSpeseGetStatusId($dbo, 'da_verificare');
+ if (empty($id_stato)) {
+ flash()->error(tr('Stato Da verificare non disponibile.'));
+ break;
+ }
+
+ $rows = $dbo->fetchArray(
+ 'SELECT n.`id`, n.`data`, n.`id_tipologia`, n.`descrizione`, n.`importo`, n.`id_anagrafica`, n.`id_operatore`, n.`controparte`, n.`note`, t.`enabled` AS `categoria_attiva` '
+ .'FROM `co_note_spese` n LEFT JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'WHERE n.`id` IN ('.implode(',', $ids).') ORDER BY n.`id` ASC'
+ );
+
+ $duplicated = 0;
+ $skipped_without_operator = 0;
+ $skipped_inactive_category = 0;
+ foreach ($rows as $row) {
+ if (empty($row['id_operatore'])) {
+ ++$skipped_without_operator;
+ continue;
+ }
+ if (empty($row['categoria_attiva'])) {
+ ++$skipped_inactive_category;
+ continue;
+ }
+
+ $dbo->insert('co_note_spese', [
+ 'data' => $row['data'],
+ 'id_tipologia' => (int) $row['id_tipologia'],
+ 'id_stato' => $id_stato,
+ 'descrizione' => $row['descrizione'],
+ 'importo' => number_format((float) $row['importo'], 2, '.', ''),
+ 'id_anagrafica' => !empty($row['id_anagrafica']) ? (int) $row['id_anagrafica'] : null,
+ 'id_operatore' => (int) $row['id_operatore'],
+ 'controparte' => $row['controparte'] ?: null,
+ 'origine' => 'manuale',
+ 'id_origine' => null,
+ 'note' => $row['note'] ?: null,
+ ]);
+ ++$duplicated;
+ }
+
+ if ($duplicated > 0) {
+ flash()->info(tr('_COUNT_ note spese duplicate. Le copie sono Da verificare e senza allegati.', ['_COUNT_' => $duplicated]));
+ }
+ if ($skipped_without_operator > 0) {
+ flash()->warning(tr('_COUNT_ registrazioni storiche senza Operatore non sono state duplicate.', ['_COUNT_' => $skipped_without_operator]));
+ }
+ if ($skipped_inactive_category > 0) {
+ flash()->warning(tr('_COUNT_ registrazioni con Tipologia non più attiva non sono state duplicate.', ['_COUNT_' => $skipped_inactive_category]));
+ }
+ if ($duplicated === 0 && $skipped_without_operator === 0 && $skipped_inactive_category === 0) {
+ flash()->warning(tr('Nessuna nota spesa duplicata.'));
+ }
+ break;
+
+ case 'delete_bulk':
+ Permissions::check('rw');
+ if (empty($ids)) {
+ break;
+ }
+
+ $deleted = 0;
+ foreach ($ids as $id) {
+ $exists = $dbo->fetchOne('SELECT `id` FROM `co_note_spese` WHERE `id` = '.prepare($id).' LIMIT 1');
+ if (!empty($exists) && noteSpeseDeleteRecord($dbo, $id_module, $id)) {
+ ++$deleted;
+ }
+ }
+
+ if ($deleted > 0) {
+ flash()->info(tr('_COUNT_ spese eliminate.', ['_COUNT_' => $deleted]));
+ } else {
+ flash()->warning(tr('Nessuna spesa eliminata.'));
+ }
+ break;
+}
+
+return [
+ 'confirm_bulk' => [
+ 'text' => tr('Conferma'),
+ 'data' => [
+ 'title' => tr('Confermare le spese selezionate?'),
+ 'msg' => tr('Saranno confermate solo le Note spese con Operatore, Tipologia attiva e senza duplicazioni rilevate.'),
+ 'button' => tr('Conferma'),
+ 'class' => 'btn btn-lg btn-success',
+ ],
+ ],
+ 'review_bulk' => [
+ 'text' => tr('Segna da verificare'),
+ 'data' => [
+ 'title' => tr('Segnare le spese come da verificare?'),
+ 'msg' => tr('Le spese selezionate non saranno incluse nella stampa, nel CSV e nei totali finché non verranno confermate.'),
+ 'button' => tr('Segna da verificare'),
+ 'class' => 'btn btn-lg btn-warning',
+ ],
+ ],
+ 'exclude_bulk' => [
+ 'text' => tr('Escludi'),
+ 'data' => [
+ 'title' => tr('Escludere le spese selezionate?'),
+ 'msg' => tr('Le spese resteranno registrate ma saranno escluse dalla stampa, dal CSV e dai totali.'),
+ 'button' => tr('Escludi'),
+ 'class' => 'btn btn-lg btn-secondary',
+ ],
+ ],
+ 'duplicate_bulk' => [
+ 'text' => tr('Duplica'),
+ 'data' => [
+ 'title' => tr('Duplicare le note spese selezionate?'),
+ 'msg' => tr('Verrà creata una copia Da verificare per ogni Nota spesa valida. Le registrazioni senza Operatore o con Tipologia non attiva vengono ignorate.'),
+ 'button' => tr('Duplica'),
+ 'class' => 'btn btn-lg btn-primary',
+ ],
+ ],
+ 'delete_bulk' => [
+ 'text' => tr('Elimina'),
+ 'data' => [
+ 'title' => tr('Eliminare le spese selezionate?'),
+ 'msg' => tr('Le spese selezionate e i relativi allegati saranno eliminati definitivamente.'),
+ 'button' => tr('Elimina'),
+ 'class' => 'btn btn-lg btn-danger',
+ ],
+ ],
+];
diff --git a/modules/note_spese/buttons.php b/modules/note_spese/buttons.php
new file mode 100644
index 000000000..802db836c
--- /dev/null
+++ b/modules/note_spese/buttons.php
@@ -0,0 +1,16 @@
+permission ?? null) === 'rw' && !empty($id_record)) {
+ $duplicate_url = base_path_osm().'/add.php?id_module='.(int) $id_module.'&duplicate_id='.(int) $id_record;
+ ?>
+
+ permission === 'rw';
+$user = auth_osm()->getUser();
+$group_id = (int) ($user->id_gruppo ?? 0);
+$editable_names = $can_inline ? [
+ 'Data' => 'data',
+ 'Descrizione' => 'descrizione',
+ 'Controparte' => 'controparte',
+ 'Importo' => 'importo',
+] : [];
+
+$views = $dbo->fetchArray(
+ 'SELECT v.`name` FROM `zz_views` v '
+ .'INNER JOIN `zz_group_view` gv ON gv.`id_vista` = v.`id` '
+ .'WHERE v.`id_module` = '.prepare((int) $id_module).' '
+ .'AND gv.`id_gruppo` = '.prepare($group_id).' '
+ .'AND v.`visible` = 1 '
+ .'ORDER BY v.`order` ASC'
+);
+
+// La prima colonna DataTables è il selettore, quindi gli indici delle viste
+// visibili partono da 1.
+$editable_columns = [];
+$amount_column = null;
+$column_index = 1;
+foreach ($views as $view) {
+ $name = (string) ($view['name'] ?? '');
+ if ($name === 'Importo') {
+ $amount_column = $column_index;
+ }
+ if (isset($editable_names[$name])) {
+ $editable_columns[$column_index] = $editable_names[$name];
+ }
+ ++$column_index;
+}
+
+if (empty($editable_columns) && $amount_column === null) {
+ return;
+}
+
+$separators = formatter()->getNumberSeparators();
+?>
+
+
diff --git a/modules/note_spese/controller_before.php b/modules/note_spese/controller_before.php
new file mode 100644
index 000000000..0d4aa1b8e
--- /dev/null
+++ b/modules/note_spese/controller_before.php
@@ -0,0 +1,68 @@
+fetchOne('SELECT `id` FROM `zz_prints` WHERE `name` = '.prepare('Nota spese').' AND `enabled` = 1 LIMIT 1');
+$print_id = (int) ($print['id'] ?? 0);
+
+$print_url = $print_id > 0 ? base_path_osm().'/pdfgen.php?id_print='.$print_id.'&id_record=0' : null;
+$csv_url = base_path_osm().'/modules/note_spese/export.php?id_module='.(int) $id_module;
+
+$legacy_summary = $dbo->fetchOne(
+ 'SELECT '
+ .'SUM(CASE WHEN n.`origine` IN ('.prepare('automezzi_rifornimento').', '.prepare('scadenzario_generico').') THEN 1 ELSE 0 END) AS `automatiche`, '
+ .'SUM(CASE WHEN COALESCE(n.`id_operatore`, 0) = 0 THEN 1 ELSE 0 END) AS `senza_operatore`, '
+ .'SUM(CASE WHEN COALESCE(t.`enabled`, 0) != 1 THEN 1 ELSE 0 END) AS `categoria_non_attiva` '
+ .'FROM `co_note_spese` n '
+ .'LEFT JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` '
+ .'LEFT JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'WHERE n.`data` >= '.prepare($period_start).' AND n.`data` <= '.prepare($period_end).' '
+ .'AND COALESCE(st.`name`, "") != '.prepare('escluso')
+) ?: ['automatiche' => 0, 'senza_operatore' => 0, 'categoria_non_attiva' => 0];
+$legacy_automatic_count = (int) ($legacy_summary['automatiche'] ?? 0);
+$missing_operator_count = (int) ($legacy_summary['senza_operatore'] ?? 0);
+$inactive_category_count = (int) ($legacy_summary['categoria_non_attiva'] ?? 0);
+?>
+
+
+
+
+ Translator::dateToLocale($period_start),
+ '_END_' => Translator::dateToLocale($period_end),
+ ]); ?>
+ permission === 'rw') { ?>
+
+
+
+
+
+
+
+
+ 0 || $missing_operator_count > 0 || $inactive_category_count > 0) { ?>
+
+
+ 0) { ?>
+
$legacy_automatic_count]); ?>
+
+ 0) { ?>
+
$missing_operator_count]); ?>
+
+ 0) { ?>
+
$inactive_category_count]); ?>
+
+
+
+
diff --git a/modules/note_spese/edit.php b/modules/note_spese/edit.php
new file mode 100644
index 000000000..52ecce093
--- /dev/null
+++ b/modules/note_spese/edit.php
@@ -0,0 +1,130 @@
+id;
+$category_query = 'SELECT t.`id`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.$lang.' '
+ .'WHERE t.`enabled` = 1 OR t.`id` = '.(int) $record['id_tipologia'].' ORDER BY t.`ordine`, `descrizione`';
+$status_query = 'SELECT st.`id`, COALESCE(l.`title`, st.`name`) AS `descrizione` '
+ .'FROM `co_note_spese_stati` st LEFT JOIN `co_note_spese_stati_lang` l ON l.`id_record` = st.`id` AND l.`id_lang` = '.$lang.' '
+ .'ORDER BY st.`ordine`, st.`id`';
+$operator_query = noteSpeseOperatorSelectQuery($record['id_operatore'] ?? null);
+
+$source_label = noteSpeseSourceLabel($record['origine'] ?? 'manuale');
+$source_url = null;
+$source_hint = null;
+$legacy_automatic_source = in_array((string) ($record['origine'] ?? ''), ['automezzi_rifornimento', 'scadenzario_generico'], true);
+
+// I collegamenti alle vecchie sorgenti vengono conservati esclusivamente per
+// la tracciabilità delle registrazioni già esistenti prima della 1.9.0.
+if (($record['origine'] ?? '') === 'automezzi_rifornimento' && !empty($record['id_origine'])) {
+ $module_source = Module::where('name', 'Automezzi')->first();
+ $can_read_source = !empty($module_source) && in_array(Modules::getPermission($module_source->id), ['r', 'rw'], true);
+ if ($can_read_source && $dbo->tableExists('an_automezzi_rifornimenti') && $dbo->tableExists('an_automezzi_viaggi')) {
+ $source = $dbo->fetchOne(
+ 'SELECT v.`id_sede` FROM `an_automezzi_rifornimenti` r '
+ .'LEFT JOIN `an_automezzi_viaggi` v ON v.`id` = r.`id_viaggio` WHERE r.`id` = '.prepare($record['id_origine']).' LIMIT 1'
+ );
+ if (!empty($source['id_sede'])) {
+ $source_url = base_path_osm().'/editor.php?id_module='.$module_source->id.'&id_record='.(int) $source['id_sede'];
+ $source_hint = tr('Rifornimento #_ID_', ['_ID_' => (int) $record['id_origine']]);
+ }
+ }
+} elseif (($record['origine'] ?? '') === 'scadenzario_generico' && !empty($record['id_origine'])) {
+ $module_source = Module::where('name', 'Scadenzario')->first();
+ if (!empty($module_source) && in_array(Modules::getPermission($module_source->id), ['r', 'rw'], true)) {
+ $source_url = base_path_osm().'/editor.php?id_module='.$module_source->id.'&id_record='.(int) $record['id_origine'];
+ $source_hint = tr('Scadenza #_ID_', ['_ID_' => (int) $record['id_origine']]);
+ }
+}
+
+$duplicate = noteSpeseFindDuplicate(
+ $dbo,
+ $record['data'] ?? null,
+ $record['importo'] ?? null,
+ $record['descrizione'] ?? '',
+ $record['controparte'] ?? '',
+ (int) $id_record,
+ $record['id_operatore'] ?? null
+);
+
+$period_start = $_SESSION['period_start'] ?? date('Y-01-01');
+$period_end = $_SESSION['period_end'] ?? date('Y-12-31');
+$is_in_period = noteSpeseIsDateInPeriod($record['data'] ?? null, $period_start, $period_end);
+$missing_operator = empty($record['id_operatore']);
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Translator::dateToLocale($period_start),
+ '_END_' => Translator::dateToLocale($period_end),
+ ]); ?>
+
+
+
+
+
+
(int) $duplicate['id']]); ?>
+
+
+
+
+
+
+
+
+{( "name": "filelist_and_upload", "id_module": "$id_module$", "id_record": "$id_record$" )}
+
+
diff --git a/modules/note_spese/export.php b/modules/note_spese/export.php
new file mode 100644
index 000000000..9c410bf6d
--- /dev/null
+++ b/modules/note_spese/export.php
@@ -0,0 +1,61 @@
+fetchOne('SELECT `id` FROM `zz_modules` WHERE `name` = '.prepare('Note spese').' LIMIT 1')['id'] ?? 0);
+if ($id_module_note_spese <= 0) {
+ exit(tr('Accesso negato'));
+}
+Permissions::addModule($id_module_note_spese);
+Permissions::check(['r', 'rw']);
+
+$date_start = ($_SESSION['period_start'] ?? date('Y-01-01'));
+$date_end = ($_SESSION['period_end'] ?? date('Y-12-31'));
+$lang = (int) Models\Locale::getDefault()->id;
+$rows = $dbo->fetchArray(
+ 'SELECT n.*, COALESCE(tl.`title`, t.`descrizione`) AS tipologia, '
+ .'COALESCE(NULLIF(n.`controparte`, ""), a.`ragione_sociale`, "") AS controparte_display, '
+ .'COALESCE(op.`ragione_sociale`, "") AS operatore, '
+ .'(SELECT COUNT(*) FROM `zz_files` f WHERE f.`id_module` = '.prepare($id_module_note_spese).' AND f.`id_plugin` IS NULL '
+ .'AND f.`id_record` = n.`id` AND (f.`key` IS NULL OR f.`key` = "")) AS allegati '
+ .'FROM `co_note_spese` n '
+ .'INNER JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` AND st.`name` = '.prepare('confermato').' '
+ .'INNER JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` AND t.`enabled` = 1 '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` tl ON tl.`id_record` = t.`id` AND tl.`id_lang` = '.prepare($lang).' '
+ .'LEFT JOIN `an_anagrafiche` a ON a.`id` = n.`id_anagrafica` '
+ .'INNER JOIN `an_anagrafiche` op ON op.`id` = n.`id_operatore` '
+ .'WHERE n.`data` >= '.prepare($date_start).' AND n.`data` <= '.prepare($date_end).' '
+ .'AND COALESCE(n.`id_operatore`, 0) > 0 '
+ .'ORDER BY n.`data`, n.`id`'
+);
+
+if (ob_get_length()) {
+ ob_clean();
+}
+
+$filename = 'note_spese_'.$date_start.'_'.$date_end.'.csv';
+header('Content-Type: text/csv; charset=UTF-8');
+header('Content-Disposition: attachment; filename="'.$filename.'"');
+header('Pragma: no-cache');
+header('Expires: 0');
+
+$out = fopen('php://output', 'wb');
+fwrite($out, "\xEF\xBB\xBF");
+fputcsv($out, [tr('Data'), tr('Tipologia'), tr('Descrizione'), tr('Controparte'), tr('Operatore'), tr('Importo'), tr('Allegati'), tr('Origine'), tr('Note')], ';', '"', '');
+
+foreach ($rows as $row) {
+ fputcsv($out, [
+ Translator::dateToLocale($row['data']),
+ noteSpeseCsvSafeCell($row['tipologia']),
+ noteSpeseCsvSafeCell($row['descrizione']),
+ noteSpeseCsvSafeCell($row['controparte_display']),
+ noteSpeseCsvSafeCell($row['operatore']),
+ number_format((float) $row['importo'], 2, ',', ''),
+ (int) $row['allegati'],
+ noteSpeseCsvSafeCell(noteSpeseSourceLabel($row['origine'])),
+ noteSpeseCsvSafeCell(preg_replace('/\s+/u', ' ', (string) $row['note'])),
+ ], ';', '"', '');
+}
+
+fclose($out);
+exit;
diff --git a/modules/note_spese/init.php b/modules/note_spese/init.php
new file mode 100644
index 000000000..e86d2a9d5
--- /dev/null
+++ b/modules/note_spese/init.php
@@ -0,0 +1,42 @@
+.
+ */
+
+/**
+ * Modulo Note spese.
+ *
+ * @author sajotrei
+ * @link https://github.com/sajotrei
+ */
+
+include_once __DIR__.'/../../core.php';
+
+if (!empty($id_record)) {
+ $lang = (int) Models\Locale::getDefault()->id;
+ $record = $dbo->fetchOne(
+ 'SELECT n.*, COALESCE(tl.`title`, t.`descrizione`) AS `tipologia`, '
+ .'COALESCE(sl.`title`, st.`name`) AS `stato`, st.`name` AS `stato_name`, st.`colore` AS `stato_colore` '
+ .'FROM `co_note_spese` n '
+ .'LEFT JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` tl ON tl.`id_record` = t.`id` AND tl.`id_lang` = '.prepare($lang).' '
+ .'LEFT JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` '
+ .'LEFT JOIN `co_note_spese_stati_lang` sl ON sl.`id_record` = st.`id` AND sl.`id_lang` = '.prepare($lang).' '
+ .'WHERE n.`id` = '.prepare($id_record)
+ );
+}
diff --git a/modules/note_spese/modutil.php b/modules/note_spese/modutil.php
new file mode 100644
index 000000000..e83b9318b
--- /dev/null
+++ b/modules/note_spese/modutil.php
@@ -0,0 +1,342 @@
+ $lastDot) {
+ $value = str_replace('.', '', $value);
+ $value = str_replace(',', '.', $value);
+ } else {
+ $value = str_replace(',', '', $value);
+ }
+ } elseif ($lastComma !== false || $lastDot !== false) {
+ $separator = $lastComma !== false ? ',' : '.';
+ $parts = explode($separator, $value);
+
+ if (count($parts) > 2) {
+ $groupsAreThousands = true;
+ foreach (array_slice($parts, 1) as $part) {
+ if (strlen($part) !== 3 || !ctype_digit($part)) {
+ $groupsAreThousands = false;
+ break;
+ }
+ }
+
+ if ($groupsAreThousands) {
+ $value = implode('', $parts);
+ } else {
+ $decimal = array_pop($parts);
+ $value = implode('', $parts).'.'.$decimal;
+ }
+ } else {
+ [$integer, $decimal] = array_pad($parts, 2, '');
+ // Nel registro gli importi hanno due decimali: un singolo gruppo di
+ // tre cifre viene quindi interpretato come separatore delle migliaia.
+ if ($decimal !== '' && strlen($decimal) === 3 && ctype_digit(ltrim($integer, '+-')) && ctype_digit($decimal)) {
+ $value = $integer.$decimal;
+ } elseif ($separator === ',') {
+ $value = str_replace(',', '.', $value);
+ }
+ }
+ }
+
+ return is_numeric($value) ? round((float) $value, 2) : null;
+}
+
+function noteSpeseParseDate($value)
+{
+ $value = trim((string) $value);
+ $formats = ['d/m/Y', 'd-m-Y', 'Y-m-d', 'd/m/y', 'd-m-y'];
+
+ foreach ($formats as $format) {
+ $date = DateTime::createFromFormat('!'.$format, $value);
+ if ($date && $date->format($format) === $value) {
+ return $date->format('Y-m-d');
+ }
+ }
+
+ return null;
+}
+
+function noteSpeseIsDateInPeriod($date, $periodStart, $periodEnd)
+{
+ $date = noteSpeseParseDate($date);
+ $periodStart = noteSpeseParseDate($periodStart);
+ $periodEnd = noteSpeseParseDate($periodEnd);
+
+ return !empty($date) && !empty($periodStart) && !empty($periodEnd) && $date >= $periodStart && $date <= $periodEnd;
+}
+
+function noteSpeseGuessCategoryCode($value)
+{
+ $value = noteSpeseLower(trim(strip_tags((string) $value)));
+
+ // L'auto-classificazione riguarda soltanto tipologie coerenti con una
+ // spesa anticipata personalmente dall'Operatore. I costi aziendali dismessi
+ // (assicurazioni, affitti, tributi, spese bancarie, personale) non vengono
+ // più proposti né dedotti automaticamente.
+ $rules = [
+ 'carburante' => ['carburante', 'benzina', 'diesel', 'gasolio', 'rifornimento'],
+ 'pedaggio' => ['pedaggio', 'autostrada', 'telepass'],
+ 'parcheggio' => ['parcheggio', 'parking', 'sosta'],
+ 'vitto' => ['pranzo', 'cena', 'ristorante', 'ristorazione', 'bar', 'vitto'],
+ 'alloggio' => ['hotel', 'albergo', 'alloggio', 'pernottamento'],
+ 'trasporto' => ['taxi', 'treno', 'aereo', 'trasporto', 'bus', 'autobus'],
+ 'materiale_consumo' => ['materiale di consumo', 'consumabile', 'cancelleria'],
+ ];
+
+ foreach ($rules as $code => $keywords) {
+ foreach ($keywords as $keyword) {
+ if (noteSpeseContains($value, $keyword)) {
+ return $code;
+ }
+ }
+ }
+
+ return 'altro';
+}
+
+function noteSpeseFindDuplicate($dbo, $date, $amount, $description, $counterparty = '', $excludeId = null, $operatorId = null)
+{
+ $date = noteSpeseParseDate($date);
+ $amount = noteSpeseParseAmount($amount);
+ $description = noteSpeseNormalizeText($description);
+ $counterparty = noteSpeseNormalizeText($counterparty);
+ $operatorId = (int) $operatorId;
+
+ if (empty($date) || $amount === null || $amount <= 0 || $description === '') {
+ return null;
+ }
+
+ $whereExclude = !empty($excludeId) ? ' AND `id` != '.prepare((int) $excludeId) : '';
+
+ return $dbo->fetchOne(
+ 'SELECT n.`id`, n.`data`, n.`importo`, n.`descrizione`, n.`controparte`, n.`origine`, n.`id_stato` '
+ .'FROM `co_note_spese` n '
+ .'LEFT JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` '
+ .'WHERE n.`data` = '.prepare($date)
+ .' AND n.`importo` = '.prepare(number_format($amount, 2, '.', ''))
+ .' AND LOWER(TRIM(n.`descrizione`)) = '.prepare($description)
+ .' AND LOWER(TRIM(COALESCE(n.`controparte`, ""))) = '.prepare($counterparty)
+ .' AND COALESCE(n.`id_operatore`, 0) = '.prepare($operatorId)
+ .' AND COALESCE(st.`name`, "") != '.prepare('escluso')
+ .str_replace('`id`', 'n.`id`', $whereExclude)
+ .' ORDER BY n.`id` ASC LIMIT 1'
+ );
+}
+
+function noteSpeseAnagraficaExists($dbo, $idAnagrafica)
+{
+ $idAnagrafica = (int) $idAnagrafica;
+ if ($idAnagrafica <= 0) {
+ return false;
+ }
+
+ return !empty($dbo->fetchOne(
+ 'SELECT `id` FROM `an_anagrafiche` WHERE `id` = '.prepare($idAnagrafica).' LIMIT 1'
+ ));
+}
+
+function noteSpeseOperatorSelectQuery($currentId = null)
+{
+ $currentId = (int) $currentId;
+
+ $activeTechnician = '(a.`deleted_at` IS NULL '
+ .'AND EXISTS ('
+ .'SELECT 1 FROM `an_tipi_anagrafiche_anagrafiche` ta '
+ .'INNER JOIN `an_tipi_anagrafiche` t ON t.`id` = ta.`id_tipo_anagrafica` '
+ .'WHERE ta.`id_anagrafica` = a.`id` AND t.`name` = \'Tecnico\') '
+ .'AND (NOT EXISTS (SELECT 1 FROM `zz_users` ux WHERE ux.`id_anagrafica` = a.`id`) '
+ .'OR EXISTS (SELECT 1 FROM `zz_users` ua WHERE ua.`id_anagrafica` = a.`id` AND ua.`enabled` = 1)))';
+
+ $where = $currentId > 0
+ ? '('.$activeTechnician.' OR a.`id` = '.$currentId.')'
+ : $activeTechnician;
+
+ return 'SELECT DISTINCT a.`id` AS `id`, '
+ .'CONCAT(a.`ragione_sociale`, IF(COALESCE(a.`codice`, \'\') = \'\', \'\', CONCAT(\' - \', a.`codice`)), '
+ .'IF(('.$activeTechnician.'), \'\', \' (non attivo)\')) AS `descrizione` '
+ .'FROM `an_anagrafiche` a '
+ .'WHERE '.$where.' '
+ .'ORDER BY a.`ragione_sociale`';
+}
+
+function noteSpeseOperatorExists($dbo, $operatorId, $allowedCurrentId = null)
+{
+ $operatorId = (int) $operatorId;
+ $allowedCurrentId = (int) $allowedCurrentId;
+ if ($operatorId <= 0) {
+ return true;
+ }
+
+ if ($allowedCurrentId > 0 && $operatorId === $allowedCurrentId) {
+ return !empty($dbo->fetchOne(
+ 'SELECT `id` FROM `an_anagrafiche` WHERE `id` = '.prepare($operatorId).' LIMIT 1'
+ ));
+ }
+
+ return !empty($dbo->fetchOne(
+ 'SELECT a.`id` FROM `an_anagrafiche` a '
+ .'WHERE a.`id` = '.prepare($operatorId).' '
+ .'AND a.`deleted_at` IS NULL '
+ .'AND EXISTS ('
+ .'SELECT 1 FROM `an_tipi_anagrafiche_anagrafiche` ta '
+ .'INNER JOIN `an_tipi_anagrafiche` t ON t.`id` = ta.`id_tipo_anagrafica` '
+ .'WHERE ta.`id_anagrafica` = a.`id` AND t.`name` = '.prepare('Tecnico').') '
+ .'AND (NOT EXISTS (SELECT 1 FROM `zz_users` ux WHERE ux.`id_anagrafica` = a.`id`) '
+ .'OR EXISTS (SELECT 1 FROM `zz_users` ua WHERE ua.`id_anagrafica` = a.`id` AND ua.`enabled` = 1)) '
+ .'LIMIT 1'
+ ));
+}
+
+function noteSpeseAppendNote($note, $line)
+{
+ $note = trim((string) $note);
+ $line = trim((string) $line);
+
+ if ($line === '') {
+ return $note !== '' ? $note : null;
+ }
+
+ if ($note === '') {
+ return $line;
+ }
+
+ if (noteSpeseContains($note, $line)) {
+ return $note;
+ }
+
+ return $note."\n".$line;
+}
+
+function noteSpeseDeleteRecord($dbo, $idModule, $idRecord)
+{
+ $idModule = (int) $idModule;
+ $idRecord = (int) $idRecord;
+ if ($idModule <= 0 || $idRecord <= 0) {
+ return false;
+ }
+
+ Uploads::deleteLinked([
+ 'id_module' => $idModule,
+ 'id_plugin' => null,
+ 'id_record' => $idRecord,
+ 'key' => null,
+ ]);
+
+ return (bool) $dbo->delete('co_note_spese', ['id' => $idRecord]);
+}
+
+function noteSpeseSourceLabel($source)
+{
+ return match ((string) $source) {
+ 'automezzi_rifornimento' => tr('Automezzi'),
+ 'scadenzario_generico' => tr('Scadenzario'),
+ 'excel' => tr('Importazione dati'),
+ default => tr('Manuale'),
+ };
+}
+
+function noteSpeseGetStatusId($dbo, $name)
+{
+ $row = $dbo->fetchOne('SELECT `id` FROM `co_note_spese_stati` WHERE `name` = '.prepare($name).' LIMIT 1');
+
+ return !empty($row['id']) ? (int) $row['id'] : null;
+}
+
+function noteSpeseGetCategory($dbo, $value)
+{
+ $value = trim((string) $value);
+ $lang = (int) Models\Locale::getDefault()->id;
+
+ if ($value !== '') {
+ $row = $dbo->fetchOne(
+ 'SELECT t.`id`, t.`codice`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($lang).' '
+ .'WHERE t.`enabled` = 1 AND ('
+ .'LOWER(t.`codice`) = LOWER('.prepare($value).') OR '
+ .'LOWER(t.`descrizione`) = LOWER('.prepare($value).') OR '
+ .'LOWER(l.`title`) = LOWER('.prepare($value).')) LIMIT 1'
+ );
+ if (!empty($row)) {
+ return $row;
+ }
+ }
+
+ $code = noteSpeseGuessCategoryCode($value);
+ $row = $dbo->fetchOne(
+ 'SELECT t.`id`, t.`codice`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($lang).' '
+ .'WHERE t.`enabled` = 1 AND t.`codice` = '.prepare($code).' LIMIT 1'
+ );
+
+ if (!empty($row)) {
+ return $row;
+ }
+
+ // Per valori non riconosciuti usa esclusivamente la tipologia Altro, se
+ // attiva. Non ripiega sulla prima tipologia disponibile: eviterebbe errori
+ // di classificazione silenziosi (es. una voce non riconosciuta come Carburante).
+ return $dbo->fetchOne(
+ 'SELECT t.`id`, t.`codice`, COALESCE(l.`title`, t.`descrizione`) AS `descrizione` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($lang).' '
+ .'WHERE t.`enabled` = 1 AND t.`codice` = '.prepare('altro').' LIMIT 1'
+ );
+}
diff --git a/modules/note_spese/update/2_12.sql b/modules/note_spese/update/2_12.sql
new file mode 100644
index 000000000..e99485f29
--- /dev/null
+++ b/modules/note_spese/update/2_12.sql
@@ -0,0 +1,12 @@
+-- Allineamento Note spese al perimetro definitivo (#1461)
+-- Le Note spese rappresentano costi anticipati personalmente da un Operatore.
+
+-- Rimuove il vecchio hook basato su import automatici da Automezzi/Scadenzario.
+DELETE FROM `zz_hooks_lang`
+WHERE `id_record` IN (SELECT `id` FROM `zz_hooks` WHERE `name` = 'Note spese da importare');
+DELETE FROM `zz_hooks` WHERE `name` = 'Note spese da importare';
+
+-- Mantiene leggibili le tipologie storiche, ma non le propone per nuove Note spese.
+UPDATE `co_note_spese_tipologie`
+SET `enabled` = 0
+WHERE `codice` IN ('assicurazioni', 'affitti', 'contributi_tributi', 'spese_bancarie', 'personale');
diff --git a/modules/note_spese/update/tables.php b/modules/note_spese/update/tables.php
new file mode 100644
index 000000000..ca8e6fdf4
--- /dev/null
+++ b/modules/note_spese/update/tables.php
@@ -0,0 +1,9 @@
+fetchOne(
+ 'SELECT '
+ .'COALESCE(SUM(IF(st.`name` = '.prepare('confermato').' AND COALESCE(n.`id_operatore`, 0) > 0 AND COALESCE(t.`enabled`, 0) = 1, n.`importo`, 0)), 0) AS confermato_totale, '
+ .'SUM(IF(st.`name` = '.prepare('confermato').' AND COALESCE(n.`id_operatore`, 0) > 0 AND COALESCE(t.`enabled`, 0) = 1, 1, 0)) AS confermato_righe, '
+ .'COALESCE(SUM(IF(st.`name` = '.prepare('da_verificare').', n.`importo`, 0)), 0) AS verifica_totale, '
+ .'SUM(IF(st.`name` = '.prepare('da_verificare').', 1, 0)) AS verifica_righe '
+ .'FROM `co_note_spese` n '
+ .'LEFT JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` '
+ .'LEFT JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` '
+ .'WHERE n.`data` >= '.prepare($period_start).' AND n.`data` <= '.prepare($period_end)
+ ) ?: [];
+
+ $summary['senza_allegati'] = 0;
+ if ($id_module_note_spese > 0) {
+ $result = $dbo->fetchOne(
+ 'SELECT COUNT(*) AS totale '
+ .'FROM `co_note_spese` n '
+ .'INNER JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` AND st.`name` = '.prepare('confermato').' '
+ .'INNER JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` AND t.`enabled` = 1 '
+ .'WHERE n.`data` >= '.prepare($period_start).' AND n.`data` <= '.prepare($period_end).' '
+ .'AND COALESCE(n.`id_operatore`, 0) > 0 '
+ .'AND NOT EXISTS ('
+ .'SELECT 1 FROM `zz_files` f '
+ .'WHERE f.`id_module` = '.prepare($id_module_note_spese).' '
+ .'AND f.`id_plugin` IS NULL '
+ .'AND f.`id_record` = n.`id` '
+ .'AND (f.`key` IS NULL OR f.`key` = "")'
+ .')'
+ );
+ $summary['senza_allegati'] = (int) ($result['totale'] ?? 0);
+ }
+
+ $cache[$cache_key] = $summary;
+
+ return $summary;
+ }
+}
+
+if (!function_exists('noteSpeseWidgetValue')) {
+ function noteSpeseWidgetValue($primary, $secondary)
+ {
+ return ''
+ .''.$primary.''
+ .''.$secondary.''
+ .'';
+ }
+}
+
+$summary = noteSpeseWidgetSummary($dbo, $period_start, $period_end, $id_module_note_spese);
+$name = $widget['name'] ?? '';
+
+switch ($name) {
+ case 'Note spese - confermate':
+ echo noteSpeseWidgetValue(
+ moneyFormat($summary['confermato_totale'] ?? 0, 2),
+ tr('_NUM_ registrazioni', ['_NUM_' => (int) ($summary['confermato_righe'] ?? 0)])
+ );
+ break;
+
+ case 'Note spese - da verificare':
+ echo noteSpeseWidgetValue(
+ moneyFormat($summary['verifica_totale'] ?? 0, 2),
+ tr('_NUM_ da verificare', ['_NUM_' => (int) ($summary['verifica_righe'] ?? 0)])
+ );
+ break;
+
+ case 'Note spese - senza allegati':
+ echo noteSpeseWidgetValue(
+ (int) ($summary['senza_allegati'] ?? 0),
+ tr('spese confermate')
+ );
+ break;
+
+ default:
+ echo noteSpeseWidgetValue('0', ' ');
+ break;
+}
diff --git a/modules/tipologie_note_spese/actions.php b/modules/tipologie_note_spese/actions.php
new file mode 100644
index 000000000..98f775036
--- /dev/null
+++ b/modules/tipologie_note_spese/actions.php
@@ -0,0 +1,141 @@
+id;
+
+ if (!noteSpeseTipologiaDescriptionIsValid($descrizione)) {
+ flash()->error(tr('Inserire una descrizione valida di massimo 100 caratteri.'));
+ break;
+ }
+
+ $duplicate = $dbo->fetchOne(
+ 'SELECT t.`id` FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` '
+ .'WHERE LOWER(t.`descrizione`) = LOWER('.prepare($descrizione).') OR LOWER(l.`title`) = LOWER('.prepare($descrizione).') LIMIT 1'
+ );
+ if (!empty($duplicate)) {
+ flash()->error(tr('Esiste già una tipologia con questa descrizione.'));
+ break;
+ }
+
+ $dbo->insert('co_note_spese_tipologie', [
+ 'codice' => null,
+ 'descrizione' => $descrizione,
+ 'ordine' => $ordine,
+ 'enabled' => 1,
+ 'can_delete' => 1,
+ ]);
+ $id_record = $dbo->lastInsertedID();
+
+ $dbo->insert('co_note_spese_tipologie_lang', [
+ 'id_lang' => $id_lang,
+ 'id_record' => $id_record,
+ 'title' => $descrizione,
+ ]);
+
+ flash()->info(tr('Tipologia aggiunta correttamente.'));
+ break;
+
+ case 'update':
+ Permissions::check('rw');
+
+ if (empty($id_record)) {
+ break;
+ }
+
+ $descrizione = noteSpeseTipologiaNormalizeDescription(post('descrizione'));
+ $ordine = max(0, (int) post('ordine'));
+ $enabled = (int) post('enabled');
+ $id_lang = (int) Models\Locale::getDefault()->id;
+
+ if (!noteSpeseTipologiaDescriptionIsValid($descrizione)) {
+ flash()->error(tr('Inserire una descrizione valida di massimo 100 caratteri.'));
+ break;
+ }
+
+ $duplicate = $dbo->fetchOne(
+ 'SELECT t.`id` FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` '
+ .'WHERE t.`id` != '.prepare($id_record).' AND (LOWER(t.`descrizione`) = LOWER('.prepare($descrizione).') '
+ .'OR LOWER(l.`title`) = LOWER('.prepare($descrizione).')) LIMIT 1'
+ );
+ if (!empty($duplicate)) {
+ flash()->error(tr('Esiste già una tipologia con questa descrizione.'));
+ break;
+ }
+
+ $category_data = [
+ 'ordine' => $ordine,
+ 'enabled' => $enabled ? 1 : 0,
+ ];
+ if (Models\Locale::getDefault()->id == Models\Locale::getPredefined()->id) {
+ $category_data['descrizione'] = $descrizione;
+ }
+ $dbo->update('co_note_spese_tipologie', $category_data, ['id' => $id_record]);
+
+ $translation = $dbo->fetchOne(
+ 'SELECT `id` FROM `co_note_spese_tipologie_lang` WHERE `id_lang` = '.prepare($id_lang).' AND `id_record` = '.prepare($id_record).' LIMIT 1'
+ );
+ if (!empty($translation)) {
+ $dbo->update('co_note_spese_tipologie_lang', ['title' => $descrizione], ['id' => $translation['id']]);
+ } else {
+ $dbo->insert('co_note_spese_tipologie_lang', [
+ 'id_lang' => $id_lang,
+ 'id_record' => $id_record,
+ 'title' => $descrizione,
+ ]);
+ }
+
+ flash()->info(tr('Tipologia aggiornata correttamente.'));
+ break;
+
+ case 'delete':
+ Permissions::check('rw');
+
+ if (empty($id_record)) {
+ break;
+ }
+
+ $used = $dbo->fetchNum('SELECT `id` FROM `co_note_spese` WHERE `id_tipologia` = '.prepare($id_record));
+ $record = $dbo->fetchOne('SELECT `can_delete` FROM `co_note_spese_tipologie` WHERE `id` = '.prepare($id_record));
+
+ if (empty($used) && !empty($record['can_delete'])) {
+ $dbo->delete('co_note_spese_tipologie', ['id' => $id_record]);
+ flash()->info(tr('Tipologia eliminata correttamente.'));
+ } else {
+ flash()->error(tr('La tipologia non può essere eliminata perché è predefinita o già utilizzata.'));
+ }
+ break;
+}
diff --git a/modules/tipologie_note_spese/add.php b/modules/tipologie_note_spese/add.php
new file mode 100644
index 000000000..8bcd9fb8a
--- /dev/null
+++ b/modules/tipologie_note_spese/add.php
@@ -0,0 +1,23 @@
+
+
diff --git a/modules/tipologie_note_spese/edit.php b/modules/tipologie_note_spese/edit.php
new file mode 100644
index 000000000..6e0917bc1
--- /dev/null
+++ b/modules/tipologie_note_spese/edit.php
@@ -0,0 +1,38 @@
+
+
+
+fetchNum('SELECT `id` FROM `co_note_spese` WHERE `id_tipologia` = '.prepare($id_record));
+if (!empty($used)) {
+ echo ' '.tr('La tipologia è già utilizzata: può essere disattivata ma non eliminata.').'
';
+} elseif (!empty($record['can_delete'])) {
+ echo ' '.tr('Elimina').'';
+} else {
+ echo ' '.tr('Questa è una tipologia predefinita: può essere disattivata ma non eliminata.').'
';
+}
+?>
diff --git a/modules/tipologie_note_spese/init.php b/modules/tipologie_note_spese/init.php
new file mode 100644
index 000000000..50f4b2887
--- /dev/null
+++ b/modules/tipologie_note_spese/init.php
@@ -0,0 +1,38 @@
+.
+ */
+
+/**
+ * Tipologie del modulo Note spese.
+ *
+ * @author sajotrei
+ * @link https://github.com/sajotrei
+ */
+
+include_once __DIR__.'/../../core.php';
+
+if (!empty($id_record)) {
+ $id_lang = (int) Models\Locale::getDefault()->id;
+ $record = $dbo->fetchOne(
+ 'SELECT t.*, COALESCE(l.`title`, t.`descrizione`) AS `title` '
+ .'FROM `co_note_spese_tipologie` t '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` l ON l.`id_record` = t.`id` AND l.`id_lang` = '.prepare($id_lang).' '
+ .'WHERE t.`id` = '.prepare($id_record).' LIMIT 1'
+ );
+}
diff --git a/templates/note_spese/body.php b/templates/note_spese/body.php
new file mode 100644
index 000000000..a5a5a0ff9
--- /dev/null
+++ b/templates/note_spese/body.php
@@ -0,0 +1,80 @@
+ Translator::dateToLocale($date_start),
+ '_END_' => Translator::dateToLocale($date_end),
+]);
+
+$total = 0;
+$total_attachments = 0;
+
+// Titolo essenziale: la stampa è una Nota spese, il periodo è quello globale selezionato.
+echo ''
+ .''
+ .''.tr('Nota spese').' '
+ .''.$period.' | '
+ .''.tr('Solo confermate').' | '
+ .'
';
+
+echo ''
+ .''
+ .'| '.tr('Data').' | '
+ .''.tr('Tipologia').' | '
+ .''.tr('Descrizione').' | '
+ .''.tr('Controparte').' | '
+ .''.tr('Operatore').' | '
+ .''.tr('Allegati').' | '
+ .''.tr('Importo').' | '
+ .'
';
+
+if (empty($rows)) {
+ echo '| '.tr('Nessuna nota spesa confermata nel periodo selezionato.').' |
';
+} else {
+ foreach ($rows as $row) {
+ $total += (float) $row['importo'];
+ $total_attachments += (int) $row['allegati'];
+ $description = htmlentities((string) $row['descrizione']);
+ if (!empty($row['note'])) {
+ $description .= '
'.nl2br(htmlentities((string) $row['note'])).'';
+ }
+
+ echo ''
+ .'| '.Translator::dateToLocale($row['data']).' | '
+ .''.htmlentities((string) $row['tipologia']).' | '
+ .''.$description.' | '
+ .''.htmlentities((string) $row['controparte_display']).' | '
+ .''.htmlentities((string) ($row['operatore'] ?: '-')).' | '
+ .''.(int) $row['allegati'].' | '
+ .''.moneyFormat($row['importo'], 2).' | '
+ .'
';
+ }
+}
+
+echo ''
+ .'| '.tr('Totale', [], ['upper' => true]).': | '
+ .''.moneyFormat($total, 2).' | '
+ .'
'
+ .'
';
+
+if (!empty($rows)) {
+ echo ''
+ .'| '
+ .''.tr('Registrazioni').': '.count($rows)
+ .' '.tr('Allegati').': '.$total_attachments
+ .' '.tr('Senza allegati').': '.(int) $without_attachments
+ .' |
';
+}
+
+if (!empty($groups)) {
+ echo ''.tr('Totali per tipologia').'
'
+ .''
+ .'| '.tr('Tipologia').' | '.tr('Righe').' | '.tr('Totale').' |
';
+
+ foreach ($groups as $group) {
+ echo '| '.htmlentities((string) $group['tipologia']).' | '.(int) $group['righe'].' | '.moneyFormat($group['totale'], 2).' |
';
+ }
+
+ echo '
';
+}
diff --git a/templates/note_spese/footer.php b/templates/note_spese/footer.php
new file mode 100644
index 000000000..5dc5b1265
--- /dev/null
+++ b/templates/note_spese/footer.php
@@ -0,0 +1,13 @@
+
+
+ | '.tr('Nota spese').' |
+ '.tr('Pagina _PAGE_ di _TOTAL_', [
+ '_PAGE_' => '{PAGENO}',
+ '_TOTAL_' => '{nb}',
+]).' |
+
+';
diff --git a/templates/note_spese/header.php b/templates/note_spese/header.php
new file mode 100644
index 000000000..f656c413f
--- /dev/null
+++ b/templates/note_spese/header.php
@@ -0,0 +1,23 @@
+
+
+ |
+ '.htmlentities($identity).''
+ .(!empty($fiscal) ? ' | '.htmlentities(implode(' · ', $fiscal)) : '').'
+ |
+ '.tr('Documento interno').' |
+
+';
diff --git a/templates/note_spese/init.php b/templates/note_spese/init.php
new file mode 100644
index 000000000..7e54d1498
--- /dev/null
+++ b/templates/note_spese/init.php
@@ -0,0 +1,81 @@
+.
+ */
+
+/**
+ * Stampa riepilogativa del modulo Note spese.
+ *
+ * @author sajotrei
+ * @link https://github.com/sajotrei
+ */
+
+include_once __DIR__.'/../../core.php';
+
+// La stampa e' di periodo e non e' legata a una singola anagrafica/documento.
+// Questi valori evitano riferimenti indefiniti quando il loader standard include
+// templates/info.php per predisporre i placeholder OSM.
+$id_cliente = 0;
+$idcliente = 0;
+$id_sede = -1;
+$tipo_cliente = '';
+
+$date_start = ($_SESSION['period_start'] ?? date('Y-01-01'));
+$date_end = ($_SESSION['period_end'] ?? date('Y-12-31'));
+$id_lang = (int) Models\Locale::getDefault()->id;
+$id_module_note_spese = (int) ($dbo->fetchOne('SELECT `id` FROM `zz_modules` WHERE `name` = '.prepare('Note spese').' LIMIT 1')['id'] ?? 0);
+
+// Dalla 1.9.0 una registrazione entra nella rendicontazione solo se e'
+// Confermata, ha un Operatore e usa una Tipologia attiva. In questo modo le
+// registrazioni storiche non coerenti restano consultabili ma non alterano i
+// riepiloghi finche' non vengono riclassificate.
+$rows = $dbo->fetchArray(
+ 'SELECT n.*, COALESCE(tl.`title`, t.`descrizione`) AS tipologia, '
+ .'COALESCE(NULLIF(n.`controparte`, ""), a.`ragione_sociale`, "") AS controparte_display, '
+ .'COALESCE(op.`ragione_sociale`, "") AS operatore, '
+ .'(SELECT COUNT(*) FROM `zz_files` f WHERE f.`id_module` = '.prepare($id_module_note_spese).' AND f.`id_plugin` IS NULL '
+ .'AND f.`id_record` = n.`id` AND (f.`key` IS NULL OR f.`key` = "")) AS allegati '
+ .'FROM `co_note_spese` n '
+ .'INNER JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` AND st.`name` = '.prepare('confermato').' '
+ .'INNER JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` AND t.`enabled` = 1 '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` tl ON tl.`id_record` = t.`id` AND tl.`id_lang` = '.prepare($id_lang).' '
+ .'LEFT JOIN `an_anagrafiche` a ON a.`id` = n.`id_anagrafica` '
+ .'INNER JOIN `an_anagrafiche` op ON op.`id` = n.`id_operatore` '
+ .'WHERE n.`data` >= '.prepare($date_start).' AND n.`data` <= '.prepare($date_end).' '
+ .'AND COALESCE(n.`id_operatore`, 0) > 0 '
+ .'ORDER BY n.`data` ASC, n.`id` ASC'
+);
+
+$groups = $dbo->fetchArray(
+ 'SELECT COALESCE(tl.`title`, t.`descrizione`, '.prepare(tr('Senza tipologia')).') AS tipologia, SUM(n.`importo`) AS totale, COUNT(*) AS righe '
+ .'FROM `co_note_spese` n '
+ .'INNER JOIN `co_note_spese_stati` st ON st.`id` = n.`id_stato` AND st.`name` = '.prepare('confermato').' '
+ .'INNER JOIN `co_note_spese_tipologie` t ON t.`id` = n.`id_tipologia` AND t.`enabled` = 1 '
+ .'LEFT JOIN `co_note_spese_tipologie_lang` tl ON tl.`id_record` = t.`id` AND tl.`id_lang` = '.prepare($id_lang).' '
+ .'WHERE n.`data` >= '.prepare($date_start).' AND n.`data` <= '.prepare($date_end).' '
+ .'AND COALESCE(n.`id_operatore`, 0) > 0 '
+ .'GROUP BY t.`id`, tl.`title`, t.`descrizione`, t.`ordine` '
+ .'ORDER BY t.`ordine`, tipologia'
+);
+
+$without_attachments = 0;
+foreach ($rows as $row) {
+ if (empty($row['allegati'])) {
+ ++$without_attachments;
+ }
+}
diff --git a/templates/note_spese/settings.php b/templates/note_spese/settings.php
new file mode 100644
index 000000000..8ebba37c4
--- /dev/null
+++ b/templates/note_spese/settings.php
@@ -0,0 +1,14 @@
+ 'L',
+ 'format' => 'A4',
+ 'font-size' => 8,
+ 'margins' => [
+ 'top' => 'auto',
+ 'bottom' => 'auto',
+ 'left' => 10,
+ 'right' => 10,
+ ],
+ 'header-font-size' => 7,
+];
diff --git a/update/2_12.sql b/update/2_12.sql
index 201536992..11ff70f58 100644
--- a/update/2_12.sql
+++ b/update/2_12.sql
@@ -102,3 +102,208 @@ INSERT INTO `zz_settings` (`nome`, `valore`, `tipo`, `editable`, `sezione`, `ord
INSERT INTO `zz_settings_lang` (`id_lang`, `id_record`, `title`, `help`) VALUES
(1, (SELECT `id` FROM `zz_settings` WHERE `nome` = 'Tipologia anagrafica predefinita'), 'Tipologia anagrafica predefinita', 'Tipologia (Azienda, Ente pubblico o Privato) preselezionata automaticamente nella finestra di aggiunta di una nuova anagrafica. Se non impostata, nessuna tipologia viene preselezionata.'),
(2, (SELECT `id` FROM `zz_settings` WHERE `nome` = 'Tipologia anagrafica predefinita'), 'Default entity classification', 'Classification (Company, Public entity or Private) automatically preselected in the new entity creation window. If not set, no classification is preselected.');
+
+-- Modulo Note spese (#1461)
+CREATE TABLE `co_note_spese_tipologie` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `codice` VARCHAR(50) NULL,
+ `descrizione` VARCHAR(100) NOT NULL,
+ `ordine` INT NOT NULL DEFAULT 100,
+ `enabled` TINYINT(1) NOT NULL DEFAULT 1,
+ `can_delete` TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `co_note_spese_tipologie_codice_unique` (`codice`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `co_note_spese_tipologie_lang` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `id_lang` INT NOT NULL,
+ `id_record` INT NOT NULL,
+ `title` VARCHAR(100) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `co_note_spese_tipologie_lang_unique` (`id_lang`, `id_record`),
+ KEY `co_note_spese_tipologie_lang_record_index` (`id_record`),
+ CONSTRAINT `co_note_spese_tipologie_lang_ibfk_1` FOREIGN KEY (`id_record`) REFERENCES `co_note_spese_tipologie` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
+ CONSTRAINT `co_note_spese_tipologie_lang_ibfk_2` FOREIGN KEY (`id_lang`) REFERENCES `zz_langs` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `co_note_spese_stati` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `name` VARCHAR(50) NOT NULL,
+ `colore` VARCHAR(30) NOT NULL DEFAULT 'secondary',
+ `ordine` INT NOT NULL DEFAULT 100,
+ `can_delete` TINYINT(1) NOT NULL DEFAULT 0,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `co_note_spese_stati_name_unique` (`name`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `co_note_spese_stati_lang` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `id_lang` INT NOT NULL,
+ `id_record` INT NOT NULL,
+ `title` VARCHAR(100) NOT NULL,
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `co_note_spese_stati_lang_unique` (`id_lang`, `id_record`),
+ KEY `co_note_spese_stati_lang_record_index` (`id_record`),
+ CONSTRAINT `co_note_spese_stati_lang_ibfk_1` FOREIGN KEY (`id_record`) REFERENCES `co_note_spese_stati` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT,
+ CONSTRAINT `co_note_spese_stati_lang_ibfk_2` FOREIGN KEY (`id_lang`) REFERENCES `zz_langs` (`id`) ON DELETE CASCADE ON UPDATE RESTRICT
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+CREATE TABLE `co_note_spese` (
+ `id` INT NOT NULL AUTO_INCREMENT,
+ `data` DATE NOT NULL,
+ `id_tipologia` INT NOT NULL,
+ `id_stato` INT NOT NULL,
+ `descrizione` VARCHAR(255) NOT NULL,
+ `importo` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
+ `id_anagrafica` INT NULL,
+ `id_operatore` INT NULL,
+ `controparte` VARCHAR(255) NULL,
+ `origine` VARCHAR(50) NOT NULL DEFAULT 'manuale',
+ `id_origine` INT NULL,
+ `note` TEXT NULL,
+ `created_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP,
+ `updated_at` TIMESTAMP NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+ PRIMARY KEY (`id`),
+ KEY `co_note_spese_data_index` (`data`),
+ KEY `co_note_spese_tipologia_index` (`id_tipologia`),
+ KEY `co_note_spese_stato_index` (`id_stato`),
+ KEY `co_note_spese_anagrafica_index` (`id_anagrafica`),
+ KEY `co_note_spese_operatore_index` (`id_operatore`),
+ UNIQUE KEY `co_note_spese_origine_unique` (`origine`, `id_origine`),
+ CONSTRAINT `co_note_spese_ibfk_1` FOREIGN KEY (`id_tipologia`) REFERENCES `co_note_spese_tipologie` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT `co_note_spese_ibfk_2` FOREIGN KEY (`id_stato`) REFERENCES `co_note_spese_stati` (`id`) ON DELETE RESTRICT ON UPDATE CASCADE,
+ CONSTRAINT `co_note_spese_ibfk_3` FOREIGN KEY (`id_anagrafica`) REFERENCES `an_anagrafiche` (`id`) ON DELETE SET NULL ON UPDATE CASCADE,
+ CONSTRAINT `co_note_spese_ibfk_4` FOREIGN KEY (`id_operatore`) REFERENCES `an_anagrafiche` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+INSERT INTO `co_note_spese_tipologie` (`codice`, `descrizione`, `ordine`, `enabled`, `can_delete`) VALUES
+('carburante', 'Carburante', 10, 1, 0),
+('pedaggio', 'Pedaggio / Autostrada', 20, 1, 0),
+('parcheggio', 'Parcheggio', 30, 1, 0),
+('vitto', 'Vitto', 40, 1, 0),
+('alloggio', 'Alloggio', 50, 1, 0),
+('trasporto', 'Trasporto', 60, 1, 0),
+('materiale_consumo', 'Materiale di consumo', 70, 1, 0),
+('assicurazioni', 'Assicurazioni', 80, 1, 0),
+('affitti', 'Canoni / Affitti', 90, 1, 0),
+('contributi_tributi', 'Contributi / Tributi', 100, 1, 0),
+('spese_bancarie', 'Spese bancarie / Commissioni', 110, 1, 0),
+('personale', 'Personale', 120, 1, 0),
+('altro', 'Altro', 1000, 1, 0);
+
+INSERT INTO `co_note_spese_tipologie_lang` (`id_lang`, `id_record`, `title`)
+SELECT 1, `id`, `descrizione` FROM `co_note_spese_tipologie`;
+INSERT INTO `co_note_spese_tipologie_lang` (`id_lang`, `id_record`, `title`)
+SELECT 2, `id`, CASE `codice`
+ WHEN 'carburante' THEN 'Fuel'
+ WHEN 'pedaggio' THEN 'Toll / motorway'
+ WHEN 'parcheggio' THEN 'Parking'
+ WHEN 'vitto' THEN 'Meals'
+ WHEN 'alloggio' THEN 'Accommodation'
+ WHEN 'trasporto' THEN 'Transport'
+ WHEN 'materiale_consumo' THEN 'Consumables'
+ WHEN 'assicurazioni' THEN 'Insurance'
+ WHEN 'affitti' THEN 'Rent / leases'
+ WHEN 'contributi_tributi' THEN 'Contributions / taxes'
+ WHEN 'spese_bancarie' THEN 'Bank fees / commissions'
+ WHEN 'personale' THEN 'Personnel'
+ ELSE 'Other'
+END FROM `co_note_spese_tipologie`;
+
+INSERT INTO `co_note_spese_stati` (`name`, `colore`, `ordine`, `can_delete`) VALUES
+('da_verificare', 'warning', 10, 0),
+('confermato', 'success', 20, 0),
+('escluso', 'secondary', 30, 0);
+
+INSERT INTO `co_note_spese_stati_lang` (`id_lang`, `id_record`, `title`)
+SELECT 1, `id`, CASE `name` WHEN 'da_verificare' THEN 'Da verificare' WHEN 'confermato' THEN 'Confermata' ELSE 'Esclusa' END FROM `co_note_spese_stati`;
+INSERT INTO `co_note_spese_stati_lang` (`id_lang`, `id_record`, `title`)
+SELECT 2, `id`, CASE `name` WHEN 'da_verificare' THEN 'To review' WHEN 'confermato' THEN 'Confirmed' ELSE 'Excluded' END FROM `co_note_spese_stati`;
+
+INSERT INTO `zz_modules` (`name`, `directory`, `attachments_directory`, `options`, `options2`, `icon`, `version`, `compatibility`, `order`, `parent`, `default`, `enabled`) VALUES
+('Note spese', 'note_spese', 'note_spese',
+'SELECT |select| FROM `co_note_spese` LEFT JOIN `co_note_spese_tipologie` ON `co_note_spese_tipologie`.`id` = `co_note_spese`.`id_tipologia` LEFT JOIN `co_note_spese_tipologie_lang` ON (`co_note_spese_tipologie_lang`.`id_record` = `co_note_spese_tipologie`.`id` AND `co_note_spese_tipologie_lang`.|lang|) LEFT JOIN `co_note_spese_stati` ON `co_note_spese_stati`.`id` = `co_note_spese`.`id_stato` LEFT JOIN `co_note_spese_stati_lang` ON (`co_note_spese_stati_lang`.`id_record` = `co_note_spese_stati`.`id` AND `co_note_spese_stati_lang`.|lang|) LEFT JOIN `an_anagrafiche` ON `an_anagrafiche`.`id` = `co_note_spese`.`id_anagrafica` LEFT JOIN `an_anagrafiche` AS `an_operatori` ON `an_operatori`.`id` = `co_note_spese`.`id_operatore` WHERE 1=1 |date_period(`co_note_spese`.`data`)| HAVING 2=2 ORDER BY `co_note_spese`.`data` DESC, `co_note_spese`.`id` DESC',
+'', 'fa fa-money', '2.12', '2.12', 20, COALESCE((SELECT `parent` FROM `zz_modules` WHERE `name` = 'Prima nota'), (SELECT `id` FROM `zz_modules` WHERE `name` = 'Contabilità')), 1, 1),
+('Tipologie note spese', 'tipologie_note_spese', 'tipologie_note_spese',
+'SELECT |select| FROM `co_note_spese_tipologie` LEFT JOIN `co_note_spese_tipologie_lang` ON (`co_note_spese_tipologie_lang`.`id_record` = `co_note_spese_tipologie`.`id` AND `co_note_spese_tipologie_lang`.|lang|) WHERE 1=1 HAVING 2=2 ORDER BY `co_note_spese_tipologie`.`ordine`, COALESCE(`co_note_spese_tipologie_lang`.`title`, `co_note_spese_tipologie`.`descrizione`)',
+'', 'fa fa-tags', '2.12', '2.12', 20, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tabelle'), 1, 1);
+
+INSERT INTO `zz_modules_lang` (`id_lang`, `id_record`, `title`, `meta_title`) VALUES
+(1, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Note spese', 'Nota spesa - {descrizione}'),
+(2, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Expense notes', 'Expense note - {descrizione}'),
+(1, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Tipologie note spese', 'Tipologia nota spesa - {title}'),
+(2, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Expense categories', 'Expense category - {title}');
+
+INSERT INTO `zz_views` (`id_module`, `name`, `query`, `order`, `search`, `visible`, `format`, `html_format`, `summable`, `default`) VALUES
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'id', '`co_note_spese`.`id`', 1, 0, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Data', '`co_note_spese`.`data`', 2, 1, 1, 1, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Tipologia', 'COALESCE(`co_note_spese_tipologie_lang`.`title`, `co_note_spese_tipologie`.`descrizione`)', 3, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Descrizione', '`co_note_spese`.`descrizione`', 4, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Controparte', 'COALESCE(NULLIF(`co_note_spese`.`controparte`, ''''), `an_anagrafiche`.`ragione_sociale`, '''')', 5, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Operatore', 'COALESCE(`an_operatori`.`ragione_sociale`, '''')', 6, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'icon_Stato', 'CASE `co_note_spese_stati`.`name` WHEN ''confermato'' THEN ''fa fa-check-circle fa-lg text-success'' WHEN ''da_verificare'' THEN ''fa fa-exclamation-triangle fa-lg text-warning'' ELSE ''fa fa-ban fa-lg text-secondary'' END', 7, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'icon_title_Stato', 'COALESCE(`co_note_spese_stati_lang`.`title`, `co_note_spese_stati`.`name`)', 8, 0, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Origine', 'CASE `co_note_spese`.`origine` WHEN ''automezzi_rifornimento'' THEN ''Automezzi'' WHEN ''scadenzario_generico'' THEN ''Scadenzario'' WHEN ''excel'' THEN ''Importazione'' ELSE ''Manuale'' END', 9, 1, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'icon_Allegati', 'IF((SELECT COUNT(*) FROM `zz_files` WHERE `zz_files`.`id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = ''Note spese'') AND `zz_files`.`id_plugin` IS NULL AND `zz_files`.`id_record` = `co_note_spese`.`id` AND (`zz_files`.`key` IS NULL OR `zz_files`.`key` = '''')) > 0, ''fa fa-paperclip fa-lg text-success'', ''fa fa-paperclip fa-lg text-warning'')', 10, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'icon_title_Allegati', 'CAST((SELECT COUNT(*) FROM `zz_files` WHERE `zz_files`.`id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = ''Note spese'') AND `zz_files`.`id_plugin` IS NULL AND `zz_files`.`id_record` = `co_note_spese`.`id` AND (`zz_files`.`key` IS NULL OR `zz_files`.`key` = '''')) AS CHAR)', 11, 0, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'Importo', '`co_note_spese`.`importo`', 12, 1, 1, 1, 0, 1, 1);
+
+INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`)
+SELECT 1, `id`, `name` FROM `zz_views` WHERE `id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese');
+INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`)
+SELECT 2, `id`, CASE `name`
+ WHEN 'Data' THEN 'Date' WHEN 'Tipologia' THEN 'Category' WHEN 'Descrizione' THEN 'Description'
+ WHEN 'Controparte' THEN 'Counterparty' WHEN 'Operatore' THEN 'Operator'
+ WHEN 'icon_Stato' THEN 'icon_Status' WHEN 'icon_title_Stato' THEN 'icon_title_Status'
+ WHEN 'Origine' THEN 'Source' WHEN 'icon_Allegati' THEN 'icon_Attachments'
+ WHEN 'icon_title_Allegati' THEN 'icon_title_Attachments' WHEN 'Importo' THEN 'Amount' ELSE `name` END
+FROM `zz_views` WHERE `id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese');
+
+INSERT INTO `zz_views` (`id_module`, `name`, `query`, `order`, `search`, `visible`, `format`, `html_format`, `summable`, `default`) VALUES
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'id', '`co_note_spese_tipologie`.`id`', 1, 0, 0, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Descrizione', 'COALESCE(`co_note_spese_tipologie_lang`.`title`, `co_note_spese_tipologie`.`descrizione`)', 2, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Ordine', '`co_note_spese_tipologie`.`ordine`', 3, 1, 1, 0, 0, 0, 1),
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese'), 'Attiva', 'IF(`co_note_spese_tipologie`.`enabled` = 1, ''SI'', ''NO'')', 4, 1, 1, 0, 0, 0, 1);
+
+INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`)
+SELECT 1, `id`, `name` FROM `zz_views` WHERE `id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese');
+INSERT INTO `zz_views_lang` (`id_lang`, `id_record`, `title`)
+SELECT 2, `id`, CASE `name` WHEN 'Descrizione' THEN 'Description' WHEN 'Ordine' THEN 'Order' WHEN 'Attiva' THEN 'Enabled' ELSE `name` END
+FROM `zz_views` WHERE `id_module` = (SELECT `id` FROM `zz_modules` WHERE `name` = 'Tipologie note spese');
+
+INSERT INTO `zz_prints` (`id_module`, `is_record`, `name`, `directory`, `previous`, `options`, `icon`, `version`, `compatibility`, `order`, `predefined`, `enabled`) VALUES
+((SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 0, 'Nota spese', 'note_spese', '', '', 'fa fa-print', '2.12', '2.12', 0, 0, 1);
+INSERT INTO `zz_prints_lang` (`id_lang`, `id_record`, `title`, `filename`) VALUES
+(1, (SELECT `id` FROM `zz_prints` WHERE `name` = 'Nota spese'), 'Nota spese', 'Nota spese'),
+(2, (SELECT `id` FROM `zz_prints` WHERE `name` = 'Nota spese'), 'Expense notes', 'Expense notes');
+
+INSERT INTO `zz_widgets` (`name`, `type`, `id_module`, `location`, `class`, `query`, `bgcolor`, `icon`, `print_link`, `more_link`, `more_link_type`, `php_include`, `enabled`, `order`, `help`) VALUES
+('Note spese - confermate', 'custom', (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'controller_top', 'col-md-4', '', 'success', 'fa fa-check-circle', '', '', 'link', 'modules/note_spese/widgets/indicatori.php', 1, 1, 'Totale e numero delle spese confermate nel periodo selezionato.'),
+('Note spese - da verificare', 'custom', (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'controller_top', 'col-md-4', '', 'warning', 'fa fa-exclamation-triangle', '', '', 'link', 'modules/note_spese/widgets/indicatori.php', 1, 2, 'Importo e numero delle note spese che richiedono verifica.'),
+('Note spese - senza allegati', 'custom', (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'), 'controller_top', 'col-md-4', '', 'info', 'fa fa-paperclip', '', '', 'link', 'modules/note_spese/widgets/indicatori.php', 1, 3, 'Spese confermate del periodo che non hanno ancora allegati.');
+
+INSERT INTO `zz_widgets_lang` (`id_lang`, `id_record`, `title`, `text`) VALUES
+(1, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - confermate'), 'Spese confermate', 'Spese confermate'),
+(2, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - confermate'), 'Confirmed expenses', 'Confirmed expenses'),
+(1, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - da verificare'), 'Da verificare', 'Da verificare'),
+(2, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - da verificare'), 'To review', 'To review'),
+(1, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - senza allegati'), 'Senza allegati', 'Senza allegati'),
+(2, (SELECT `id` FROM `zz_widgets` WHERE `name` = 'Note spese - senza allegati'), 'Without attachments', 'Without attachments');
+
+INSERT INTO `zz_hooks` (`name`, `class`, `enabled`, `id_module`) VALUES
+('Note spese da importare', 'Modules\\NoteSpese\\PendingExpensesHook', 1, (SELECT `id` FROM `zz_modules` WHERE `name` = 'Note spese'));
+INSERT INTO `zz_hooks_lang` (`id_lang`, `id_record`, `title`) VALUES
+(1, (SELECT `id` FROM `zz_hooks` WHERE `name` = 'Note spese da importare'), 'Note spese da importare'),
+(2, (SELECT `id` FROM `zz_hooks` WHERE `name` = 'Note spese da importare'), 'Expense notes to import');
+
+INSERT INTO `zz_permissions` (`id_gruppo`, `id_module`, `permessi`)
+SELECT g.`id`, m.`id`, 'rw'
+FROM `zz_groups` g
+CROSS JOIN `zz_modules` m
+WHERE g.`nome` = 'Amministratori' AND m.`name` IN ('Note spese', 'Tipologie note spese');
+
+INSERT INTO `zz_group_view` (`id_gruppo`, `id_vista`)
+SELECT g.`id`, v.`id`
+FROM `zz_groups` g
+INNER JOIN `zz_views` v ON v.`id_module` IN (SELECT `id` FROM `zz_modules` WHERE `name` IN ('Note spese', 'Tipologie note spese'));
diff --git a/update/tables.php b/update/tables.php
index ad542e845..eeb2d5a40 100755
--- a/update/tables.php
+++ b/update/tables.php
@@ -43,6 +43,11 @@
'co_mandati_sepa',
'co_movimenti',
'co_movimenti_modelli',
+ 'co_note_spese',
+ 'co_note_spese_stati',
+ 'co_note_spese_stati_lang',
+ 'co_note_spese_tipologie',
+ 'co_note_spese_tipologie_lang',
'co_pagamenti',
'co_pagamenti_lang',
'co_piano_dei_conti1',