Stable Version fertig Multidocument Kundenaufträge Angebot und Rechnungen

This commit is contained in:
Eduard Wisch 2026-01-29 14:55:42 +01:00
parent a1468d359e
commit 51f309f90e
10 changed files with 672 additions and 172 deletions

209
README.md
View file

@ -1,66 +1,41 @@
# SubtotalTitle - Facturedet Sync Update # SubtotalTitle - Dolibarr Modul
## Was ist neu? Erweitert Rechnungen, Angebote und Kundenaufträge um **Sections**, **Textzeilen** und **Zwischensummen**.
- **📄 Checkbox** bei jeder Section/Textzeile/Subtotal: Element zur Rechnung hinzufügen
- **→ Zur Rechnung / ← Aus Rechnung** Buttons: Alle Elemente auf einmal
- **ODT-Variablen** für formatierte Ausgabe im PDF/ODT
## Installation ---
### 1. Dateien kopieren ## 🔑 ODT Template Schlüsselwörter
``` Diese Variablen stehen in ODT-Templates zur Verfügung:
subtotaltitle_complete/
├── class/
│ └── actions_subtotaltitle.class.php → ERSETZEN
├── ajax/
│ └── sync_to_facturedet.php → NEU
├── js/
│ └── subtotaltitle_sync.js → NEU
├── css/
│ └── subtotaltitle_sync.css → Inhalt zu deiner CSS hinzufügen
└── core/
└── substitutions/
└── functions_subtotaltitle.lib.php → NEU (Ordner ggf. erstellen!)
```
### 2. Modul-Descriptor anpassen | Variable | Wert | Beschreibung |
|----------|------|--------------|
| `{line_is_section}` | 1/0 | Zeile ist eine Section (Überschrift) |
| `{line_is_textline}` | 1/0 | Zeile ist eine Textzeile |
| `{line_is_subtotal}` | 1/0 | Zeile ist eine Zwischensumme |
| `{line_is_product}` | 1/0 | Zeile ist ein Produkt (inkl. Sections/Text/Subtotals) |
| `{line_is_normal}` | 1/0 | Zeile ist ein normales Produkt (KEIN Section/Text/Subtotal) |
| `{line_is_special}` | 1/0 | Zeile ist Section, Text ODER Subtotal |
| `{line_special_code}` | 0-102 | special_code Wert der Zeile |
In `core/modules/modSubtotalTitle.class.php` ändern: ### special_code Werte
```php | Typ | special_code |
$this->module_parts = array( |-----|-------------|
'substitutions' => 1, // ← Diese Zeile hinzufügen/ändern! | Normales Produkt | 0 |
'hooks' => array( | Section (Überschrift) | 100 |
'data' => array('invoicecard'), | Textzeile | 101 |
'entity' => '0' | Zwischensumme | 102 |
)
);
```
### 3. Modul deaktivieren und wieder aktivieren ### ODT Template Beispiel
Damit die Substitution-Funktion erkannt wird.
## Verwendung
### In der Rechnungsansicht
| Element | Checkbox | Bedeutung |
|---------|----------|-----------|
| Section | 📄 | Zur Rechnung hinzufügen |
| Textzeile | 📄 | Zur Rechnung hinzufügen |
| Subtotal | 📄 | Zur Rechnung hinzufügen |
**Grüner Rand** = Element ist in der Rechnung/PDF enthalten
### Im ODT-Template
``` ```
[!-- BEGIN row.lines --] [!-- BEGIN row.lines --]
[!-- IF {line_is_section} --] [!-- IF {line_is_section} --]
═══════════════════════════════════════
{line_desc} {line_desc}
═══════════════════════════════════════
[!-- ENDIF {line_is_section} --] [!-- ENDIF {line_is_section} --]
[!-- IF {line_is_textline} --] [!-- IF {line_is_textline} --]
@ -72,17 +47,139 @@ Damit die Substitution-Funktion erkannt wird.
[!-- ENDIF {line_is_normal} --] [!-- ENDIF {line_is_normal} --]
[!-- IF {line_is_subtotal} --] [!-- IF {line_is_subtotal} --]
───────────────────────────────────────
Zwischensumme: {line_price_ht_locale} € Zwischensumme: {line_price_ht_locale} €
───────────────────────────────────────
[!-- ENDIF {line_is_subtotal} --] [!-- ENDIF {line_is_subtotal} --]
[!-- END row.lines --] [!-- END row.lines --]
``` ```
## special_code Werte ---
| Typ | special_code | ODT-Variable | ## 📋 Modul-Funktionen
|-----|-------------|--------------|
| Normales Produkt | 0 | `{line_is_normal}` | ### Sections (Überschriften)
| Section | 100 | `{line_is_section}` | - Erstellen von Überschriften zur Strukturierung
| Textzeile | 101 | `{line_is_textline}` | - Produkte können per Drag & Drop oder Link-Button zugeordnet werden
| Zwischensumme | 102 | `{line_is_subtotal}` | - Optional: Zwischensumme für jede Section anzeigen
- Ein-/Ausklappen von Sections
### Textzeilen
- Freie Textzeilen ohne Preis
- Ideal für Hinweise, Bedingungen oder Erklärungen
### Zwischensummen
- Automatische Berechnung der Summe aller Produkte in einer Section
- Checkbox zum Ein-/Ausschalten pro Section
### Dokument-Synchronisation
- **📄 Checkbox** bei jeder Section/Textzeile/Subtotal: Element zum Dokument hinzufügen
- **→ Zum Dokument / ← Aus Dokument** Buttons: Alle Elemente auf einmal synchronisieren
- **Grüner Rand** = Element ist im Dokument/PDF enthalten
- Buttons werden nur angezeigt wenn Sections oder Textzeilen vorhanden sind
### Unterstützte Dokumenttypen
- Rechnungen (Factures)
- Angebote (Propals)
- Kundenaufträge (Commandes)
---
## 🔧 Installation
### 1. Dateien kopieren
```
htdocs/custom/subtotaltitle/
├── class/
│ ├── actions_subtotaltitle.class.php
│ └── DocumentTypeHelper.class.php
├── ajax/
│ ├── add_to_section.php
│ ├── assign_last_product.php
│ ├── cleanup_subtotals.php
│ ├── create_section.php
│ ├── create_textline.php
│ ├── get_textlines.php
│ ├── move_section.php
│ ├── reorder_all.php
│ ├── sync_to_facturedet.php
│ └── toggle_subtotal.php
├── core/
│ ├── modules/
│ │ └── modSubtotalTitle.class.php
│ └── substitutions/
│ └── functions_subtotaltitle.lib.php
├── js/
│ └── subtotaltitle.js
├── css/
│ └── subtotaltitle.css
├── lib/
│ └── subtotaltitle.lib.php
└── sql/
└── llx_facture_lines_manager.sql
```
### 2. Modul aktivieren
Im Dolibarr Backend unter **Home → Setup → Modules** das Modul **SubtotalTitle** aktivieren.
Die Datenbanktabelle `llx_facture_lines_manager` wird automatisch erstellt.
### 3. Modul-Konfiguration
In `core/modules/modSubtotalTitle.class.php` muss folgendes gesetzt sein:
```php
$this->module_parts = array(
'substitutions' => 1, // Für ODT-Variablen
'hooks' => array(
'data' => array('invoicecard', 'propalcard', 'ordercard'),
'entity' => '0'
)
);
```
---
## 🗄️ Datenbank
### Tabelle: llx_facture_lines_manager
| Feld | Typ | Beschreibung |
|------|-----|--------------|
| rowid | INT | Primary Key |
| fk_facture | INT | FK zu Rechnung |
| fk_propal | INT | FK zu Angebot |
| fk_commande | INT | FK zu Kundenauftrag |
| document_type | VARCHAR(20) | 'invoice', 'propal', 'order' |
| line_type | VARCHAR(20) | 'section', 'product', 'text', 'subtotal' |
| fk_facturedet | INT | FK zu llx_facturedet |
| fk_propaldet | INT | FK zu llx_propaldet |
| fk_commandedet | INT | FK zu llx_commandedet |
| title | VARCHAR(255) | Titel für Sections/Text |
| parent_section | INT | FK zur übergeordneten Section |
| line_order | INT | Sortierreihenfolge |
| show_subtotal | TINYINT | Zwischensumme anzeigen (0/1) |
| collapsed | TINYINT | Section eingeklappt (0/1) |
| in_facturedet | TINYINT | Im Dokument enthalten (0/1) |
---
## 📝 Changelog
### Version 1.0
- Initiale Version mit Section-, Text- und Subtotal-Unterstützung
- ODT-Substitutionsvariablen
- Multi-Dokument-Support (Rechnungen, Angebote, Kundenaufträge)
- Drag & Drop Sortierung
- Dokument-Synchronisation
---
## 📄 Lizenz
Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
Dieses Programm ist freie Software: Sie können es unter den Bedingungen der GNU General Public License, wie von der Free Software Foundation veröffentlicht, weitergeben und/oder modifizieren, entweder Version 3 der Lizenz oder (nach Ihrer Wahl) jede spätere Version.

View file

@ -58,38 +58,40 @@ if (!$resql_section || $db->num_rows($resql_section) == 0) {
$section = $db->fetch_object($resql_section); $section = $db->fetch_object($resql_section);
$section_line_order = $section->line_order; $section_line_order = $section->line_order;
// 3. Finde die neue line_order für das Produkt (nach der Section, vor anderen Produkten der Section) // 3. Finde die neue line_order für das Produkt
$sql_next = "SELECT MIN(line_order) as next_order FROM ".MAIN_DB_PREFIX."facture_lines_manager"; // Das Produkt soll IMMER als LETZTES Produkt der Section eingefügt werden
$sql_next .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; // (nach allen bestehenden Produkten dieser Section, aber vor der nächsten Section oder dem Subtotal)
$sql_next .= " AND document_type = '".$db->escape($docType)."'";
$sql_next .= " AND parent_section = ".(int)$section_id;
$sql_next .= " AND line_type IN ('product', 'text')";
$resql_next = $db->query($sql_next);
$obj_next = $db->fetch_object($resql_next);
if ($obj_next && $obj_next->next_order) { // Suche das letzte Produkt/Text dieser Section
// Es gibt bereits Produkte in dieser Section → füge VOR dem ersten ein $sql_last = "SELECT MAX(line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$new_line_order = $obj_next->next_order; $sql_last .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
$sql_last .= " AND document_type = '".$db->escape($docType)."'";
$sql_last .= " AND parent_section = ".(int)$section_id;
$sql_last .= " AND line_type IN ('product', 'text')";
$sql_last .= " AND rowid != ".(int)$manager_id; // Nicht das aktuelle Produkt selbst
$resql_last = $db->query($sql_last);
$obj_last = $db->fetch_object($resql_last);
if ($obj_last && $obj_last->max_order) {
// Es gibt bereits Produkte in dieser Section → füge NACH dem letzten ein
$new_line_order = (int)$obj_last->max_order + 1;
} else { } else {
// Keine Produkte in der Section → füge direkt nach der Section ein // Keine anderen Produkte in der Section → füge direkt nach der Section ein
$new_line_order = $section_line_order + 1; $new_line_order = $section_line_order + 1;
} }
subtotaltitle_debug_log(' current_line_order='.$current_line_order.', section_line_order='.$section_line_order.', new_line_order='.$new_line_order); subtotaltitle_debug_log(' current_line_order='.$current_line_order.', section_line_order='.$section_line_order.', new_line_order='.$new_line_order);
// 4. Verschiebe Zeilen je nach Richtung // 4. Verschiebe Zeilen
if ($current_line_order > $new_line_order) { // WICHTIG: Zuerst das Produkt "entfernen" (temporär auf -1 setzen), dann verschieben, dann einfügen
// Nach vorne: Verschiebe Zeilen von new_line_order bis BEFORE current um +1 $sql_temp = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_shift = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_temp .= " SET line_order = -1";
$sql_shift .= " SET line_order = line_order + 1"; $sql_temp .= " WHERE rowid = ".(int)$manager_id;
$sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; $db->query($sql_temp);
$sql_shift .= " AND document_type = '".$db->escape($docType)."'";
$sql_shift .= " AND line_order >= ".(int)$new_line_order; if ($current_line_order < $new_line_order) {
$sql_shift .= " AND line_order < ".(int)$current_line_order; // Produkt wird nach hinten verschoben
$db->query($sql_shift); // Schließe die Lücke: alle Zeilen zwischen current+1 und new_line_order um -1 verschieben
subtotaltitle_debug_log(' Nach vorne verschoben: Zeilen '.$new_line_order.'-'.($current_line_order-1).' um +1');
} elseif ($current_line_order < $new_line_order) {
// Nach hinten: Verschiebe Zeilen von current+1 bis new um -1
$sql_shift = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_shift = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_shift .= " SET line_order = line_order - 1"; $sql_shift .= " SET line_order = line_order - 1";
$sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id; $sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
@ -97,9 +99,21 @@ if ($current_line_order > $new_line_order) {
$sql_shift .= " AND line_order > ".(int)$current_line_order; $sql_shift .= " AND line_order > ".(int)$current_line_order;
$sql_shift .= " AND line_order <= ".(int)$new_line_order; $sql_shift .= " AND line_order <= ".(int)$new_line_order;
$db->query($sql_shift); $db->query($sql_shift);
subtotaltitle_debug_log(' Nach hinten verschoben: Zeilen '.($current_line_order+1).'-'.$new_line_order.' um -1'); // Korrigiere new_line_order (weil wir verschoben haben)
$new_line_order = $new_line_order - 1;
subtotaltitle_debug_log(' Nach hinten: Lücke geschlossen, new_line_order korrigiert auf '.$new_line_order);
} elseif ($current_line_order > $new_line_order) {
// Produkt wird nach vorne verschoben
// Mache Platz: alle Zeilen ab new_line_order bis current-1 um +1 verschieben
$sql_shift = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_shift .= " SET line_order = line_order + 1";
$sql_shift .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
$sql_shift .= " AND document_type = '".$db->escape($docType)."'";
$sql_shift .= " AND line_order >= ".(int)$new_line_order;
$sql_shift .= " AND line_order < ".(int)$current_line_order;
$db->query($sql_shift);
subtotaltitle_debug_log(' Nach vorne: Platz gemacht ab position '.$new_line_order);
} }
// Wenn current == new, keine Verschiebung nötig
// 5. Update das Produkt: setze parent_section und neue line_order // 5. Update das Produkt: setze parent_section und neue line_order
$sql_update = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_update = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager";
@ -108,9 +122,26 @@ $sql_update .= ", line_order = ".(int)$new_line_order;
$sql_update .= " WHERE rowid = ".(int)$manager_id; $sql_update .= " WHERE rowid = ".(int)$manager_id;
$db->query($sql_update); $db->query($sql_update);
subtotaltitle_debug_log('✅ Produkt #'.$line_id.' zu Section #'.$section_id.' hinzugefügt'); subtotaltitle_debug_log('✅ Produkt #'.$line_id.' zu Section #'.$section_id.' hinzugefügt mit line_order='.$new_line_order);
// 6. Sync rang in Detail-Tabelle // 6. Normalisiere alle line_order (keine Lücken, keine Duplikate)
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
$sql .= " AND document_type = '".$db->escape($docType)."'";
$sql .= " ORDER BY line_order, rowid"; // Bei Gleichstand nach rowid sortieren
$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++;
}
subtotaltitle_debug_log(' line_order normalisiert: '.($new_order-1).' Zeilen');
// 7. Sync rang in Detail-Tabelle
subtotaltitle_debug_log(' Starte rang-Synchronisation für docType='.$docType.' doc_id='.$document_id); subtotaltitle_debug_log(' Starte rang-Synchronisation für docType='.$docType.' doc_id='.$document_id);
$sql = "SELECT rowid, line_type, ".$tables['fk_line']." as detail_id FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql = "SELECT rowid, line_type, ".$tables['fk_line']." as detail_id FROM ".MAIN_DB_PREFIX."facture_lines_manager";

View file

@ -32,8 +32,8 @@ $sql_fix_sections .= " WHERE line_type = 'section'";
$sql_fix_sections .= " AND parent_section IS NOT NULL"; $sql_fix_sections .= " AND parent_section IS NOT NULL";
$sql_fix_sections .= " AND ".$tables['fk_parent']." = ".(int)$facture_id; $sql_fix_sections .= " AND ".$tables['fk_parent']." = ".(int)$facture_id;
$sql_fix_sections .= " AND document_type = '".$db->escape($docType)."'"; $sql_fix_sections .= " AND document_type = '".$db->escape($docType)."'";
$db->query($sql_fix_sections); $resql_fix = $db->query($sql_fix_sections);
$sections_fixed = $db->affected_rows(); $sections_fixed = $resql_fix ? $db->affected_rows($resql_fix) : 0;
if ($sections_fixed > 0) { if ($sections_fixed > 0) {
subtotaltitle_debug_log('🧹 ' . $sections_fixed . ' Sections mit falscher parent_section korrigiert'); subtotaltitle_debug_log('🧹 ' . $sections_fixed . ' Sections mit falscher parent_section korrigiert');
$fixed += $sections_fixed; $fixed += $sections_fixed;
@ -45,8 +45,8 @@ $sql_fix_zero .= " SET parent_section = NULL";
$sql_fix_zero .= " WHERE parent_section = 0"; $sql_fix_zero .= " WHERE parent_section = 0";
$sql_fix_zero .= " AND ".$tables['fk_parent']." = ".(int)$facture_id; $sql_fix_zero .= " AND ".$tables['fk_parent']." = ".(int)$facture_id;
$sql_fix_zero .= " AND document_type = '".$db->escape($docType)."'"; $sql_fix_zero .= " AND document_type = '".$db->escape($docType)."'";
$db->query($sql_fix_zero); $resql_fix_zero = $db->query($sql_fix_zero);
$zero_fixed = $db->affected_rows(); $zero_fixed = $resql_fix_zero ? $db->affected_rows($resql_fix_zero) : 0;
if ($zero_fixed > 0) { if ($zero_fixed > 0) {
subtotaltitle_debug_log('🧹 ' . $zero_fixed . ' Einträge mit parent_section=0 korrigiert'); subtotaltitle_debug_log('🧹 ' . $zero_fixed . ' Einträge mit parent_section=0 korrigiert');
$fixed += $zero_fixed; $fixed += $zero_fixed;

View file

@ -41,7 +41,7 @@ $fk_commande = ($docType === 'order') ? (int)$facture_id : 'NULL';
$sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager"; $sql = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager";
$sql .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, title, line_order, in_facturedet, date_creation)"; $sql .= " (fk_facture, fk_propal, fk_commande, document_type, line_type, title, line_order, in_facturedet, date_creation)";
$sql .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'text', '".$db->escape($text)."', ".$next_order.", 1, NOW())"; $sql .= " VALUES (".$fk_facture.", ".$fk_propal.", ".$fk_commande.", '".$db->escape($docType)."', 'text', '".$db->escape($text)."', ".$next_order.", 0, NOW())";
if ($db->query($sql)) { if ($db->query($sql)) {
$new_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager"); $new_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager");

View file

@ -98,6 +98,37 @@ foreach ($subtotals_to_update as $subtotal) {
} }
} }
// ========== SECTIONS VOR IHREN PRODUKTEN SICHERSTELLEN ==========
subtotaltitle_debug_log('🔧 Stelle sicher dass Sections vor ihren Produkten stehen...');
// Für jede Section: Wenn ein Produkt dieser Section VOR der Section steht,
// verschiebe die Section vor das erste Produkt
$sql_sections = "SELECT rowid, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager
WHERE ".$tables['fk_parent']." = ".(int)$facture_id."
AND document_type = '".$db->escape($docType)."'
AND line_type = 'section'";
$res_sections = $db->query($sql_sections);
while ($section = $db->fetch_object($res_sections)) {
// Finde das Produkt dieser Section mit der kleinsten line_order
$sql_first_prod = "SELECT MIN(line_order) as min_order FROM ".MAIN_DB_PREFIX."facture_lines_manager
WHERE parent_section = ".(int)$section->rowid."
AND document_type = '".$db->escape($docType)."'
AND line_type = 'product'";
$res_first = $db->query($sql_first_prod);
$first_prod = $db->fetch_object($res_first);
if ($first_prod && $first_prod->min_order && $first_prod->min_order < $section->line_order) {
// Section steht NACH ihrem ersten Produkt - verschiebe sie davor
$new_section_order = (float)$first_prod->min_order - 0.5;
$sql_move = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager
SET line_order = ".$new_section_order."
WHERE rowid = ".(int)$section->rowid;
$db->query($sql_move);
subtotaltitle_debug_log(' Section #'.$section->rowid.' verschoben: '.$section->line_order.' → '.$new_section_order.' (vor Produkt mit order='.$first_prod->min_order.')');
}
}
// ========== ALLES NEU DURCHNUMMERIEREN ========== // ========== ALLES NEU DURCHNUMMERIEREN ==========
subtotaltitle_debug_log('🔢 Normalisiere line_order...'); subtotaltitle_debug_log('🔢 Normalisiere line_order...');

View file

@ -141,41 +141,17 @@ if ($action == 'add') {
// Bestimme rang (Position) - UNTERSCHIEDLICH für Sections vs andere Zeilen // Bestimme rang (Position) - UNTERSCHIEDLICH für Sections vs andere Zeilen
$new_rang = 1; $new_rang = 1;
if ($line_type == 'section') { // EINHEITLICHE BERECHNUNG für ALLE Zeilentypen basierend auf line_order
// Für Sections: Finde das erste Produkt dieser Section und füge Section DAVOR ein // Finde den höchsten rang aller Zeilen, die VOR dieser Zeile (nach line_order) liegen
$sql_first_product = "SELECT MIN(d.rang) as min_rang"; $sql_rang = "SELECT MAX(d.rang) as max_rang";
$sql_first_product .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; $sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sql_first_product .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; $sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line'];
$sql_first_product .= " WHERE m.parent_section = ".(int)$line_id; $sql_rang .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id;
$sql_first_product .= " AND m.".$tables['fk_parent']." = ".(int)$document_id; $sql_rang .= " AND m.document_type = '".$db->escape($docType)."'";
$sql_first_product .= " AND m.document_type = '".$db->escape($docType)."'"; $sql_rang .= " AND m.line_order < ".(int)$line->line_order;
$sql_first_product .= " AND m.line_type = 'product'"; $res_rang = $db->query($sql_rang);
$res_first = $db->query($sql_first_product); $obj_rang = $db->fetch_object($res_rang);
$obj_first = $db->fetch_object($res_first); $new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1;
if ($obj_first && $obj_first->min_rang) {
// Section VOR dem ersten Produkt einfügen
$new_rang = (int)$obj_first->min_rang;
} else {
// Keine Produkte in dieser Section - ans Ende
$sql_max = "SELECT MAX(rang) as max_rang FROM ".MAIN_DB_PREFIX.$tables['lines_table'];
$sql_max .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
$res_max = $db->query($sql_max);
$obj_max = $db->fetch_object($res_max);
$new_rang = ($obj_max && $obj_max->max_rang) ? $obj_max->max_rang + 1 : 1;
}
} else {
// Für Text/Subtotal: Basierend auf line_order Position
$sql_rang = "SELECT MAX(d.rang) as max_rang";
$sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line'];
$sql_rang .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id;
$sql_rang .= " AND m.document_type = '".$db->escape($docType)."'";
$sql_rang .= " AND m.line_order < ".(int)$line->line_order;
$res_rang = $db->query($sql_rang);
$obj_rang = $db->fetch_object($res_rang);
$new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1;
}
subtotaltitle_debug_log('📝 Berechne rang: line_type='.$line_type.', new_rang='.$new_rang); subtotaltitle_debug_log('📝 Berechne rang: line_type='.$line_type.', new_rang='.$new_rang);

View file

@ -63,8 +63,17 @@ if ($show) {
$res_last = $db->query($sql_last); $res_last = $db->query($sql_last);
$obj_last = $db->fetch_object($res_last); $obj_last = $db->fetch_object($res_last);
$last_order = $obj_last->max_order ? $obj_last->max_order : 0; $last_order = $obj_last->max_order ? $obj_last->max_order : 0;
// Neue line_order = nach letztem Produkt // Wenn keine Produkte in der Section, nimm die line_order der Section selbst
if ($last_order == 0) {
$sql_sec = "SELECT line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_sec .= " WHERE rowid = ".(int)$section_id;
$res_sec = $db->query($sql_sec);
$obj_sec = $db->fetch_object($res_sec);
$last_order = $obj_sec->line_order ? $obj_sec->line_order : 0;
}
// Neue line_order = nach letztem Produkt (oder nach der Section selbst wenn leer)
$new_order = $last_order + 1; $new_order = $last_order + 1;
// Alle nachfolgenden Zeilen +1 // Alle nachfolgenden Zeilen +1
@ -88,13 +97,14 @@ if ($show) {
$subtotal_manager_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager"); $subtotal_manager_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager");
// Subtotal-Zeile auch direkt in Detail-Tabelle einfügen // Subtotal-Zeile auch direkt in Detail-Tabelle einfügen
// Bestimme rang (nach letztem Produkt der Section) // Bestimme rang basierend auf line_order (EINHEITLICH für alle Zeilentypen)
// Finde den höchsten rang aller Zeilen, die VOR dieser neuen Subtotal-Zeile liegen
$sql_rang = "SELECT MAX(d.rang) as max_rang"; $sql_rang = "SELECT MAX(d.rang) as max_rang";
$sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m"; $sql_rang .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line']; $sql_rang .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." d ON d.rowid = m.".$tables['fk_line'];
$sql_rang .= " WHERE m.parent_section = ".(int)$section_id; $sql_rang .= " WHERE m.".$tables['fk_parent']." = ".(int)$facture_id;
$sql_rang .= " AND m.document_type = '".$db->escape($docType)."'"; $sql_rang .= " AND m.document_type = '".$db->escape($docType)."'";
$sql_rang .= " AND m.line_type = 'product'"; $sql_rang .= " AND m.line_order < ".(int)$new_order;
$res_rang = $db->query($sql_rang); $res_rang = $db->query($sql_rang);
$obj_rang = $db->fetch_object($res_rang); $obj_rang = $db->fetch_object($res_rang);
$new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1; $new_rang = ($obj_rang && $obj_rang->max_rang) ? $obj_rang->max_rang + 1 : 1;
@ -109,7 +119,8 @@ if ($show) {
// Füge Subtotal in Detail-Tabelle ein (special_code = 102) // Füge Subtotal in Detail-Tabelle ein (special_code = 102)
$sql_ins_fd = "INSERT INTO ".MAIN_DB_PREFIX.$tables['lines_table']; $sql_ins_fd = "INSERT INTO ".MAIN_DB_PREFIX.$tables['lines_table'];
$sql_ins_fd .= " (".$tables['fk_parent'].", description, qty, subprice, total_ht, total_tva, total_ttc,"; $sql_ins_fd .= " (".$tables['fk_parent'].", description, qty, subprice, total_ht, total_tva, total_ttc,";
$sql_ins_fd .= " tva_tx, product_type, special_code, rang, info_bits)"; $sql_ins_fd .= " tva_tx, product_type, special_code, rang, info_bits,";
$sql_ins_fd .= " multicurrency_subprice, multicurrency_total_ht, multicurrency_total_tva, multicurrency_total_ttc)";
$sql_ins_fd .= " VALUES ("; $sql_ins_fd .= " VALUES (";
$sql_ins_fd .= (int)$facture_id.", "; $sql_ins_fd .= (int)$facture_id.", ";
$sql_ins_fd .= "'".$db->escape('Zwischensumme: '.$section->title)."', "; $sql_ins_fd .= "'".$db->escape('Zwischensumme: '.$section->title)."', ";
@ -122,7 +133,11 @@ if ($show) {
$sql_ins_fd .= "9, "; // product_type = 9 (Titel/Kommentar) $sql_ins_fd .= "9, "; // product_type = 9 (Titel/Kommentar)
$sql_ins_fd .= "102, "; // special_code = 102 (Subtotal) $sql_ins_fd .= "102, "; // special_code = 102 (Subtotal)
$sql_ins_fd .= (int)$new_rang.", "; $sql_ins_fd .= (int)$new_rang.", ";
$sql_ins_fd .= "0)"; $sql_ins_fd .= "0, "; // info_bits
$sql_ins_fd .= (float)$subtotal_ht.", "; // multicurrency_subprice
$sql_ins_fd .= (float)$subtotal_ht.", "; // multicurrency_total_ht
$sql_ins_fd .= "0, "; // multicurrency_total_tva
$sql_ins_fd .= (float)$subtotal_ht.")";
$db->query($sql_ins_fd); $db->query($sql_ins_fd);
$new_detail_id = $db->last_insert_id(MAIN_DB_PREFIX.$tables['lines_table']); $new_detail_id = $db->last_insert_id(MAIN_DB_PREFIX.$tables['lines_table']);

View file

@ -142,11 +142,14 @@ class ActionsSubtotalTitle extends CommonHookActions
// Lade Übersetzungen // Lade Übersetzungen
$langs->load('subtotaltitle@subtotaltitle'); $langs->load('subtotaltitle@subtotaltitle');
// Prüfe ob Sections existieren (für Collapse-Buttons) // Prüfe ob Sections oder Textzeilen existieren (für Buttons)
global $db; global $db;
$tables = DocumentTypeHelper::getTableNames($this->currentDocType); $tables = DocumentTypeHelper::getTableNames($this->currentDocType);
$hasSections = false; $hasSections = false;
$hasTextLines = false;
$hasSectionsOrTextLines = false;
if ($tables && $object->id) { if ($tables && $object->id) {
// Prüfe Sections
$sql_sec = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_sec = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_sec .= " WHERE ".$tables['fk_parent']." = ".(int)$object->id; $sql_sec .= " WHERE ".$tables['fk_parent']." = ".(int)$object->id;
$sql_sec .= " AND document_type = '".$db->escape($this->currentDocType)."'"; $sql_sec .= " AND document_type = '".$db->escape($this->currentDocType)."'";
@ -155,6 +158,18 @@ class ActionsSubtotalTitle extends CommonHookActions
if ($res_sec && $obj_sec = $db->fetch_object($res_sec)) { if ($res_sec && $obj_sec = $db->fetch_object($res_sec)) {
$hasSections = ($obj_sec->cnt > 0); $hasSections = ($obj_sec->cnt > 0);
} }
// Prüfe Textzeilen
$sql_text = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_text .= " WHERE ".$tables['fk_parent']." = ".(int)$object->id;
$sql_text .= " AND document_type = '".$db->escape($this->currentDocType)."'";
$sql_text .= " AND line_type = 'text'";
$res_text = $db->query($sql_text);
if ($res_text && $obj_text = $db->fetch_object($res_text)) {
$hasTextLines = ($obj_text->cnt > 0);
}
$hasSectionsOrTextLines = ($hasSections || $hasTextLines);
} }
// CSS // CSS
@ -247,20 +262,23 @@ class ActionsSubtotalTitle extends CommonHookActions
echo '});</script>'."\n"; echo '});</script>'."\n";
// Sync-Buttons + Collapse-Buttons - rechts ausgerichtet // Sync-Buttons + Collapse-Buttons - rechts ausgerichtet
echo '<script>$(document).ready(function() {'; // Nur anzeigen wenn Sections oder Textzeilen vorhanden sind
echo ' if ($(".sync-collapse-row").length === 0) {'; if ($hasSectionsOrTextLines) {
echo ' var buttons = \'<div class="sync-collapse-row" style="text-align:right; margin:5px 0;">\';'; echo '<script>$(document).ready(function() {';
echo ' buttons += \'<a class="button" href="#" onclick="syncAllToFacturedet(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonToInvoice + \'</a>\';'; echo ' if ($(".sync-collapse-row").length === 0) {';
echo ' buttons += \'<a class="button" href="#" onclick="removeAllFromFacturedet(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonFromInvoice + \'</a>\';'; echo ' var buttons = \'<div class="sync-collapse-row" style="text-align:right; margin:5px 0;">\';';
// Collapse-Buttons nur wenn Sections existieren (aus PHP) echo ' buttons += \'<a class="button" href="#" onclick="syncAllToFacturedet(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonToInvoice + \'</a>\';';
if ($hasSections) { echo ' buttons += \'<a class="button" href="#" onclick="removeAllFromFacturedet(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonFromInvoice + \'</a>\';';
echo ' buttons += \'<a class="button" href="#" onclick="expandAllSections(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonExpandAll + \'</a>\';'; // Collapse-Buttons nur wenn Sections existieren (aus PHP)
echo ' buttons += \'<a class="button" href="#" onclick="collapseAllSections(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonCollapseAll + \'</a>\';'; if ($hasSections) {
echo ' buttons += \'<a class="button" href="#" onclick="expandAllSections(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonExpandAll + \'</a>\';';
echo ' buttons += \'<a class="button" href="#" onclick="collapseAllSections(); return false;" style="margin-left:5px;">\' + subtotalTitleLang.buttonCollapseAll + \'</a>\';';
}
echo ' buttons += \'</div>\';';
echo ' $(".tabsAction").first().after(buttons);';
echo ' }';
echo '});</script>'."\n";
} }
echo ' buttons += \'</div>\';';
echo ' $(".tabsAction").first().after(buttons);';
echo ' }';
echo '});</script>'."\n";
} }
} }
@ -646,12 +664,14 @@ class ActionsSubtotalTitle extends CommonHookActions
static $last_rang = array(); static $last_rang = array();
static $last_parent_section = array(); static $last_parent_section = array();
static $last_line_order = array();
$doc_key = $docType.'_'.$document_id; $doc_key = $docType.'_'.$document_id;
if (!isset(self::$rendered_sections[$doc_key])) { if (!isset(self::$rendered_sections[$doc_key])) {
self::$rendered_sections[$doc_key] = array(); self::$rendered_sections[$doc_key] = array();
$last_rang[$doc_key] = 0; $last_rang[$doc_key] = 0;
$last_parent_section[$doc_key] = null; $last_parent_section[$doc_key] = null;
$last_line_order[$doc_key] = 0;
} }
// Hole rang dieser Produktzeile // Hole rang dieser Produktzeile
@ -710,7 +730,8 @@ class ActionsSubtotalTitle extends CommonHookActions
$sql_combined .= " (SELECT MIN(fd.rang) FROM ".MAIN_DB_PREFIX."facture_lines_manager m2"; $sql_combined .= " (SELECT MIN(fd.rang) FROM ".MAIN_DB_PREFIX."facture_lines_manager m2";
$sql_combined .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." fd ON fd.rowid = m2.".$tables['fk_line']; $sql_combined .= " INNER JOIN ".MAIN_DB_PREFIX.$tables['lines_table']." fd ON fd.rowid = m2.".$tables['fk_line'];
$sql_combined .= " WHERE m2.parent_section = ".MAIN_DB_PREFIX."facture_lines_manager.rowid"; $sql_combined .= " WHERE m2.parent_section = ".MAIN_DB_PREFIX."facture_lines_manager.rowid";
$sql_combined .= " AND m2.document_type = '".$db->escape($docType)."') as first_product_rang"; $sql_combined .= " AND m2.document_type = '".$db->escape($docType)."'";
$sql_combined .= " AND m2.line_type = 'product') as first_product_rang"; // NUR echte Produkte, keine Subtotals!
$sql_combined .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager"; $sql_combined .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_combined .= $this->getDocumentWhere($document_id, $docType, ''); $sql_combined .= $this->getDocumentWhere($document_id, $docType, '');
$sql_combined .= " AND (line_type = 'section' OR line_type = 'text')"; $sql_combined .= " AND (line_type = 'section' OR line_type = 'text')";
@ -720,25 +741,113 @@ class ActionsSubtotalTitle extends CommonHookActions
while ($obj = $db->fetch_object($resql_combined)) { while ($obj = $db->fetch_object($resql_combined)) {
if ($obj->line_type == 'section') { if ($obj->line_type == 'section') {
// Section nur rendern wenn first_product_rang passt // Section rendern wenn:
if ($obj->first_product_rang > (int)$last_rang[$doc_key] && $obj->first_product_rang <= (int)$current_rang) { // 1. Sie Produkte hat UND first_product_rang im Bereich liegt, ODER
if (!in_array($obj->rowid, self::$rendered_sections[$doc_key])) { // 2. Sie KEINE Produkte hat (leere Section) UND ihre line_order zwischen last_line_order und current_line_order liegt
$section = array( $should_render = false;
'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) { // DEBUG
error_log('[SubtotalTitle] ✅ Section "'.$obj->title.'" gerendert vor rang '.$current_rang); error_log('[SubtotalTitle] DEBUG Section "'.$obj->title.'": line_order='.$obj->line_order.', first_product_rang='.($obj->first_product_rang ?? 'NULL').', last_line_order='.$last_line_order[$doc_key].', last_rang='.$last_rang[$doc_key].', current_rang='.$current_rang);
if ($obj->first_product_rang !== null) {
// Section mit Produkten: prüfe rang-Bereich
if ($obj->first_product_rang > (int)$last_rang[$doc_key] && $obj->first_product_rang <= (int)$current_rang) {
$should_render = true;
}
} else {
// Leere Section: rendern wenn line_order > last_line_order UND < current_line_order
// (SQL filtert bereits nach line_order < current_line_order)
if ($obj->line_order > (int)$last_line_order[$doc_key]) {
$should_render = true;
error_log('[SubtotalTitle] DEBUG → Leere Section "'.$obj->title.'" SOLLTE gerendert werden');
}
}
if ($should_render && !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.' (leere Section: '.($obj->first_product_rang === null ? 'ja' : 'nein').')');
}
// Für LEERE Sections: Subtotal direkt nach Section-Header rendern (wenn show_subtotal aktiviert)
if ($obj->first_product_rang === null && $obj->show_subtotal) {
$sql_subtotal_empty = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_subtotal_empty .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
$sql_subtotal_empty .= " AND document_type = '".$db->escape($docType)."'";
$sql_subtotal_empty .= " AND parent_section = ".(int)$obj->rowid;
$sql_subtotal_empty .= " AND line_type = 'subtotal'";
$resql_subtotal_empty = $db->query($sql_subtotal_empty);
if ($obj_sub_empty = $db->fetch_object($resql_subtotal_empty)) {
$subtotal_key_empty = 'subtotal_'.$obj_sub_empty->rowid;
if (!in_array($subtotal_key_empty, self::$rendered_sections[$doc_key])) {
echo $this->renderSubtotalLine($obj_sub_empty);
self::$rendered_sections[$doc_key][] = $subtotal_key_empty;
if ($this->debug) {
error_log('[SubtotalTitle] ✅ Subtotal für leere Section "'.$obj->title.'" gerendert');
}
}
} }
} }
} }
} elseif ($obj->line_type == 'text') { } elseif ($obj->line_type == 'text') {
// VOR der Textzeile: Prüfe ob es leere Sections gibt, die noch nicht gerendert wurden
// und deren line_order kleiner ist als die der Textzeile
$sql_empty_sections = "SELECT rowid, title, line_order, show_subtotal, collapsed, in_facturedet,";
$sql_empty_sections .= " (SELECT COUNT(*) FROM ".MAIN_DB_PREFIX."facture_lines_manager m2";
$sql_empty_sections .= " WHERE m2.parent_section = ".MAIN_DB_PREFIX."facture_lines_manager.rowid";
$sql_empty_sections .= " AND m2.document_type = '".$db->escape($docType)."'";
$sql_empty_sections .= " AND m2.line_type = 'product') as product_count";
$sql_empty_sections .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_empty_sections .= $this->getDocumentWhere($document_id, $docType, '');
$sql_empty_sections .= " AND line_type = 'section'";
$sql_empty_sections .= " AND line_order < ".(int)$obj->line_order;
$sql_empty_sections .= " ORDER BY line_order";
$resql_empty = $db->query($sql_empty_sections);
while ($empty_sec = $db->fetch_object($resql_empty)) {
// Nur leere Sections (keine Produkte) die noch nicht gerendert wurden
if ($empty_sec->product_count == 0 && !in_array($empty_sec->rowid, self::$rendered_sections[$doc_key])) {
$section_data = array(
'section_id' => $empty_sec->rowid,
'title' => $empty_sec->title,
'show_subtotal' => $empty_sec->show_subtotal,
'collapsed' => $empty_sec->collapsed,
'in_facturedet' => $empty_sec->in_facturedet
);
echo $this->renderSectionHeader($section_data);
self::$rendered_sections[$doc_key][] = $empty_sec->rowid;
if ($this->debug) {
error_log('[SubtotalTitle] ✅ Leere Section "'.$empty_sec->title.'" vor Textzeile gerendert');
}
// Subtotal für diese Section (wenn aktiviert)
if ($empty_sec->show_subtotal) {
$sql_sub = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_sub .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
$sql_sub .= " AND document_type = '".$db->escape($docType)."'";
$sql_sub .= " AND parent_section = ".(int)$empty_sec->rowid;
$sql_sub .= " AND line_type = 'subtotal'";
$resql_sub = $db->query($sql_sub);
if ($obj_sub = $db->fetch_object($resql_sub)) {
echo $this->renderSubtotalLine($obj_sub);
self::$rendered_sections[$doc_key][] = 'subtotal_'.$obj_sub->rowid;
}
}
}
}
// Textzeile rendern // Textzeile rendern
$text_key = 'text_'.$obj->rowid; $text_key = 'text_'.$obj->rowid;
if (!in_array($text_key, self::$rendered_sections[$doc_key])) { if (!in_array($text_key, self::$rendered_sections[$doc_key])) {
@ -761,6 +870,7 @@ class ActionsSubtotalTitle extends CommonHookActions
// Merke für nächsten Durchlauf // Merke für nächsten Durchlauf
$last_rang[$doc_key] = $current_rang; $last_rang[$doc_key] = $current_rang;
$last_parent_section[$doc_key] = $current_parent_section; $last_parent_section[$doc_key] = $current_parent_section;
$last_line_order[$doc_key] = $current_line_order;
// Prüfe ob dies die LETZTE Produktzeile ist - dann Subtotal per JavaScript NACH dieser Zeile einfügen // Prüfe ob dies die LETZTE Produktzeile ist - dann Subtotal per JavaScript NACH dieser Zeile einfügen
if ($current_parent_section) { if ($current_parent_section) {
@ -808,10 +918,13 @@ class ActionsSubtotalTitle extends CommonHookActions
} }
} }
} }
// Trailing items (leere Sections, Textzeilen) werden jetzt in formAddObjectLine gerendert
// da dieser Hook NACH allen Produktzeilen aber VOR dem Eingabeformular aufgerufen wird
} }
} }
} }
/** /**
* Rendert eine Textzeile * Rendert eine Textzeile
*/ */
@ -928,7 +1041,7 @@ class ActionsSubtotalTitle extends CommonHookActions
/** /**
* Hook: Wird aufgerufen wenn das Produkt-Hinzufügen-Formular gerendert wird * Hook: Wird aufgerufen wenn das Produkt-Hinzufügen-Formular gerendert wird
* Fügt Section-Dropdown hinzu * Fügt Section-Dropdown hinzu UND rendert trailing items (Textzeilen, leere Sections)
*/ */
public function formAddObjectLine($parameters, &$object, &$action, $hookmanager) public function formAddObjectLine($parameters, &$object, &$action, $hookmanager)
{ {
@ -947,7 +1060,112 @@ class ActionsSubtotalTitle extends CommonHookActions
} }
$document_id = $object->id; $document_id = $object->id;
$tables = DocumentTypeHelper::getTableNames($docType);
$doc_key = $docType.'_'.$document_id;
// Initialisiere rendered_sections falls nicht vorhanden
if (!isset(self::$rendered_sections[$doc_key])) {
self::$rendered_sections[$doc_key] = array();
}
// ========== TRAILING ITEMS RENDERN ==========
// Hole ALLE Manager-Einträge, die NACH der letzten facturedet-Zeile kommen
// und noch nicht gerendert wurden
// Finde die höchste line_order einer Zeile, die in facturedet ist
$sql_max_rendered = "SELECT MAX(m.line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sql_max_rendered .= " WHERE m.".$tables['fk_parent']." = ".(int)$document_id;
$sql_max_rendered .= " AND m.document_type = '".$db->escape($docType)."'";
$sql_max_rendered .= " AND m.".$tables['fk_line']." IS NOT NULL";
$res_max = $db->query($sql_max_rendered);
$obj_max = $db->fetch_object($res_max);
$last_rendered_order = $obj_max && $obj_max->max_order ? (int)$obj_max->max_order : 0;
if ($this->debug) {
error_log('[SubtotalTitle] formAddObjectLine: Letzte gerenderte line_order = '.$last_rendered_order);
}
// Hole alle trailing items (alles mit line_order > last_rendered_order)
$sql_trailing = "SELECT rowid, line_type, title, line_order, parent_section, show_subtotal, collapsed, in_facturedet";
$sql_trailing .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_trailing .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
$sql_trailing .= " AND document_type = '".$db->escape($docType)."'";
$sql_trailing .= " AND line_order > ".$last_rendered_order;
$sql_trailing .= " ORDER BY line_order";
$resql_trailing = $db->query($sql_trailing);
while ($trailing = $db->fetch_object($resql_trailing)) {
if ($trailing->line_type == 'section') {
// Section-Header rendern (falls noch nicht gerendert)
if (!in_array($trailing->rowid, self::$rendered_sections[$doc_key])) {
$section_data = array(
'section_id' => $trailing->rowid,
'title' => $trailing->title,
'show_subtotal' => $trailing->show_subtotal,
'collapsed' => $trailing->collapsed,
'in_facturedet' => $trailing->in_facturedet
);
echo $this->renderSectionHeader($section_data);
self::$rendered_sections[$doc_key][] = $trailing->rowid;
if ($this->debug) {
error_log('[SubtotalTitle] ✅ Trailing Section "'.$trailing->title.'" gerendert (order='.$trailing->line_order.')');
}
// Falls Subtotal aktiviert, auch Subtotal rendern
if ($trailing->show_subtotal) {
$sql_sub = "SELECT rowid, title, parent_section, line_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sql_sub .= " WHERE parent_section = ".(int)$trailing->rowid;
$sql_sub .= " AND line_type = 'subtotal'";
$sql_sub .= " AND document_type = '".$db->escape($docType)."'";
$resql_sub = $db->query($sql_sub);
if ($obj_sub = $db->fetch_object($resql_sub)) {
$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] ✅ Trailing Subtotal für Section "'.$trailing->title.'" gerendert');
}
}
}
}
}
} elseif ($trailing->line_type == 'text') {
// Textzeile rendern
$text_key = 'text_'.$trailing->rowid;
if (!in_array($text_key, self::$rendered_sections[$doc_key])) {
$textline = array(
'id' => $trailing->rowid,
'title' => $trailing->title,
'parent_section' => $trailing->parent_section,
'line_order' => $trailing->line_order,
'in_facturedet' => $trailing->in_facturedet
);
echo $this->renderTextLine($textline);
self::$rendered_sections[$doc_key][] = $text_key;
if ($this->debug) {
error_log('[SubtotalTitle] ✅ Trailing Textzeile "'.$trailing->title.'" gerendert (order='.$trailing->line_order.')');
}
}
} elseif ($trailing->line_type == 'subtotal') {
// Subtotal rendern (falls nicht schon mit Section gerendert)
$subtotal_key = 'subtotal_'.$trailing->rowid;
if (!in_array($subtotal_key, self::$rendered_sections[$doc_key])) {
echo $this->renderSubtotalLine($trailing);
self::$rendered_sections[$doc_key][] = $subtotal_key;
if ($this->debug) {
error_log('[SubtotalTitle] ✅ Trailing Subtotal gerendert (order='.$trailing->line_order.')');
}
}
}
}
// Dann das Section-Dropdown rendern
echo $this->renderSectionDropdown($document_id, $docType); echo $this->renderSectionDropdown($document_id, $docType);
return 0; return 0;

View file

@ -254,8 +254,45 @@ if (typeof SubtotalTitleLoaded === 'undefined') {
*/ */
function getDocumentInfo() { function getDocumentInfo() {
var url = window.location.href; var url = window.location.href;
// Versuche ID aus URL-Parametern zu extrahieren (mehrere Formate)
var id = null;
// Format 1: ?id=123 oder &id=123
var match = url.match(/[?&]id=(\d+)/); var match = url.match(/[?&]id=(\d+)/);
var id = match ? match[1] : null; if (match) {
id = match[1];
}
// Format 2: Aus URLSearchParams (robuster)
if (!id) {
try {
var params = new URLSearchParams(window.location.search);
id = params.get('id');
} catch(e) {
// URLSearchParams nicht verfügbar in alten Browsern
}
}
// Format 3: Aus dem DOM (falls ID dort gespeichert ist)
if (!id) {
// Versuche aus versteckten Feldern oder data-Attributen
var $idInput = $('input[name="id"], input[name="facid"], input[name="socid"]').first();
if ($idInput.length > 0) {
id = $idInput.val();
}
}
// Format 4: Aus der Seite selbst (Dolibarr zeigt oft die ID an)
if (!id) {
var $refDiv = $('.refid, .ref').first();
if ($refDiv.length > 0) {
var refMatch = $refDiv.text().match(/\((\d+)\)/);
if (refMatch) {
id = refMatch[1];
}
}
}
// Erkenne Dokumenttyp anhand der URL // Erkenne Dokumenttyp anhand der URL
var docType = 'invoice'; // Default var docType = 'invoice'; // Default
@ -265,6 +302,8 @@ function getDocumentInfo() {
docType = 'order'; docType = 'order';
} }
debugLog('getDocumentInfo: id=' + id + ', type=' + docType + ', url=' + url);
return { id: id, type: docType }; return { id: id, type: docType };
} }
@ -332,7 +371,13 @@ function moveSection(sectionId, direction) {
if (response.success) { if (response.success) {
window.location.href = window.location.pathname + window.location.search; window.location.href = window.location.pathname + window.location.search;
} else { } else {
alert((lang.errorReordering || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler')); // "Already at top/bottom" ist kein echter Fehler, sondern eine Info
if (response.error === 'Already at top' || response.error === 'Already at bottom') {
debugLog('Section ist bereits am ' + (response.error === 'Already at top' ? 'Anfang' : 'Ende'));
// Kein Alert nötig - einfach nichts tun
} else {
alert((lang.errorReordering || 'Fehler') + ': ' + (response.error || 'Unbekannter Fehler'));
}
} }
}, 'json').fail(function(xhr, status, error) { }, 'json').fail(function(xhr, status, error) {
if (SUBTOTAL_DEBUG) { if (SUBTOTAL_DEBUG) {
@ -788,8 +833,13 @@ function addUnlinkColumn() {
if (rowId.match(/^row-\d+/)) { if (rowId.match(/^row-\d+/)) {
var lineId = rowId.replace('row-', '').split('-')[0]; var lineId = rowId.replace('row-', '').split('-')[0];
var parentSection = $row.attr('data-parent-section'); var parentSection = $row.attr('data-parent-section');
if (parentSection && parentSection !== 'null') { // Robustere Prüfung: Hat gültige Section-ID?
var hasValidSection = parentSection && parentSection !== 'null' && parentSection !== 'undefined' && parentSection !== '' && parseInt(parentSection) > 0;
debugLog('🔗 Zeile ' + lineId + ': parent_section="' + parentSection + '" → hasValidSection=' + hasValidSection);
if (hasValidSection) {
// Hat Section → Unlink-Button // Hat Section → Unlink-Button
$row.append('<td class="linecolunlink center"><a href="#" onclick="removeFromSection(' + lineId + '); return false;" title="Aus Positionsgruppe entfernen"><span class="fas fa-unlink" style="color:#888;"></span></a></td>'); $row.append('<td class="linecolunlink center"><a href="#" onclick="removeFromSection(' + lineId + '); return false;" title="Aus Positionsgruppe entfernen"><span class="fas fa-unlink" style="color:#888;"></span></a></td>');
} else { } else {
@ -811,9 +861,10 @@ function addUnlinkColumn() {
* PLAN B: Section-Assignment beim Produkt hinzufügen * PLAN B: Section-Assignment beim Produkt hinzufügen
*/ */
$(document).ready(function() { $(document).ready(function() {
// Unlink-Spalte hinzufügen (nach data-Attributen) // Unlink-Spalte hinzufügen - NACH den PHP-Scripts die data-parent-section setzen
setTimeout(addUnlinkColumn, 600); // Timeout erhöht auf 1500ms um sicherzustellen dass alle Attribute gesetzt sind
setTimeout(addUnlinkColumn, 1500);
// 1. Beim Submit des Formulars: Section merken // 1. Beim Submit des Formulars: Section merken
$(document).on('submit', 'form[name="addproduct"]', function(e) { $(document).on('submit', 'form[name="addproduct"]', function(e) {
@ -847,6 +898,7 @@ $(document).ready(function() {
/** /**
* Weist das neueste Produkt einer Section zu * Weist das neueste Produkt einer Section zu
* Nach der Zuweisung wird die Darstellung per JavaScript aktualisiert (KEIN zweiter Reload)
*/ */
function assignLastProductToSection(sectionId, factureId) { function assignLastProductToSection(sectionId, factureId) {
var docInfo = getDocumentInfo(); var docInfo = getDocumentInfo();
@ -860,7 +912,35 @@ function assignLastProductToSection(sectionId, factureId) {
debugLog('✅ Assignment Response: ' + JSON.stringify(response)); debugLog('✅ Assignment Response: ' + JSON.stringify(response));
if (response.success) { if (response.success) {
debugLog('✅ Produkt #' + response.product_id + ' zu Section zugewiesen'); debugLog('✅ Produkt #' + response.product_id + ' zu Section zugewiesen');
window.location.href = window.location.pathname + window.location.search;
// KEIN zweiter Reload - stattdessen Zeile per JavaScript aktualisieren
var $productRow = $('#row-' + response.product_id);
if ($productRow.length > 0) {
// Setze data-parent-section Attribut
$productRow.attr('data-parent-section', sectionId);
// Verschiebe die Zeile an die richtige Position (nach der Section)
var $sectionRow = $('tr.section-header[data-section-id="' + sectionId + '"]');
if ($sectionRow.length > 0) {
// Finde das letzte Element dieser Section
var $lastInSection = $('tr[data-parent-section="' + sectionId + '"]').last();
if ($lastInSection.length > 0 && $lastInSection[0] !== $productRow[0]) {
$lastInSection.after($productRow);
} else if ($lastInSection.length === 0) {
// Erstes Produkt in der Section
$sectionRow.after($productRow);
}
}
// Färbung aktualisieren
colorSections();
debugLog('✅ Zeile per JavaScript aktualisiert - kein Reload nötig');
} else {
// Fallback: Wenn Zeile nicht gefunden, doch reloaden
debugLog('⚠️ Zeile nicht gefunden, Fallback: Reload');
window.location.href = window.location.pathname + window.location.search;
}
} else { } else {
debugLog('❌ Fehler: ' + response.error); debugLog('❌ Fehler: ' + response.error);
} }
@ -1669,7 +1749,8 @@ function linkToNearestSection(lineId) {
debugLog(' Response: ' + JSON.stringify(response)); debugLog(' Response: ' + JSON.stringify(response));
if (response.success) { if (response.success) {
window.location.reload(); // safeReload statt reload() um POST-Warnung zu vermeiden
safeReload();
} else { } else {
showErrorAlert('Fehler: ' + (response.error || 'Unbekannter Fehler')); showErrorAlert('Fehler: ' + (response.error || 'Unbekannter Fehler'));
} }

View file

@ -0,0 +1,51 @@
-- ============================================================================
-- Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
--
-- 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.
-- ============================================================================
CREATE TABLE IF NOT EXISTS llx_facture_lines_manager (
rowid INTEGER AUTO_INCREMENT PRIMARY KEY,
-- Verknüpfung mit dem Elterndokument (nur eines davon ist gesetzt)
fk_facture INTEGER DEFAULT NULL, -- Rechnung
fk_propal INTEGER DEFAULT NULL, -- Angebot
fk_commande INTEGER DEFAULT NULL, -- Kundenauftrag
-- Dokumenttyp zur einfachen Unterscheidung
document_type VARCHAR(20) DEFAULT 'invoice', -- 'invoice', 'propal', 'order'
-- Zeilentyp
line_type VARCHAR(20) NOT NULL, -- 'section', 'product', 'text', 'subtotal'
-- Verknüpfung zur Detail-Tabelle (facturedet, propaldet, commandedet)
fk_facturedet INTEGER DEFAULT NULL, -- für Rechnungen
fk_propaldet INTEGER DEFAULT NULL, -- für Angebote
fk_commandedet INTEGER DEFAULT NULL, -- für Kundenaufträge
-- Section-Eigenschaften
title VARCHAR(255) DEFAULT NULL,
parent_section INTEGER DEFAULT NULL, -- FK zu rowid dieser Tabelle
-- Sortierung und Status
line_order INTEGER DEFAULT 0,
show_subtotal TINYINT DEFAULT 0, -- Zwischensumme anzeigen?
collapsed TINYINT DEFAULT 0, -- Section eingeklappt?
in_facturedet TINYINT DEFAULT 0, -- In der Detail-Tabelle vorhanden?
-- Zeitstempel
date_creation DATETIME DEFAULT NULL,
tms TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- Indizes
INDEX idx_flm_fk_facture (fk_facture),
INDEX idx_flm_fk_propal (fk_propal),
INDEX idx_flm_fk_commande (fk_commande),
INDEX idx_flm_document_type (document_type),
INDEX idx_flm_line_type (line_type),
INDEX idx_flm_parent_section (parent_section),
INDEX idx_flm_line_order (line_order)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;