Stable Version fertig Multidocument Kundenaufträge Angebot und Rechnungen
This commit is contained in:
parent
a1468d359e
commit
51f309f90e
10 changed files with 672 additions and 172 deletions
209
README.md
209
README.md
|
|
@ -1,66 +1,41 @@
|
|||
# SubtotalTitle - Facturedet Sync Update
|
||||
# SubtotalTitle - Dolibarr Modul
|
||||
|
||||
## Was ist neu?
|
||||
- **📄 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
|
||||
Erweitert Rechnungen, Angebote und Kundenaufträge um **Sections**, **Textzeilen** und **Zwischensummen**.
|
||||
|
||||
## Installation
|
||||
---
|
||||
|
||||
### 1. Dateien kopieren
|
||||
## 🔑 ODT Template Schlüsselwörter
|
||||
|
||||
```
|
||||
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!)
|
||||
```
|
||||
Diese Variablen stehen in ODT-Templates zur Verfügung:
|
||||
|
||||
### 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
|
||||
$this->module_parts = array(
|
||||
'substitutions' => 1, // ← Diese Zeile hinzufügen/ändern!
|
||||
'hooks' => array(
|
||||
'data' => array('invoicecard'),
|
||||
'entity' => '0'
|
||||
)
|
||||
);
|
||||
```
|
||||
| Typ | special_code |
|
||||
|-----|-------------|
|
||||
| Normales Produkt | 0 |
|
||||
| Section (Überschrift) | 100 |
|
||||
| Textzeile | 101 |
|
||||
| Zwischensumme | 102 |
|
||||
|
||||
### 3. Modul deaktivieren und wieder aktivieren
|
||||
|
||||
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
|
||||
### ODT Template Beispiel
|
||||
|
||||
```
|
||||
[!-- BEGIN row.lines --]
|
||||
|
||||
[!-- IF {line_is_section} --]
|
||||
═══════════════════════════════════════
|
||||
{line_desc}
|
||||
═══════════════════════════════════════
|
||||
[!-- ENDIF {line_is_section} --]
|
||||
|
||||
[!-- IF {line_is_textline} --]
|
||||
|
|
@ -72,17 +47,139 @@ Damit die Substitution-Funktion erkannt wird.
|
|||
[!-- ENDIF {line_is_normal} --]
|
||||
|
||||
[!-- IF {line_is_subtotal} --]
|
||||
───────────────────────────────────────
|
||||
Zwischensumme: {line_price_ht_locale} €
|
||||
───────────────────────────────────────
|
||||
[!-- ENDIF {line_is_subtotal} --]
|
||||
|
||||
[!-- END row.lines --]
|
||||
```
|
||||
|
||||
## special_code Werte
|
||||
---
|
||||
|
||||
| Typ | special_code | ODT-Variable |
|
||||
|-----|-------------|--------------|
|
||||
| Normales Produkt | 0 | `{line_is_normal}` |
|
||||
| Section | 100 | `{line_is_section}` |
|
||||
| Textzeile | 101 | `{line_is_textline}` |
|
||||
| Zwischensumme | 102 | `{line_is_subtotal}` |
|
||||
## 📋 Modul-Funktionen
|
||||
|
||||
### Sections (Überschriften)
|
||||
- Erstellen von Überschriften zur Strukturierung
|
||||
- Produkte können per Drag & Drop oder Link-Button zugeordnet werden
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -58,38 +58,40 @@ if (!$resql_section || $db->num_rows($resql_section) == 0) {
|
|||
$section = $db->fetch_object($resql_section);
|
||||
$section_line_order = $section->line_order;
|
||||
|
||||
// 3. Finde die neue line_order für das Produkt (nach der Section, vor anderen Produkten der Section)
|
||||
$sql_next = "SELECT MIN(line_order) as next_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
|
||||
$sql_next .= " WHERE ".$tables['fk_parent']." = ".(int)$document_id;
|
||||
$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);
|
||||
// 3. Finde die neue line_order für das Produkt
|
||||
// Das Produkt soll IMMER als LETZTES Produkt der Section eingefügt werden
|
||||
// (nach allen bestehenden Produkten dieser Section, aber vor der nächsten Section oder dem Subtotal)
|
||||
|
||||
if ($obj_next && $obj_next->next_order) {
|
||||
// Es gibt bereits Produkte in dieser Section → füge VOR dem ersten ein
|
||||
$new_line_order = $obj_next->next_order;
|
||||
// Suche das letzte Produkt/Text dieser Section
|
||||
$sql_last = "SELECT MAX(line_order) as max_order FROM ".MAIN_DB_PREFIX."facture_lines_manager";
|
||||
$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 {
|
||||
// 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;
|
||||
}
|
||||
|
||||
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
|
||||
if ($current_line_order > $new_line_order) {
|
||||
// Nach vorne: Verschiebe Zeilen von new_line_order bis BEFORE current um +1
|
||||
$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 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
|
||||
// 4. Verschiebe Zeilen
|
||||
// WICHTIG: Zuerst das Produkt "entfernen" (temporär auf -1 setzen), dann verschieben, dann einfügen
|
||||
$sql_temp = "UPDATE ".MAIN_DB_PREFIX."facture_lines_manager";
|
||||
$sql_temp .= " SET line_order = -1";
|
||||
$sql_temp .= " WHERE rowid = ".(int)$manager_id;
|
||||
$db->query($sql_temp);
|
||||
|
||||
if ($current_line_order < $new_line_order) {
|
||||
// Produkt wird nach hinten verschoben
|
||||
// Schließe die Lücke: alle Zeilen zwischen current+1 und new_line_order 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;
|
||||
|
|
@ -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)$new_line_order;
|
||||
$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
|
||||
$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;
|
||||
$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);
|
||||
|
||||
$sql = "SELECT rowid, line_type, ".$tables['fk_line']." as detail_id FROM ".MAIN_DB_PREFIX."facture_lines_manager";
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ $sql_fix_sections .= " WHERE line_type = 'section'";
|
|||
$sql_fix_sections .= " AND parent_section IS NOT NULL";
|
||||
$sql_fix_sections .= " AND ".$tables['fk_parent']." = ".(int)$facture_id;
|
||||
$sql_fix_sections .= " AND document_type = '".$db->escape($docType)."'";
|
||||
$db->query($sql_fix_sections);
|
||||
$sections_fixed = $db->affected_rows();
|
||||
$resql_fix = $db->query($sql_fix_sections);
|
||||
$sections_fixed = $resql_fix ? $db->affected_rows($resql_fix) : 0;
|
||||
if ($sections_fixed > 0) {
|
||||
subtotaltitle_debug_log('🧹 ' . $sections_fixed . ' Sections mit falscher parent_section korrigiert');
|
||||
$fixed += $sections_fixed;
|
||||
|
|
@ -45,8 +45,8 @@ $sql_fix_zero .= " SET parent_section = NULL";
|
|||
$sql_fix_zero .= " WHERE parent_section = 0";
|
||||
$sql_fix_zero .= " AND ".$tables['fk_parent']." = ".(int)$facture_id;
|
||||
$sql_fix_zero .= " AND document_type = '".$db->escape($docType)."'";
|
||||
$db->query($sql_fix_zero);
|
||||
$zero_fixed = $db->affected_rows();
|
||||
$resql_fix_zero = $db->query($sql_fix_zero);
|
||||
$zero_fixed = $resql_fix_zero ? $db->affected_rows($resql_fix_zero) : 0;
|
||||
if ($zero_fixed > 0) {
|
||||
subtotaltitle_debug_log('🧹 ' . $zero_fixed . ' Einträge mit parent_section=0 korrigiert');
|
||||
$fixed += $zero_fixed;
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ $fk_commande = ($docType === 'order') ? (int)$facture_id : 'NULL';
|
|||
|
||||
$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 .= " 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)) {
|
||||
$new_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager");
|
||||
|
|
|
|||
|
|
@ -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 ==========
|
||||
subtotaltitle_debug_log('🔢 Normalisiere line_order...');
|
||||
|
||||
|
|
|
|||
|
|
@ -141,31 +141,8 @@ if ($action == 'add') {
|
|||
// Bestimme rang (Position) - UNTERSCHIEDLICH für Sections vs andere Zeilen
|
||||
$new_rang = 1;
|
||||
|
||||
if ($line_type == 'section') {
|
||||
// Für Sections: Finde das erste Produkt dieser Section und füge Section DAVOR ein
|
||||
$sql_first_product = "SELECT MIN(d.rang) as min_rang";
|
||||
$sql_first_product .= " 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_first_product .= " WHERE m.parent_section = ".(int)$line_id;
|
||||
$sql_first_product .= " AND m.".$tables['fk_parent']." = ".(int)$document_id;
|
||||
$sql_first_product .= " AND m.document_type = '".$db->escape($docType)."'";
|
||||
$sql_first_product .= " AND m.line_type = 'product'";
|
||||
$res_first = $db->query($sql_first_product);
|
||||
$obj_first = $db->fetch_object($res_first);
|
||||
|
||||
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
|
||||
// EINHEITLICHE BERECHNUNG für ALLE Zeilentypen basierend auf line_order
|
||||
// Finde den höchsten rang aller Zeilen, die VOR dieser Zeile (nach line_order) liegen
|
||||
$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'];
|
||||
|
|
@ -175,7 +152,6 @@ if ($action == 'add') {
|
|||
$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);
|
||||
|
||||
|
|
|
|||
|
|
@ -64,7 +64,16 @@ if ($show) {
|
|||
$obj_last = $db->fetch_object($res_last);
|
||||
$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;
|
||||
|
||||
// Alle nachfolgenden Zeilen +1
|
||||
|
|
@ -88,13 +97,14 @@ if ($show) {
|
|||
$subtotal_manager_id = $db->last_insert_id(MAIN_DB_PREFIX."facture_lines_manager");
|
||||
|
||||
// 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 .= " 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.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.line_type = 'product'";
|
||||
$sql_rang .= " AND m.line_order < ".(int)$new_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;
|
||||
|
|
@ -109,7 +119,8 @@ if ($show) {
|
|||
// Füge Subtotal in Detail-Tabelle ein (special_code = 102)
|
||||
$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 .= " 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 .= (int)$facture_id.", ";
|
||||
$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 .= "102, "; // special_code = 102 (Subtotal)
|
||||
$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);
|
||||
$new_detail_id = $db->last_insert_id(MAIN_DB_PREFIX.$tables['lines_table']);
|
||||
|
||||
|
|
|
|||
|
|
@ -142,11 +142,14 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
// Lade Übersetzungen
|
||||
$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;
|
||||
$tables = DocumentTypeHelper::getTableNames($this->currentDocType);
|
||||
$hasSections = false;
|
||||
$hasTextLines = false;
|
||||
$hasSectionsOrTextLines = false;
|
||||
if ($tables && $object->id) {
|
||||
// Prüfe Sections
|
||||
$sql_sec = "SELECT COUNT(*) as cnt FROM ".MAIN_DB_PREFIX."facture_lines_manager";
|
||||
$sql_sec .= " WHERE ".$tables['fk_parent']." = ".(int)$object->id;
|
||||
$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)) {
|
||||
$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
|
||||
|
|
@ -247,6 +262,8 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
echo '});</script>'."\n";
|
||||
|
||||
// Sync-Buttons + Collapse-Buttons - rechts ausgerichtet
|
||||
// Nur anzeigen wenn Sections oder Textzeilen vorhanden sind
|
||||
if ($hasSectionsOrTextLines) {
|
||||
echo '<script>$(document).ready(function() {';
|
||||
echo ' if ($(".sync-collapse-row").length === 0) {';
|
||||
echo ' var buttons = \'<div class="sync-collapse-row" style="text-align:right; margin:5px 0;">\';';
|
||||
|
|
@ -263,6 +280,7 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
echo '});</script>'."\n";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
|
@ -646,12 +664,14 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
|
||||
static $last_rang = array();
|
||||
static $last_parent_section = array();
|
||||
static $last_line_order = 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;
|
||||
$last_line_order[$doc_key] = 0;
|
||||
}
|
||||
|
||||
// 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 .= " 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 .= " 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 .= $this->getDocumentWhere($document_id, $docType, '');
|
||||
$sql_combined .= " AND (line_type = 'section' OR line_type = 'text')";
|
||||
|
|
@ -720,9 +741,29 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
|
||||
while ($obj = $db->fetch_object($resql_combined)) {
|
||||
if ($obj->line_type == 'section') {
|
||||
// Section nur rendern wenn first_product_rang passt
|
||||
// Section rendern wenn:
|
||||
// 1. Sie Produkte hat UND first_product_rang im Bereich liegt, ODER
|
||||
// 2. Sie KEINE Produkte hat (leere Section) UND ihre line_order zwischen last_line_order und current_line_order liegt
|
||||
$should_render = false;
|
||||
|
||||
// DEBUG
|
||||
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) {
|
||||
if (!in_array($obj->rowid, self::$rendered_sections[$doc_key])) {
|
||||
$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,
|
||||
|
|
@ -734,11 +775,79 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
self::$rendered_sections[$doc_key][] = $obj->rowid;
|
||||
|
||||
if ($this->debug) {
|
||||
error_log('[SubtotalTitle] ✅ Section "'.$obj->title.'" gerendert vor rang '.$current_rang);
|
||||
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') {
|
||||
// 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
|
||||
$text_key = 'text_'.$obj->rowid;
|
||||
if (!in_array($text_key, self::$rendered_sections[$doc_key])) {
|
||||
|
|
@ -761,6 +870,7 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
// Merke für nächsten Durchlauf
|
||||
$last_rang[$doc_key] = $current_rang;
|
||||
$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
|
||||
if ($current_parent_section) {
|
||||
|
|
@ -808,6 +918,9 @@ 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -928,7 +1041,7 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
|
||||
/**
|
||||
* 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)
|
||||
{
|
||||
|
|
@ -947,7 +1060,112 @@ class ActionsSubtotalTitle extends CommonHookActions
|
|||
}
|
||||
|
||||
$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);
|
||||
|
||||
return 0;
|
||||
|
|
|
|||
|
|
@ -254,8 +254,45 @@ if (typeof SubtotalTitleLoaded === 'undefined') {
|
|||
*/
|
||||
function getDocumentInfo() {
|
||||
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 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
|
||||
var docType = 'invoice'; // Default
|
||||
|
|
@ -265,6 +302,8 @@ function getDocumentInfo() {
|
|||
docType = 'order';
|
||||
}
|
||||
|
||||
debugLog('getDocumentInfo: id=' + id + ', type=' + docType + ', url=' + url);
|
||||
|
||||
return { id: id, type: docType };
|
||||
}
|
||||
|
||||
|
|
@ -331,9 +370,15 @@ function moveSection(sectionId, direction) {
|
|||
debugLog('Move response: ' + JSON.stringify(response));
|
||||
if (response.success) {
|
||||
window.location.href = window.location.pathname + window.location.search;
|
||||
} else {
|
||||
// "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) {
|
||||
if (SUBTOTAL_DEBUG) {
|
||||
console.error('AJAX Fehler: ' + status);
|
||||
|
|
@ -789,7 +834,12 @@ function addUnlinkColumn() {
|
|||
var lineId = rowId.replace('row-', '').split('-')[0];
|
||||
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
|
||||
$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 {
|
||||
|
|
@ -812,8 +862,9 @@ function addUnlinkColumn() {
|
|||
*/
|
||||
$(document).ready(function() {
|
||||
|
||||
// Unlink-Spalte hinzufügen (nach data-Attributen)
|
||||
setTimeout(addUnlinkColumn, 600);
|
||||
// Unlink-Spalte hinzufügen - NACH den PHP-Scripts die data-parent-section setzen
|
||||
// Timeout erhöht auf 1500ms um sicherzustellen dass alle Attribute gesetzt sind
|
||||
setTimeout(addUnlinkColumn, 1500);
|
||||
|
||||
// 1. Beim Submit des Formulars: Section merken
|
||||
$(document).on('submit', 'form[name="addproduct"]', function(e) {
|
||||
|
|
@ -847,6 +898,7 @@ $(document).ready(function() {
|
|||
|
||||
/**
|
||||
* Weist das neueste Produkt einer Section zu
|
||||
* Nach der Zuweisung wird die Darstellung per JavaScript aktualisiert (KEIN zweiter Reload)
|
||||
*/
|
||||
function assignLastProductToSection(sectionId, factureId) {
|
||||
var docInfo = getDocumentInfo();
|
||||
|
|
@ -860,7 +912,35 @@ function assignLastProductToSection(sectionId, factureId) {
|
|||
debugLog('✅ Assignment Response: ' + JSON.stringify(response));
|
||||
if (response.success) {
|
||||
debugLog('✅ Produkt #' + response.product_id + ' zu Section zugewiesen');
|
||||
|
||||
// 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 {
|
||||
debugLog('❌ Fehler: ' + response.error);
|
||||
}
|
||||
|
|
@ -1669,7 +1749,8 @@ function linkToNearestSection(lineId) {
|
|||
debugLog(' Response: ' + JSON.stringify(response));
|
||||
|
||||
if (response.success) {
|
||||
window.location.reload();
|
||||
// safeReload statt reload() um POST-Warnung zu vermeiden
|
||||
safeReload();
|
||||
} else {
|
||||
showErrorAlert('Fehler: ' + (response.error || 'Unbekannter Fehler'));
|
||||
}
|
||||
|
|
|
|||
51
sql/llx_facture_lines_manager.sql
Normal file
51
sql/llx_facture_lines_manager.sql
Normal 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;
|
||||
Loading…
Reference in a new issue