diff --git a/modules/partitario/dettagli_conto2.php b/modules/partitario/dettagli_conto2.php index 8da3cafc4..d9718b4f1 100644 --- a/modules/partitario/dettagli_conto2.php +++ b/modules/partitario/dettagli_conto2.php @@ -20,235 +20,552 @@ include_once __DIR__.'/../../core.php'; +/** + * Costruisce le celle HTML di una riga sottoconto. Riusata sia dal render completo + * (mastri sotto soglia, client-side) sia dalla risposta JSON server-side (oltre soglia), + * così la query e il markup della riga vivono una volta sola. + * + * @return array Celle: [sottoconto, importo, (importo reddito se Economico), vuota] + */ +function partitario_sottoconto_cells($conto_terzo, $conto_secondo, $conto_primo) +{ + $is_economico = $conto_primo['descrizione'] == 'Economico'; + $numero_movimenti = $conto_terzo['numero_movimenti']; + + $totale_conto = $conto_terzo['totale']; + $totale_reddito = $conto_terzo['totale_reddito']; + if ($conto_primo['descrizione'] != 'Patrimoniale') { + $totale_conto = -$totale_conto ?: 0; + $totale_reddito = -$totale_reddito ?: 0; + } + + $cella = ''; + + // Possibilità di esplodere i movimenti del conto + if (!empty($numero_movimenti)) { + $cella .= ''; + } + + // Span con i pulsanti + $cella .= ''; + + // Possibilità di visionare l'anagrafica + $id_anagrafica = $conto_terzo['id_anagrafica']; + $anagrafica_deleted = $conto_terzo['deleted_at']; + if (isset($id_anagrafica)) { + $cella .= Modules::link('Anagrafiche', $id_anagrafica, ' '); + } + + // Stampa mastrino + if (!empty($numero_movimenti)) { + $cella .= Prints::getLink('Mastrino', $conto_terzo['id'], 'btn-info btn-xs', '', null, 'lev=3'); + } + + // Pulsante per aggiornare il totale reddito del conto di livello 3 + $cella .= ''; + + // Pulsante per modificare il nome del conto di livello 3 + $cella .= ''; + + // Possibilità di eliminare il conto se non ci sono movimenti collegati + if ($numero_movimenti <= 0) { + $cella .= ''; + } + + $cella .= ''; + + // Span con info del conto + contenitore per l'espansione dei movimenti + $deducibile = $conto_terzo['percentuale_deducibile'] != '100.00' + ? tr('(deducibile al _PERC_%', ['_PERC_' => Translator::numberToLocale($conto_terzo['percentuale_deducibile'], 0)]).')' + : ''; + $cella .= ' '.$conto_secondo['numero'].'.'.$conto_terzo['numero'].' '.$conto_terzo['descrizione'].' '.$deducibile.''; + $cella .= ''; + + $cells = [$cella, moneyFormat($totale_conto, 2)]; + if ($is_economico) { + $cells[] = moneyFormat($totale_reddito, 2); + } + $cells[] = ''; + + return $cells; +} + $id_conto = get('id_conto'); $conto_secondo = $dbo->selectOne('co_piano_dei_conti2', '*', ['id' => $id_conto]); $conto_primo = $dbo->selectOne('co_piano_dei_conti1', '*', ['id' => $conto_secondo['id_piano_dei_conti1']]); +$is_economico = $conto_primo['descrizione'] == 'Economico'; + +// Oltre la soglia configurabile i sottoconti del mastro vengono mostrati come DataTable +// (ricerca + impaginazione server-side). Il conteggio si basa sul totale dei sottoconti +// del mastro, indipendentemente da eventuali filtri. +$soglia_datatable = (int) setting('Soglia datatable sottoconti'); +if ($soglia_datatable <= 0) { + $soglia_datatable = 500; +} -// Livello 3 -$query3 = 'SELECT `co_piano_dei_conti3`.*, movimenti.numero_movimenti, movimenti.totale, movimenti.totale_reddito, id_anagrafica, anagrafica.deleted_at - FROM `co_piano_dei_conti3` - LEFT OUTER JOIN ( - SELECT id as id_anagrafica, - id_conto_cliente, - id_conto_fornitore, - deleted_at - FROM an_anagrafiche - ) AS anagrafica ON co_piano_dei_conti3.id IN (anagrafica.id_conto_cliente, anagrafica.id_conto_fornitore) - LEFT OUTER JOIN ( - SELECT COUNT(id_conto) AS numero_movimenti, - id_conto, - SUM( - CASE - WHEN co_movimenti.data BETWEEN '.prepare($_SESSION['period_start']).' AND '.prepare($_SESSION['period_end']).' THEN - totale - ELSE - 0 - END - ) AS totale, - SUM( - CASE - WHEN data_inizio_competenza IS NULL OR data_fine_competenza IS NULL THEN - totale_reddito - ELSE - totale_reddito * ( - DATEDIFF( - LEAST(data_fine_competenza, '.prepare($_SESSION['period_end']).'), - GREATEST(data_inizio_competenza, '.prepare($_SESSION['period_start']).') - ) + 1 - ) / ( - DATEDIFF(data_fine_competenza, data_inizio_competenza) + 1 - ) - END - ) AS totale_reddito - FROM co_movimenti - WHERE ( - (data BETWEEN '.prepare($_SESSION['period_start']).' AND '.prepare($_SESSION['period_end']).') - OR - (data_inizio_competenza IS NOT NULL AND data_fine_competenza IS NOT NULL AND - data_fine_competenza >= '.prepare($_SESSION['period_start']).' AND - data_inizio_competenza <= '.prepare($_SESSION['period_end']).') - OR - (data_inizio_competenza IS NOT NULL AND data_fine_competenza IS NOT NULL AND - data_inizio_competenza < '.prepare($_SESSION['period_start']).' AND - data_fine_competenza > '.prepare($_SESSION['period_end']).') - OR - (data_inizio_competenza IS NOT NULL AND data_fine_competenza IS NOT NULL AND - data_inizio_competenza <= '.prepare($_SESSION['period_end']).' AND - data_inizio_competenza >= '.prepare($_SESSION['period_start']).') - OR - (data_inizio_competenza IS NOT NULL AND data_fine_competenza IS NOT NULL AND - data_fine_competenza >= '.prepare($_SESSION['period_start']).' AND - data_fine_competenza <= '.prepare($_SESSION['period_end']).') - ) GROUP BY id_conto - ) movimenti ON co_piano_dei_conti3.id=movimenti.id_conto +// Subquery dei movimenti per conto (periodo + competenza). Definita una volta e riusata +// sia dalla query di pagina sia dal totale del footer. +$movimenti_subquery = '( + SELECT COUNT(id_conto) AS numero_movimenti, + id_conto, + SUM( + CASE + WHEN co_movimenti.data BETWEEN '.prepare($_SESSION['period_start']).' AND '.prepare($_SESSION['period_end']).' THEN totale + ELSE 0 + END + ) AS totale, + SUM( + CASE + WHEN data_inizio_competenza IS NULL OR data_fine_competenza IS NULL THEN totale_reddito + ELSE totale_reddito * ( + DATEDIFF( + LEAST(data_fine_competenza, '.prepare($_SESSION['period_end']).'), + GREATEST(data_inizio_competenza, '.prepare($_SESSION['period_start']).') + ) + 1 + ) / (DATEDIFF(data_fine_competenza, data_inizio_competenza) + 1) + END + ) AS totale_reddito + FROM co_movimenti + WHERE ( + (data BETWEEN '.prepare($_SESSION['period_start']).' AND '.prepare($_SESSION['period_end']).') + OR (data_inizio_competenza IS NOT NULL AND data_fine_competenza IS NOT NULL AND data_fine_competenza >= '.prepare($_SESSION['period_start']).' AND data_inizio_competenza <= '.prepare($_SESSION['period_end']).') + OR (data_inizio_competenza IS NOT NULL AND data_fine_competenza IS NOT NULL AND data_inizio_competenza < '.prepare($_SESSION['period_start']).' AND data_fine_competenza > '.prepare($_SESSION['period_end']).') + OR (data_inizio_competenza IS NOT NULL AND data_fine_competenza IS NOT NULL AND data_inizio_competenza <= '.prepare($_SESSION['period_end']).' AND data_inizio_competenza >= '.prepare($_SESSION['period_start']).') + OR (data_inizio_competenza IS NOT NULL AND data_fine_competenza IS NOT NULL AND data_fine_competenza >= '.prepare($_SESSION['period_start']).' AND data_fine_competenza <= '.prepare($_SESSION['period_end']).') + ) GROUP BY id_conto +)'; + +// Anagrafica collegata al conto: due LEFT JOIN diretti e indicizzati su an_anagrafiche +// (come cliente o fornitore) invece di un join IN su derived table, che non usava indici. +$anagrafica_select = 'COALESCE(ac.id, af.id) AS id_anagrafica, COALESCE(ac.deleted_at, af.deleted_at) AS deleted_at'; +$anagrafica_join = function ($src) { + return ' + LEFT JOIN an_anagrafiche ac ON ac.id_conto_cliente = '.$src.'.id + LEFT JOIN an_anagrafiche af ON af.id_conto_fornitore = '.$src.'.id'; +}; + +// Elenco COMPLETO dei sottoconti (mastri sotto soglia, client-side): join su tutta la tabella. +$query3_full = 'SELECT `co_piano_dei_conti3`.*, movimenti.numero_movimenti, movimenti.totale, movimenti.totale_reddito, '.$anagrafica_select.' + FROM `co_piano_dei_conti3`'.$anagrafica_join('co_piano_dei_conti3').' + LEFT OUTER JOIN '.$movimenti_subquery.' movimenti ON co_piano_dei_conti3.id=movimenti.id_conto WHERE `id_piano_dei_conti2` = '.prepare($conto_secondo['id']).' ORDER BY numero ASC'; -$terzo_livello = $dbo->fetchArray($query3); +// Conteggio totale sottoconti del mastro (decide la soglia ed è recordsTotal della DataTable). +$total_sottoconti = (int) $dbo->fetchOne('SELECT COUNT(*) AS tot FROM `co_piano_dei_conti3` WHERE `id_piano_dei_conti2` = '.prepare($conto_secondo['id']))['tot']; +$usa_datatable = $total_sottoconti > $soglia_datatable; +$datatable_id = 'sottoconti-datatable-'.$conto_secondo['id']; +$root_id = 'sottoconti-root-'.$conto_secondo['id']; + +// Filtro di ricerca server-side: descrizione oppure numero "mastro.sottoconto". +// Valore letto raw per non farlo alterare dal formatter degli input. +$search_arr = get('search', true); +$search_value = (is_array($search_arr) && isset($search_arr['value'])) ? trim((string) $search_arr['value']) : ''; +$search_where = ''; +if ($search_value !== '') { + $like = prepare('%'.$search_value.'%'); + $search_where = ' AND (`co_piano_dei_conti3`.`descrizione` LIKE '.$like.' OR CONCAT('.prepare($conto_secondo['numero']).', \'.\', `co_piano_dei_conti3`.`numero`) LIKE '.$like.')'; +} -if (!empty($terzo_livello)) { - echo ' -
- - '; - foreach ($terzo_livello as $conto_terzo) { - // Se il conto non ha documenti collegati posso eliminarlo - $numero_movimenti = $conto_terzo['numero_movimenti']; +if (filter('draw', null, true) !== '') { + // === Ramo JSON: una pagina di righe per la DataTable server-side === + // Parametri numerici letti raw: il formatter degli input potrebbe inserire + // separatori di migliaia su valori grandi (es. start oltre 999) e rompere l'OFFSET. + $draw = (int) filter('draw', null, true); + $start = (int) filter('start', null, true); + $length = (int) filter('length', null, true); + if ($length <= 0) { + $length = 25; + } - $totale_conto = $conto_terzo['totale']; - $totale_reddito = $conto_terzo['totale_reddito']; - if ($conto_primo['descrizione'] != 'Patrimoniale') { - $totale_conto = -$totale_conto ?: 0; - $totale_reddito = -$totale_reddito ?: 0; + $records_filtered = (int) $dbo->fetchOne('SELECT COUNT(*) AS tot FROM `co_piano_dei_conti3` WHERE `id_piano_dei_conti2` = '.prepare($conto_secondo['id']).$search_where)['tot']; + + // Paginazione efficiente: prima si selezionano i sottoconti della sola pagina, poi si + // fanno i join sulle ~25 righe risultanti, evitando i join sull'intero mastro. + $page_query = 'SELECT base.*, movimenti.numero_movimenti, movimenti.totale, movimenti.totale_reddito, '.$anagrafica_select.' + FROM ( + SELECT `co_piano_dei_conti3`.* FROM `co_piano_dei_conti3` + WHERE `id_piano_dei_conti2` = '.prepare($conto_secondo['id']).$search_where.' + ORDER BY numero ASC LIMIT '.$start.', '.$length.' + ) base'.$anagrafica_join('base').' + LEFT OUTER JOIN '.$movimenti_subquery.' movimenti ON base.id=movimenti.id_conto + ORDER BY base.numero ASC'; + $rows = $dbo->fetchArray($page_query); + + $data = []; + foreach ($rows as $conto_terzo) { + $cells = partitario_sottoconto_cells($conto_terzo, $conto_secondo, $conto_primo); + $row = [ + 'DT_RowId' => 'conto3-'.$conto_terzo['id'], + 'DT_RowClass' => 'conto3', + ]; + if (empty($conto_terzo['numero_movimenti'])) { + $row['DT_RowAttr'] = ['style' => 'opacity: 0.5;']; } + foreach ($cells as $i => $cella) { + $row[$i] = $cella; + } + $data[] = $row; + } - $totale_conto2 += $totale_conto; - $totale_reddito2 += $totale_reddito; + if (!headers_sent()) { + header('Content-Type: application/json'); + } + echo json_encode([ + 'draw' => $draw, + 'recordsTotal' => $total_sottoconti, + 'recordsFiltered' => $records_filtered, + 'data' => $data, + ]); +} else { + // === Ramo HTML: guscio (oltre soglia, server-side) o elenco completo (sotto soglia) === + echo '
'; + + if ($total_sottoconti == 0) { + echo '
'.tr('Nessun conto presente').''; + } elseif ($usa_datatable) { + // Totale del mastro per il footer: una sola query (indipendente dai filtri). + $tot = $dbo->fetchOne('SELECT SUM(movimenti.totale) AS totale, SUM(movimenti.totale_reddito) AS totale_reddito + FROM `co_piano_dei_conti3` + LEFT OUTER JOIN '.$movimenti_subquery.' movimenti ON co_piano_dei_conti3.id=movimenti.id_conto + WHERE `id_piano_dei_conti2` = '.prepare($conto_secondo['id'])); + $totale_conto2 = $tot['totale']; + $totale_reddito2 = $tot['totale_reddito']; + if ($conto_primo['descrizione'] != 'Patrimoniale') { + $totale_conto2 = -$totale_conto2; + $totale_reddito2 = -$totale_reddito2; + } echo ' -
-
'; - - // Possibilità di esplodere i movimenti del conto - if (!empty($numero_movimenti)) { +
+ + + + + '; + if ($is_economico) { echo ' - '; + '; } - - // Span con i pulsanti echo ' - '; - - // Possibilità di visionare l'anagrafica - $id_anagrafica = $conto_terzo['id_anagrafica']; - $anagrafica_deleted = $conto_terzo['deleted_at']; - if (isset($id_anagrafica)) { - echo Modules::link('Anagrafiche', $id_anagrafica, ' '); + + + + + + + + '; + if ($is_economico) { + echo ' + '; } + echo ' + + +
'.tr('Sottoconto').''.tr('Importo').''.tr('Importo reddito').'
'.tr('Totale').''.moneyFormat($totale_conto2).''.moneyFormat($totale_reddito2).'
+

+
'; - // Stampa mastrino - if (!empty($numero_movimenti)) { - echo ' - '.Prints::getLink('Mastrino', $conto_terzo['id'], 'btn-info btn-xs', '', null, 'lev=3'); + // Colonne (3 Patrimoniale, 4 Economico) — importi allineati a destra via className. + $columns_js = '{ data: "0" }, { data: "1", className: "text-right" }'; + if ($is_economico) { + $columns_js .= ', { data: "2", className: "text-right" }'; } + $columns_js .= ', { data: "'.($is_economico ? '3' : '2').'" }'; - // Pulsante per aggiornare il totale reddito del conto di livello 3 echo ' - '; + +'; + } else { + // Sotto soglia: elenco completo client-side (comportamento invariato). + $terzo_livello = $dbo->fetchArray($query3_full); + $totale_conto2 = 0; + $totale_reddito2 = 0; echo ' - '; +
+ + '; + foreach ($terzo_livello as $conto_terzo) { + $totale_conto = $conto_terzo['totale']; + $totale_reddito = $conto_terzo['totale_reddito']; + if ($conto_primo['descrizione'] != 'Patrimoniale') { + $totale_conto = -$totale_conto ?: 0; + $totale_reddito = -$totale_reddito ?: 0; + } + $totale_conto2 += $totale_conto; + $totale_reddito2 += $totale_reddito; - // Span con info del conto - echo ' - -  '.$conto_secondo['numero'].'.'.$conto_terzo['numero'].' '.$conto_terzo['descrizione'].' '.($conto_terzo['percentuale_deducibile'] != '100.00' ? tr('(deducibile al _PERC_%', ['_PERC_' => Translator::numberToLocale($conto_terzo['percentuale_deducibile'], 0)]).')' : '').''.' - - - - - '; - if ($conto_primo['descrizione'] == 'Economico') { + $cells = partitario_sottoconto_cells($conto_terzo, $conto_secondo, $conto_primo); + echo ' + + + '; + if ($is_economico) { + echo ' + '; + } echo ' - '; - } - echo ' '; - } -} else { - echo ' -
'.tr('Nessun conto presente').''; -} - -if (!empty($terzo_livello)) { - echo ' + } + echo ' '; - if ($conto_primo['descrizione'] == 'Economico') { - echo ' '; - } - echo ' + if ($is_economico) { + echo ' + '; + } + echo '
- '.moneyFormat($totale_conto, 2).' -
'.$cells[0].''.$cells[1].''.$cells[2].' - '.moneyFormat($totale_reddito, 2).' -
'.tr('Totale').' '.moneyFormat($totale_conto2).''.moneyFormat($totale_reddito2).''.moneyFormat($totale_reddito2).'


'; -} + } -echo ' + // Script condiviso: hover sui pulsanti + espansione movimenti. Event delegation sul + // contenitore (le righe arrivano dinamicamente in server-side ad ogni draw). + echo ' '; + + echo ''; +} diff --git a/modules/partitario/edit.php b/modules/partitario/edit.php index e54cc7f28..c5a8ece85 100755 --- a/modules/partitario/edit.php +++ b/modules/partitario/edit.php @@ -487,6 +487,21 @@ function eliminaConto(id_conto, level) { }); } + // I sottoconti dei mastri oltre soglia sono renderizzati come DataTable: le loro + // righe non vanno mostrate/nascoste direttamente (ci pensa la DataTable), altrimenti + // si rompe l\'impaginazione. Questi helper distinguono i due casi. + function sottocontoNonInDatatable() { + return $(this).closest(".js-sottoconti-datatable").length === 0; + } + + function forEachSottocontiDatatable(callback) { + $(".js-sottoconti-datatable").each(function () { + if ($.fn.DataTable.isDataTable(this)) { + callback($(this).DataTable()); + } + }); + } + $("#button-search").on("click", function(){ var text = $("#input-cerca").val(); @@ -506,14 +521,18 @@ function eliminaConto(id_conto, level) { $(this).find(".search").click(); } }); - $(".conto3").show(); + // Azzera il filtro delle DataTable dei sottoconti + forEachSottocontiDatatable(function (dt) { + dt.search("").draw(); + }); + $(".conto3").filter(sottocontoNonInDatatable).show(); $(".conto1").show(); $(".conto2").show(); $(".totali").show(); } else { $(".conto1").hide(); $(".conto2").hide(); - $(".conto3").hide(); + $(".conto3").filter(sottocontoNonInDatatable).hide(); $(".totali").hide(); results.conti2.forEach(function(item) { $("#conto2-"+ item).parent().parent().parent().parent().parent().show(); @@ -529,10 +548,19 @@ function eliminaConto(id_conto, level) { }); results.conti3.forEach(function(item) { - $("#conto3-"+ item).show(); + var $row = $("#conto3-"+ item); + if ($row.length && sottocontoNonInDatatable.call($row[0])) { + $row.show(); + } }); - + // I mastri oltre soglia filtrano i sottoconti tramite la ricerca + // interna della DataTable (la regola "datatable se oltre soglia" + // resta valida anche con risultati filtrati). I mastri espansi ora + // dalla ricerca si auto-filtrano in fase di init. + forEachSottocontiDatatable(function (dt) { + dt.search(text).draw(); + }); } } }); diff --git a/update/2_13.sql b/update/2_13.sql new file mode 100644 index 000000000..8f515c7a5 --- /dev/null +++ b/update/2_13.sql @@ -0,0 +1,14 @@ +-- Soglia oltre la quale i sottoconti di un mastro del Piano dei conti vengono mostrati come tabella con ricerca e impaginazione +INSERT INTO `zz_settings` (`nome`, `valore`, `tipo`, `editable`, `sezione`, `order`, `is_user_setting`) VALUES +('Soglia datatable sottoconti', '500', 'integer', 1, 'Piano dei conti', 10, 0); + +INSERT INTO `zz_settings_lang` (`id_lang`, `id_record`, `title`, `help`) VALUES +(1, (SELECT `id` FROM `zz_settings` WHERE `nome` = 'Soglia datatable sottoconti'), 'Soglia datatable sottoconti', 'Numero di sottoconti oltre il quale, espandendo un mastro nel Piano dei conti, i sottoconti vengono mostrati in una tabella con ricerca e impaginazione invece dell\'elenco semplice. Default 500.'), +(2, (SELECT `id` FROM `zz_settings` WHERE `nome` = 'Soglia datatable sottoconti'), 'Subaccount datatable threshold', 'Number of subaccounts above which, when expanding an account in the Chart of accounts, subaccounts are shown in a searchable, paginated table instead of the plain list. Default 500.'); + +-- Indice per la selezione paginata dei sottoconti per mastro +ALTER TABLE `co_piano_dei_conti3` ADD INDEX `idx_id_piano_dei_conti2_numero` (`id_piano_dei_conti2`, `numero`); + +-- Indici per il join anagrafica del dettaglio sottoconti +ALTER TABLE `an_anagrafiche` ADD INDEX `idx_id_conto_cliente` (`id_conto_cliente`); +ALTER TABLE `an_anagrafiche` ADD INDEX `idx_id_conto_fornitore` (`id_conto_fornitore`);