* Copyright (C) 2026 Eduard Wisch * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ /** * \file subtotaltitle/class/actions_subtotaltitle.class.php * \ingroup subtotaltitle * \brief Hook overload for SubtotalTitle module */ require_once DOL_DOCUMENT_ROOT.'/core/class/commonhookactions.class.php'; require_once __DIR__.'/DocumentTypeHelper.class.php'; /** * Class ActionsSubtotalTitle */ class ActionsSubtotalTitle extends CommonHookActions { private $debug = 0; // Wird dynamisch aus Config geladen // Gemeinsames Array für gerenderte Sections private static $rendered_sections = array(); // Aktueller Dokumentkontext (wird in formObjectOptions/printObjectLine gesetzt) private $currentDocType = null; private $currentDocumentId = null; private $isDraft = false; /** * @var DoliDB Database handler. */ public $db; /** * Helper-Methode: Erstellt WHERE-Klausel für document_id */ private function getDocumentWhere($document_id, $docType, $tableAlias = 'm') { global $db; $tables = DocumentTypeHelper::getTableNames($docType); if (!$tables) return ""; return " WHERE ".$tableAlias.".".$tables['fk_parent']." = ".(int)$document_id." AND ".$tableAlias.".document_type = '".$db->escape($docType)."'"; } /** * @var string Error code (or message) */ public $error = ''; /** * @var string[] Errors */ public $errors = array(); /** * @var mixed[] Hook results. Propagated to $hookmanager->resArray for later reuse */ public $results = array(); /** * @var ?string String displayed by executeHook() immediately after return */ public $resprints; /** * @var int Priority of hook (50 is used if value is not defined) */ public $priority; /** * Constructor * * @param DoliDB $db Database handler */ public function __construct($db) { global $conf; $this->db = $db; // Debug-Modus aus Config laden $this->debug = getDolGlobalInt('SUBTOTALTITLE_DEBUG_MODE', 0); // IMMER Debug-Log in Datei schreiben $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; @mkdir(dirname($logFile), 0777, true); error_log('['.date('Y-m-d H:i:s').'] SubtotalTitle Constructor aufgerufen'."\n", 3, $logFile); } public function formObjectOptions($parameters, &$object, &$action, $hookmanager) { global $conf, $langs; // Prüfe ob es ein unterstützter Kontext ist $supportedContexts = array('invoicecard', 'propalcard', 'ordercard'); $currentContexts = explode(':', $parameters['currentcontext']); $isSupported = false; foreach ($supportedContexts as $ctx) { if (in_array($ctx, $currentContexts)) { $isSupported = true; break; } } if ($isSupported) { // Setze Klassenvariablen für den aktuellen Dokumentkontext $this->currentDocType = DocumentTypeHelper::getTypeFromObject($object); $this->currentDocumentId = $object->id; // Prüfe ob Dokument bearbeitbar ist (nur im Entwurfsstatus) $this->isDraft = false; if (isset($object->statut)) { $this->isDraft = ($object->statut == 0); // Debug-Log $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; error_log('['.date('Y-m-d H:i:s').'] formObjectOptions - DocType: '.$this->currentDocType.', statut: '.$object->statut.', isDraft: '.($this->isDraft ? 'true' : 'false')."\n", 3, $logFile); } elseif (isset($object->status)) { $this->isDraft = ($object->status == 0); // Debug-Log $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; error_log('['.date('Y-m-d H:i:s').'] formObjectOptions - DocType: '.$this->currentDocType.', status: '.$object->status.', isDraft: '.($this->isDraft ? 'true' : 'false')."\n", 3, $logFile); } $is_draft = $this->isDraft; // Lade Übersetzungen $langs->load('subtotaltitle@subtotaltitle'); // CSS $cssPath = dol_buildpath('/custom/subtotaltitle/css/subtotaltitle.css', 1); echo ''."\n"; // Übersetzungen als JavaScript-Variablen bereitstellen echo ''."\n"; // Haupt-JavaScript $jsPath = dol_buildpath('/custom/subtotaltitle/js/subtotaltitle.js', 1); echo ''."\n"; // Sync-JavaScript (NEU!) $jsSyncPath = dol_buildpath('/custom/subtotaltitle/js/subtotaltitle_sync.js', 1); echo ''."\n"; // Buttons nur im Entwurfsstatus anzeigen if ($is_draft) { // Textzeile-Button echo ''."\n"; // Massenlösch-Button (ans ENDE der Hauptzeile) - NUR EINMAL EINFÜGEN echo ''."\n"; // Sync-Buttons + Collapse-Buttons - rechts ausgerichtet echo ''."\n"; } } return 0; } /** * Execute action */ public function getNomUrl($parameters, &$object, &$action) { global $db, $langs, $conf, $user; $this->resprints = ''; return 0; } /** * Overload the doActions function */ public function doActions($parameters, &$object, &$action, $hookmanager) { return 0; } /** * Overload the doMassActions function */ public function doMassActions($parameters, &$object, &$action, $hookmanager) { global $conf, $user, $langs; $error = 0; if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) { foreach ($parameters['toselect'] as $objectid) { // Do action on each object id } if (!$error) { $this->results = array('myreturn' => 999); $this->resprints = 'A text to show'; return 0; } else { $this->errors[] = 'Error message'; return -1; } } return 0; } /** * Overload the addMoreMassActions function */ public function addMoreMassActions($parameters, &$object, &$action, $hookmanager) { global $conf, $user, $langs; $error = 0; $disabled = 1; if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) { $this->resprints = ''; } if (!$error) { return 0; } else { $this->errors[] = 'Error message'; return -1; } } /** * Execute action before PDF (document) creation */ public function beforePDFCreation($parameters, &$object, &$action) { global $conf, $user, $langs; global $hookmanager; $outputlangs = $langs; $ret = 0; $deltemp = array(); dol_syslog(get_class($this).'::executeHooks action='.$action); if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) { // do something only for the context 'somecontext1' or 'somecontext2' } return $ret; } /** * Execute action after PDF (document) creation */ public function afterPDFCreation($parameters, &$pdfhandler, &$action) { global $conf, $user, $langs; global $hookmanager; $outputlangs = $langs; $ret = 0; $deltemp = array(); dol_syslog(get_class($this).'::executeHooks action='.$action); if (in_array($parameters['currentcontext'], array('somecontext1', 'somecontext2'))) { // do something only for the context 'somecontext1' or 'somecontext2' } return $ret; } /** * Overload the loadDataForCustomReports function */ public function loadDataForCustomReports($parameters, &$action, $hookmanager) { global $langs; $langs->load("subtotaltitle@subtotaltitle"); $this->results = array(); $head = array(); $h = 0; if ($parameters['tabfamily'] == 'subtotaltitle') { $head[$h][0] = dol_buildpath('/module/index.php', 1); $head[$h][1] = $langs->trans("Home"); $head[$h][2] = 'home'; $h++; $this->results['title'] = $langs->trans("SubtotalTitle"); $this->results['picto'] = 'subtotaltitle@subtotaltitle'; } $head[$h][0] = 'customreports.php?objecttype='.$parameters['objecttype'].(empty($parameters['tabfamily']) ? '' : '&tabfamily='.$parameters['tabfamily']); $head[$h][1] = $langs->trans("CustomReports"); $head[$h][2] = 'customreports'; $this->results['head'] = $head; $arrayoftypes = array(); $this->results['arrayoftype'] = $arrayoftypes; return 0; } /** * Overload the restrictedArea function */ public function restrictedArea($parameters, $object, &$action, $hookmanager) { global $user; if ($parameters['features'] == 'myobject') { if ($user->hasRight('subtotaltitle', 'myobject', 'read')) { $this->results['result'] = 1; return 1; } else { $this->results['result'] = 0; return 1; } } return 0; } /** * Execute action completeTabsHead */ public function completeTabsHead(&$parameters, &$object, &$action, $hookmanager) { global $langs, $conf, $user; if (!isset($parameters['object']->element)) { return 0; } if ($parameters['mode'] == 'remove') { return 0; } elseif ($parameters['mode'] == 'add') { $langs->load('subtotaltitle@subtotaltitle'); $counter = count($parameters['head']); $element = $parameters['object']->element; $id = $parameters['object']->id; if (in_array($element, ['context1', 'context2'])) { $datacount = 0; $parameters['head'][$counter][0] = dol_buildpath('/subtotaltitle/subtotaltitle_tab.php', 1) . '?id=' . $id . '&module='.$element; $parameters['head'][$counter][1] = $langs->trans('SubtotalTitleTab'); if ($datacount > 0) { $parameters['head'][$counter][1] .= '' . $datacount . ''; } $parameters['head'][$counter][2] = 'subtotaltitleemails'; $counter++; } if ($counter > 0 && (int) DOL_VERSION < 14) { $this->results = $parameters['head']; return 1; } else { return 0; } } else { return -1; } } /** * Overload the showLinkToObjectBlock function */ public function showLinkToObjectBlock($parameters, &$object, &$action, $hookmanager) { return 0; } /** * Hook: Wird aufgerufen wenn eine Rechnung geladen wird * Synchronisiert die Tabelle und rendert Section-Header */ public function printObjectLine($parameters, &$object, &$action, $hookmanager) { global $db, $langs; // Erkenne Dokumenttyp $docType = DocumentTypeHelper::getTypeFromObject($object); if (!$docType) { return 0; } // Prüfe Context $expectedContext = DocumentTypeHelper::getContext($docType); if ($parameters['currentcontext'] != $expectedContext) { return 0; } $document_id = $object->id; // Setze Klassenvariablen für den aktuellen Dokumentkontext $this->currentDocType = $docType; $this->currentDocumentId = $document_id; // Prüfe ob Dokument bearbeitbar ist (nur im Entwurfsstatus) $this->isDraft = false; if (isset($object->statut)) { $this->isDraft = ($object->statut == 0); // Debug-Log $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; error_log('['.date('Y-m-d H:i:s').'] printObjectLine - DocType: '.$this->currentDocType.', statut: '.$object->statut.', isDraft: '.($this->isDraft ? 'true' : 'false')."\n", 3, $logFile); } elseif (isset($object->status)) { $this->isDraft = ($object->status == 0); // Debug-Log $logFile = '/srv/http/dolibarr/documents/subtotaltitle/debug.log'; error_log('['.date('Y-m-d H:i:s').'] printObjectLine - DocType: '.$this->currentDocType.', status: '.$object->status.', isDraft: '.($this->isDraft ? 'true' : 'false')."\n", 3, $logFile); } $line = $parameters['line']; if (!$line || !$line->id) { return 0; } // Prüfe ob diese Zeile eine unserer speziellen Zeilen ist (Section, Text, Subtotal) // special_code: 100=Section, 101=Text, 102=Subtotal if (isset($line->special_code) && in_array($line->special_code, array(100, 101, 102))) { // Diese Zeile ist eine unserer speziellen Zeilen - per JS ausblenden echo ''; return 0; } // Dokument neu laden um aktuelle rang zu haben static $reloaded = array(); $reload_key = $docType.'_'.$document_id; if (!isset($reloaded[$reload_key])) { $object->fetch($document_id); $object->fetch_thirdparty(); $object->fetch_lines(); $reloaded[$reload_key] = true; } // Synchronisiere Manager-Tabelle $this->syncManagerTable($document_id, $docType); // Rendere alle Sections die VOR dieser Zeile kommen sollten (inkl. leere!) $this->renderAllPendingSections($document_id, $line, $docType); // WICHTIG: Markiere diese Zeile mit line_order UND parent_section für JavaScript $tables = DocumentTypeHelper::getTableNames($docType); $sql = "SELECT line_order, parent_section FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " WHERE ".$tables['fk_line']." = ".(int)$line->id; $resql = $db->query($sql); if ($obj = $db->fetch_object($resql)) { $parentSection = $obj->parent_section ? $obj->parent_section : 'null'; echo ''; } return 0; } /** * Rendert ALLE Sections die VOR dieser RANG-Position kommen sollten */ private function renderAllPendingSections($document_id, $line, $docType) { global $db; $tables = DocumentTypeHelper::getTableNames($docType); if (!$tables) return; static $last_rang = array(); static $last_parent_section = array(); $doc_key = $docType.'_'.$document_id; if (!isset(self::$rendered_sections[$doc_key])) { self::$rendered_sections[$doc_key] = array(); $last_rang[$doc_key] = 0; $last_parent_section[$doc_key] = null; } // Hole rang dieser Produktzeile $sql = "SELECT rang FROM ".MAIN_DB_PREFIX.$tables['lines_table']; $sql .= " WHERE rowid = ".(int)$line->id; $resql = $db->query($sql); if (!$resql || $db->num_rows($resql) == 0) return; $current_rang = $db->fetch_object($resql)->rang; // Hole line_order und parent_section des aktuellen Produkts aus Manager-Tabelle $sql_current = "SELECT line_order, parent_section FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_current .= " WHERE ".$tables['fk_line']." = ".(int)$line->id." AND document_type = '".$db->escape($docType)."'"; $res_current = $db->query($sql_current); $current_line_order = 0; $current_parent_section = null; if ($obj_current = $db->fetch_object($res_current)) { $current_line_order = $obj_current->line_order; $current_parent_section = $obj_current->parent_section; } // Subtotal der VORHERIGEN Section rendern (wenn Section-Wechsel) if ($last_parent_section[$doc_key] && $last_parent_section[$doc_key] != $current_parent_section) { $sql_subtotal = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_subtotal .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; $sql_subtotal .= " AND document_type = '".$db->escape($docType)."'"; $sql_subtotal .= " AND parent_section = ".(int)$last_parent_section[$doc_key]; $sql_subtotal .= " AND line_type = 'subtotal'"; $resql_subtotal = $db->query($sql_subtotal); if ($obj_sub = $db->fetch_object($resql_subtotal)) { $subtotal_key = 'subtotal_'.$obj_sub->rowid; if (!in_array($subtotal_key, self::$rendered_sections[$doc_key])) { echo $this->renderSubtotalLine($obj_sub); self::$rendered_sections[$doc_key][] = $subtotal_key; if ($this->debug) { error_log('[SubtotalTitle] ✅ Subtotal "'.$obj_sub->title.'" gerendert (Section-Wechsel)'); } } } } // Hole ALLE Sections ZWISCHEN letztem rang und aktuellem rang $sql = "SELECT DISTINCT s.rowid, s.title, s.show_subtotal, s.collapsed, s.line_order, s.in_facturedet,"; $sql .= " (SELECT MIN(fd.rang) FROM ".MAIN_DB_PREFIX."facture_lines_manager m2"; $sql .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." fd ON fd.rowid = m2.".$tables['fk_line']; $sql .= " WHERE m2.parent_section = s.rowid AND m2.document_type = '".$db->escape($docType)."') as first_product_rang"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager s"; $sql .= $this->getDocumentWhere($document_id, $docType, 's'); $sql .= " AND s.line_type = 'section'"; $sql .= " HAVING first_product_rang > ".(int)$last_rang[$doc_key]; $sql .= " AND first_product_rang <= ".(int)$current_rang; $sql .= " ORDER BY first_product_rang"; $resql = $db->query($sql); while ($obj = $db->fetch_object($resql)) { if (!in_array($obj->rowid, self::$rendered_sections[$doc_key])) { $section = array( 'section_id' => $obj->rowid, 'title' => $obj->title, 'show_subtotal' => $obj->show_subtotal, 'collapsed' => $obj->collapsed, 'in_facturedet' => $obj->in_facturedet ); echo $this->renderSectionHeader($section); self::$rendered_sections[$doc_key][] = $obj->rowid; if ($this->debug) { error_log('[SubtotalTitle] ✅ Section "'.$obj->title.'" gerendert vor rang '.$current_rang); } } } // Rendere Textzeilen die VOR dieser Produktzeile kommen $sql_text = "SELECT rowid, title, line_order, in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_text .= $this->getDocumentWhere($document_id, $docType); $sql_text .= " AND line_type = 'text'"; $sql_text .= " AND line_order < ".(int)$current_line_order; $sql_text .= " ORDER BY line_order"; $resql_text = $db->query($sql_text); if ($resql_text) { while ($obj_text = $db->fetch_object($resql_text)) { $text_key = 'text_'.$obj_text->rowid; if (!in_array($text_key, self::$rendered_sections[$doc_key])) { $textline = array( 'id' => $obj_text->rowid, 'title' => $obj_text->title, 'line_order' => $obj_text->line_order, 'in_facturedet' => $obj_text->in_facturedet ); echo $this->renderTextLine($textline); self::$rendered_sections[$doc_key][] = $text_key; if ($this->debug) { error_log('[SubtotalTitle] ✅ Textzeile "'.$obj_text->title.'" gerendert'); } } } } // Merke für nächsten Durchlauf $last_rang[$doc_key] = $current_rang; $last_parent_section[$doc_key] = $current_parent_section; } /** * Rendert eine Textzeile */ private function renderTextLine($textline) { global $db; // In-Facturedet Status $in_facturedet = isset($textline['in_facturedet']) ? $textline['in_facturedet'] : 0; $sync_checked = $in_facturedet ? 'checked' : ''; $in_class = $in_facturedet ? ' in-facturedet' : ''; $html = ''; // Inhalt (colspan=10) $html .= ''; $html .= htmlspecialchars($textline['title']); // Sync-Checkbox (NEU!) - nur im Entwurfsstatus if ($this->isDraft) { $html .= ' '; } $html .= ''; // Edit (Spalte 11) - nur im Entwurfsstatus $html .= ''; if ($this->isDraft) { $html .= ''; $html .= ''; } $html .= ''; // Delete (Spalte 12) - nur im Entwurfsstatus $html .= ''; if ($this->isDraft) { $html .= ''; $html .= ''; } $html .= ''; // Move (Spalte 13) $html .= ''; // Unlink (Spalte 14) $html .= ''; $html .= ''; return $html; } /** * Rendert eine Subtotal-Zeile */ private function renderSubtotalLine($subtotal) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return ''; // Hole in_facturedet Status $sql_sync = "SELECT in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager WHERE rowid = ".(int)$subtotal->rowid; $res_sync = $db->query($sql_sync); $obj_sync = $db->fetch_object($res_sync); $in_facturedet = $obj_sync ? $obj_sync->in_facturedet : 0; $sync_checked = $in_facturedet ? 'checked' : ''; $in_class = $in_facturedet ? ' in-facturedet' : ''; // Berechne aktuelle Summe $sql = "SELECT SUM(d.total_ht) as total"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; $sql .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; $sql .= " WHERE m.parent_section = ".(int)$subtotal->parent_section; $sql .= " AND m.".$tables['fk_parent']." = ".(int)$this->currentDocumentId; $sql .= " AND m.document_type = '".$db->escape($this->currentDocType)."'"; $sql .= " AND m.line_type = 'product'"; $resql = $db->query($sql); $sum = 0; if ($resql && ($obj = $db->fetch_object($resql))) { $sum = $obj->total ? $obj->total : 0; } $formatted = number_format($sum, 2, ',', '.'); $html = ''; $html .= ''; $html .= 'Zwischensumme:'; // Sync-Checkbox (NEU!) - nur im Entwurfsstatus if ($this->isDraft) { $html .= ' '; } $html .= ''; $html .= ''.$formatted.''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; $html .= ''; return $html; } /** * Hook: Wird aufgerufen wenn das Produkt-Hinzufügen-Formular gerendert wird * Fügt Section-Dropdown hinzu */ public function formAddObjectLine($parameters, &$object, &$action, $hookmanager) { global $db; // Erkenne Dokumenttyp $docType = DocumentTypeHelper::getTypeFromObject($object); if (!$docType) { return 0; } // Prüfe Context $expectedContext = DocumentTypeHelper::getContext($docType); if ($parameters['currentcontext'] != $expectedContext) { return 0; } $document_id = $object->id; echo $this->renderSectionDropdown($document_id, $docType); return 0; } /** * Synchronisiert llx_facture_lines_manager mit vorhandenen Zeilen */ private function syncManagerTable($document_id, $docType) { global $db; $tables = DocumentTypeHelper::getTableNames($docType); if (!$tables) return; // 1. CLEANUP: Lösche verwaiste Einträge $sql_cleanup = "DELETE m FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; $sql_cleanup .= " LEFT JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON m.".$tables['fk_line']." = d.rowid"; $sql_cleanup .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id; $sql_cleanup .= " AND m.line_type = 'product'"; $sql_cleanup .= " AND d.rowid IS NULL"; $result = $db->query($sql_cleanup); // 2. Hole alle Produktzeilen des Dokuments $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX.$tables['lines_table']; $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; $sql .= " ORDER BY rang"; $resql = $db->query($sql); while ($obj = $db->fetch_object($resql)) { $sql_check = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_check .= " WHERE ".$tables['fk_line']." = ".(int)$obj->rowid; $resql_check = $db->query($sql_check); if ($db->num_rows($resql_check) == 0) { $next_order = $this->getNextLineOrder($document_id, $docType); // Setze alle FK-Felder explizit (NULL für nicht genutzte) $fk_facture = ($docType === 'invoice') ? (int)$document_id : 'NULL'; $fk_propal = ($docType === 'propal') ? (int)$document_id : 'NULL'; $fk_commande = ($docType === 'order') ? (int)$document_id : 'NULL'; $sql_ins = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_ins .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, ".$tables['fk_line'].", line_order, date_creation)"; $sql_ins .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'product', ".(int)$obj->rowid.", ".$next_order.", NOW())"; $db->query($sql_ins); } } } /** * Holt die Section für eine Produktzeile */ private function getSectionForLine($line_id) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return null; $sql = "SELECT m.parent_section as section_id, s.title, s.show_subtotal, s.collapsed"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."facture_lines_manager s ON s.rowid = m.parent_section"; $sql .= " WHERE m.".$tables['fk_line']." = ".(int)$line_id; $sql .= " AND m.document_type = '".$db->escape($this->currentDocType)."'"; $sql .= " AND m.line_type = 'product'"; $resql = $db->query($sql); if ($obj = $db->fetch_object($resql)) { if ($obj->section_id) { return array( 'section_id' => $obj->section_id, 'title' => $obj->title, 'show_subtotal' => $obj->show_subtotal, 'collapsed' => $obj->collapsed ); } } return null; } /** * Prüft ob dies die erste Zeile einer Section ist */ private function isFirstLineInSection($line_id, $section_id) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return false; $sql = "SELECT line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " WHERE ".$tables['fk_line']." = ".(int)$line_id; $sql .= " AND document_type = '".$db->escape($this->currentDocType)."'"; $resql = $db->query($sql); if (!$resql || !($obj = $db->fetch_object($resql))) { return false; } $current_order = $obj->line_order; $sql = "SELECT MIN(line_order) as min_order"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " WHERE parent_section = ".(int)$section_id; $sql .= " AND line_type = 'product'"; $resql = $db->query($sql); if (!$resql || !($obj = $db->fetch_object($resql))) { return false; } return ($current_order == $obj->min_order); } /** * Rendert einen Section-Header */ private function renderSectionHeader($section) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return ''; // Hole line_order und in_facturedet der Section $sql = "SELECT line_order, in_facturedet FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " WHERE rowid = ".(int)$section['section_id']; $resql = $db->query($sql); $line_order = 0; $in_facturedet = 0; if ($obj = $db->fetch_object($resql)) { $line_order = $obj->line_order; $in_facturedet = $obj->in_facturedet; } $sync_checked = $in_facturedet ? 'checked' : ''; $in_class = $in_facturedet ? ' in-facturedet' : ''; // Hole Produkte dieser Section (IDs + Anzahl) $sql_products = "SELECT ".$tables['fk_line']." FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_products .= " WHERE parent_section = ".(int)$section['section_id']; $sql_products .= " AND ".$tables['fk_parent']." = ".(int)$this->currentDocumentId; $sql_products .= " AND document_type = '".$db->escape($this->currentDocType)."'"; $sql_products .= " AND line_type = 'product'"; $res_products = $db->query($sql_products); $product_ids = []; while ($obj_prod = $db->fetch_object($res_products)) { $fk_line_col = $tables['fk_line']; $product_ids[] = (int)$obj_prod->$fk_line_col; } $product_count = count($product_ids); $product_ids_json = htmlspecialchars(json_encode($product_ids)); $html = ''; // Titel (colspan=10) $html .= ''; $html .= ''; $html .= htmlspecialchars($section['title']); $html .= ''.$product_count.' Produkte'; // Zwischensummen-Checkbox - nur im Entwurfsstatus if ($this->isDraft) { $checked = $section['show_subtotal'] ? 'checked' : ''; $html .= ' '; // Sync-Checkbox (NEU!) $html .= ' '; } $html .= ''; // Move-Buttons - nur im Entwurfsstatus if ($this->isDraft) { $html .= ' '; $html .= ' '; } $html .= ''; // Edit (Spalte 11) - nur im Entwurfsstatus $html .= ''; if ($this->isDraft) { $html .= ''; $html .= ''; } $html .= ''; // Delete (Spalte 12) - nur im Entwurfsstatus $html .= ''; if ($this->isDraft) { if ($product_count == 0) { $html .= ''; $html .= ''; } else { $html .= ''; $html .= ''; } } $html .= ''; // Move (Spalte 13) $html .= ''; // Unlink (Spalte 14) $html .= ''; $html .= ''; return $html; } /** * Rendert Section-Dropdown im Add-Product-Formular */ private function renderSectionDropdown($document_id, $docType) { global $db; $tables = DocumentTypeHelper::getTableNames($docType); if (!$tables) return ''; $sql = "SELECT rowid, title FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; $sql .= " AND document_type = '".$db->escape($docType)."'"; $sql .= " AND line_type = 'section'"; $sql .= " ORDER BY line_order"; $resql = $db->query($sql); $html = ''; return $html; } /** * Fügt ein Produkt zu einer Section hinzu */ private function addProductToSection($doc_key, $line_id, $section_id) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return; if ($this->debug) { error_log('[SubtotalTitle] 🔴 addProductToSection: doc='.$doc_key.', line='.$line_id.', section='.$section_id); } $sql_check = "SELECT rowid, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_check .= " WHERE ".$tables['fk_line']." = ".(int)$line_id; $sql_check .= " AND document_type = '".$db->escape($this->currentDocType)."'"; $resql_check = $db->query($sql_check); if ($db->num_rows($resql_check) > 0) { $obj = $db->fetch_object($resql_check); $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_upd .= " SET parent_section = ".(int)$section_id; $sql_upd .= " WHERE rowid = ".(int)$obj->rowid; $db->query($sql_upd); $this->reorderLines($doc_key); } else { $sql = "SELECT line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " WHERE rowid = ".(int)$section_id; $resql = $db->query($sql); if (!$resql || !($obj = $db->fetch_object($resql))) { return; } $section_order = $obj->line_order; $new_order = $section_order + 1; $sql = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " SET line_order = line_order + 1"; $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$doc_key; $sql .= " AND document_type = '".$db->escape($this->currentDocType)."'"; $sql .= " AND line_order >= ".$new_order; $db->query($sql); $sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " (".$tables['fk_parent'].", document_type, line_type, ".$tables['fk_line'].", parent_section, line_order, date_creation)"; $sql .= " VALUES (".(int)$doc_key.", '".$db->escape($this->currentDocType)."', 'product', ".(int)$line_id.", ".(int)$section_id.", ".$new_order.", NOW())"; $db->query($sql); } } /** * Fügt ein freies Produkt hinzu */ private function addFreeProduct($doc_key, $line_id) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return; if ($this->debug) { error_log('[SubtotalTitle] 🟢 addFreeProduct: doc='.$doc_key.', line='.$line_id.' (parent_section=NULL)'); } $next_order = $this->getNextLineOrder($doc_key, $this->currentDocType); $sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " (".$tables['fk_parent'].", document_type, line_type, ".$tables['fk_line'].", line_order, date_creation)"; $sql .= " VALUES (".(int)$doc_key.", '".$db->escape($this->currentDocType)."', 'product', ".(int)$line_id.", ".$next_order.", NOW())"; $db->query($sql); } /** * Synchronisiert lines_table.rang aus line_order */ private function syncRangFromManager($doc_key) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return; $sql = "SELECT ".$tables['fk_line'].", line_order"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$doc_key; $sql .= " AND document_type = '".$db->escape($this->currentDocType)."'"; $sql .= " AND line_type = 'product'"; $sql .= " ORDER BY line_order"; $resql = $db->query($sql); $rang = 1; $fk_line_col = $tables['fk_line']; while ($obj = $db->fetch_object($resql)) { $sql_upd = "UPDATE ".MAIN_DB_PREFIX.$tables['lines_table']; $sql_upd .= " SET rang = ".$rang; $sql_upd .= " WHERE rowid = ".(int)$obj->$fk_line_col; $db->query($sql_upd); $rang++; } } /** * Holt die nächste freie line_order */ private function getNextLineOrder($document_id, $docType) { global $db; $tables = DocumentTypeHelper::getTableNames($docType); $sql = "SELECT MAX(line_order) as max_order"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= $this->getDocumentWhere($document_id, $docType); $resql = $db->query($sql); if (!$resql || !($obj = $db->fetch_object($resql))) { return 1; } return ($obj->max_order ? $obj->max_order + 1 : 1); } /** * Holt die rowid der letzten Produktzeile */ private function getLastProductLine($doc_key) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return 0; $fk_line_col = $tables['fk_line']; $sql = "SELECT m.".$fk_line_col; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; $sql .= " WHERE m.".$tables['fk_parent']." = ".(int)$doc_key; $sql .= " AND m.document_type = '".$db->escape($this->currentDocType)."'"; $sql .= " AND m.line_type = 'product'"; $sql .= " ORDER BY m.line_order DESC"; $sql .= " LIMIT 1"; $resql = $db->query($sql); if ($obj = $db->fetch_object($resql)) { return $obj->$fk_line_col; } return 0; } /** * Holt alle Sections die keine Produkte haben */ private function getEmptySections($doc_key) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return array(); $sql = "SELECT s.rowid as section_id, s.title, s.show_subtotal, s.collapsed"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager s"; $sql .= " WHERE s.".$tables['fk_parent']." = ".(int)$doc_key; $sql .= " AND s.document_type = '".$db->escape($this->currentDocType)."'"; $sql .= " AND s.line_type = 'section'"; $sql .= " AND NOT EXISTS ("; $sql .= " SELECT 1 FROM ".MAIN_DB_PREFIX."facture_lines_manager p"; $sql .= " WHERE p.parent_section = s.rowid"; $sql .= " AND p.".$tables['fk_parent']." = ".(int)$doc_key; $sql .= " AND p.document_type = '".$db->escape($this->currentDocType)."'"; $sql .= " )"; $sql .= " ORDER BY s.line_order"; $resql = $db->query($sql); $sections = array(); while ($obj = $db->fetch_object($resql)) { $sections[] = array( 'section_id' => $obj->section_id, 'title' => $obj->title, 'show_subtotal' => $obj->show_subtotal, 'collapsed' => $obj->collapsed ); } return $sections; } /** * Sortiert alle Zeilen neu: Schließt nur Lücken, behält Reihenfolge bei */ private function reorderLines($doc_key) { global $db; $tables = DocumentTypeHelper::getTableNames($this->currentDocType); if (!$tables) return; $sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql .= " WHERE ".$tables['fk_parent']." = ".(int)$doc_key; $sql .= " AND document_type = '".$db->escape($this->currentDocType)."'"; $sql .= " ORDER BY line_order"; $resql = $db->query($sql); $new_order = 1; while ($obj = $db->fetch_object($resql)) { $sql_upd = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_upd .= " SET line_order = ".$new_order; $sql_upd .= " WHERE rowid = ".(int)$obj->rowid; $db->query($sql_upd); $new_order++; } } }