Version 1.6.0: Stundenübernahme-Modus, Leistungsbeschreibungen, Bugfixes, Dark Mode

- Neues Extrafeld 'stundenzettel_hours_mode' am Auftrag: Gruppiert oder Pro Stundenzettel
- Leistungsbeschreibungen werden per GROUP_CONCAT in Rechnungszeilen übernommen
- Bugfix: Rücknahme-Dropdown zeigt jetzt auch manuell hinzugefügte Produkte (fk_commandedet=NULL)
- Bugfix: Entfällt berücksichtigt Freitext-Produkte korrekt (fk_product IS NULL)
- Bugfix: NULL-Handling für fk_product in 5 SQL-Queries (card.php + stundenzettel_commande.php)
- Bugfix: Rechnungsübernahme inkl. origin='added' Produkte ohne Auftragszeile
- Bugfix: Tracking-Tab zeigt alle Mehraufwand/zusätzlich verbauten Produkte
- Dark Mode: Hardcodierte Hintergrundfarben durch CSS-Klassen mit Variablen ersetzt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-20 19:21:03 +01:00
parent 585d530992
commit c9cbd54fa3
6 changed files with 304 additions and 69 deletions

View file

@ -1,6 +1,6 @@
# Stundenzettel Modul für Dolibarr # Stundenzettel Modul für Dolibarr
**Version:** 1.4.0 **Version:** 1.6.0
**Autor:** Data IT Solution **Autor:** Data IT Solution
**Kompatibilität:** Dolibarr 16.0+ **Kompatibilität:** Dolibarr 16.0+
**Lizenz:** GPL v3 **Lizenz:** GPL v3
@ -25,7 +25,9 @@ Das Stundenzettel-Modul ermöglicht die Verwaltung von Stundenzetteln für Kunde
- **Stundenzettel-Freigabe**: Sperren von Stundenzetteln nach Fertigstellung - **Stundenzettel-Freigabe**: Sperren von Stundenzetteln nach Fertigstellung
- **Rechnungsübernahme**: Automatische Übernahme aller Produkte und Leistungen in eine Rechnung - **Rechnungsübernahme**: Automatische Übernahme aller Produkte und Leistungen in eine Rechnung
- **Stunden-Modus**: Wahlweise Übernahme als Gesamtstunden oder pro Tag - **Stunden-Modus**: Wahlweise Übernahme gruppiert oder pro Stundenzettel (einstellbar pro Auftrag)
- **Rücknahme**: Bereits verbaute Produkte als zurückgenommen markieren
- **Leistungsbeschreibungen**: Arbeitsbeschreibungen werden automatisch in die Rechnung übernommen
### Integration ### Integration
@ -55,6 +57,17 @@ Die Moduleinstellungen finden Sie unter **Einstellungen > Module > Stundenzettel
| **Standard-Datum** | Aktuelles Datum oder Datum des letzten offenen Stundenzettels | | **Standard-Datum** | Aktuelles Datum oder Datum des letzten offenen Stundenzettels |
| **Stunden-Übernahme** | Gesamtstunden auf einer Zeile oder pro Tag eine Zeile | | **Stunden-Übernahme** | Gesamtstunden auf einer Zeile oder pro Tag eine Zeile |
### Stundenübernahme-Modus (pro Auftrag)
Im Auftrag kann unter den Extrafeldern der Modus der Stundenübernahme eingestellt werden:
| Modus | Beschreibung |
|-------|--------------|
| **Gruppiert (Standard)** | Alle gleichen Leistungspositionen über alle Stundenzettel zusammenrechnen |
| **Pro Stundenzettel** | Jeder Stundenzettel bekommt eigene Rechnungszeile(n) mit STZ-Referenz und Datum |
In beiden Modi werden die Leistungsbeschreibungen automatisch in die Rechnungszeile übernommen.
### Standard-Leistung beim Kunden ### Standard-Leistung beim Kunden
Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung (Dienstleistung) hinterlegen. Diese wird dann bei allen Stundenzetteln für diesen Kunden angezeigt und kann für die Rechnungsstellung verwendet werden. Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung (Dienstleistung) hinterlegen. Diese wird dann bei allen Stundenzetteln für diesen Kunden angezeigt und kann für die Rechnungsstellung verwendet werden.
@ -77,6 +90,7 @@ Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung
| `auftragsbeschreibung` | Auftrag | Zusätzliche Beschreibung für den Auftrag | | `auftragsbeschreibung` | Auftrag | Zusätzliche Beschreibung für den Auftrag |
| `stundenzettel_status` | Auftrag | Status der Stundenzettel (0=Offen, 1=Freigegeben, 2=Abgerechnet) | | `stundenzettel_status` | Auftrag | Status der Stundenzettel (0=Offen, 1=Freigegeben, 2=Abgerechnet) |
| `stundenzettel_netto` | Auftrag | Berechneter Netto-Wert aller freigegebenen Stundenzettel | | `stundenzettel_netto` | Auftrag | Berechneter Netto-Wert aller freigegebenen Stundenzettel |
| `stundenzettel_hours_mode` | Auftrag | Stundenübernahme-Modus: Gruppiert oder Pro Stundenzettel |
| `stundenzettel_default_service` | Kunde | Standard-Dienstleistung für Stundenzettel | | `stundenzettel_default_service` | Kunde | Standard-Dienstleistung für Stundenzettel |
## Berechtigungen ## Berechtigungen
@ -100,6 +114,20 @@ Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung
## Changelog ## Changelog
### Version 1.6.0
- **Stundenübernahme-Modus pro Auftrag**: Neues Extrafeld am Auftrag zur Wahl zwischen gruppierten Leistungen (Standard) und pro-Stundenzettel-Übernahme
- **Leistungsbeschreibungen in Rechnung**: Arbeitsbeschreibungen aus den Stundenzetteln werden automatisch in die Rechnungszeilen übernommen
- **Bugfix Rücknahme-Dropdown**: Manuell hinzugefügte Produkte (ohne Auftragszeile) werden jetzt korrekt angezeigt
- **Bugfix Entfällt**: Freitext-Produkte und Produkte ohne Auftragszeile werden korrekt berücksichtigt
- **Bugfix NULL-Handling**: Korrektes SQL-Handling für `fk_product IS NULL` bei Freitext-Produkten (5 Stellen)
- **Bugfix Rechnungsübernahme**: Produkte mit `origin='added'` ohne Auftragszeile werden jetzt in der Rechnung berücksichtigt
- **Bugfix Tracking-Tab**: Mehraufwand/zusätzlich verbaute Produkte werden vollständig in der Lieferauflistung angezeigt
### Version 1.5.0
- **Rücknahme-Bereich**: Bereits verbaute Produkte können als zurückgenommen markiert werden
- **Checkbox-Sichtbarkeit**: Verbesserte Darstellung der Produktauswahl
- **Berechtigungen**: Erweiterte Berechtigungssteuerung
### Version 1.4.0 ### Version 1.4.0
- **Stundenzettel öffnen ohne Produktauswahl**: Button "Stundenzettel öffnen" funktioniert jetzt auch ohne Checkbox-Auswahl - öffnet oder erstellt direkt einen Stundenzettel - **Stundenzettel öffnen ohne Produktauswahl**: Button "Stundenzettel öffnen" funktioniert jetzt auch ohne Checkbox-Auswahl - öffnet oder erstellt direkt einen Stundenzettel
- **Dezimalmengen**: Alle Mengenfelder (Produkte, Mehraufwand, Entfällt) unterstützen jetzt Kommazahlen (z.B. 0,3m Kabel) - **Dezimalmengen**: Alle Mengenfelder (Produkte, Mehraufwand, Entfällt) unterstützen jetzt Kommazahlen (z.B. 0,3m Kabel)

View file

@ -516,7 +516,7 @@ if ($action == 'add_entfaellt' && $permissiontoadd) {
$sqlCheck .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_used,"; $sqlCheck .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_used,";
$sqlCheck .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2"; $sqlCheck .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sqlCheck .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel"; $sqlCheck .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sqlCheck .= " WHERE sp2.fk_product = cd.fk_product AND sp2.origin = 'omitted' AND s2.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_omitted"; $sqlCheck .= " WHERE (sp2.fk_commandedet = cd.rowid OR (sp2.fk_product = cd.fk_product AND cd.fk_product > 0)) AND sp2.origin = 'omitted' AND s2.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_omitted";
$sqlCheck .= " FROM ".MAIN_DB_PREFIX."commandedet as cd"; $sqlCheck .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sqlCheck .= " WHERE cd.fk_commande = ".((int)$object->fk_commande); $sqlCheck .= " WHERE cd.fk_commande = ".((int)$object->fk_commande);
$sqlCheck .= " AND cd.fk_product = ".((int)$fk_product); $sqlCheck .= " AND cd.fk_product = ".((int)$fk_product);
@ -586,7 +586,7 @@ if ($action == 'add_entfaellt' && $permissiontoadd) {
// Produkt zum Stundenzettel hinzufügen mit origin='omitted' // Produkt zum Stundenzettel hinzufügen mit origin='omitted'
$result = $object->addProduct( $result = $object->addProduct(
$fk_product, $fk_product,
0, // fk_commandedet $commandedet_id, // fk_commandedet (Verknüpfung zur Auftragszeile)
0, // fk_manager_line 0, // fk_manager_line
0, // qty_original 0, // qty_original
$qty, // qty_done (Menge die entfällt) $qty, // qty_done (Menge die entfällt)
@ -640,6 +640,14 @@ if ($action == 'add_ruecknahme' && $permissiontoadd) {
if ($resqlDesc && ($objDesc = $db->fetch_object($resqlDesc))) { if ($resqlDesc && ($objDesc = $db->fetch_object($resqlDesc))) {
$freetext_description = $objDesc->description; $freetext_description = $objDesc->description;
} }
} elseif (strpos($ruecknahme_product_raw, 'mehraufwand_') === 0) {
// Freitext-Mehraufwand aus stundenzettel_product
$sp_rowid = (int)substr($ruecknahme_product_raw, 12);
$sqlDesc = "SELECT description FROM ".MAIN_DB_PREFIX."stundenzettel_product WHERE rowid = ".((int)$sp_rowid);
$resqlDesc = $db->query($sqlDesc);
if ($resqlDesc && ($objDesc = $db->fetch_object($resqlDesc))) {
$freetext_description = $objDesc->description;
}
} else { } else {
$fk_product = (int)$ruecknahme_product_raw; $fk_product = (int)$ruecknahme_product_raw;
} }
@ -1587,11 +1595,11 @@ elseif ($object->id > 0) {
// Bereits als entfällt markiert (auf allen Stundenzetteln dieses Auftrags) // Bereits als entfällt markiert (auf allen Stundenzetteln dieses Auftrags)
$sqlOrderProducts .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2"; $sqlOrderProducts .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sqlOrderProducts .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel"; $sqlOrderProducts .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sqlOrderProducts .= " WHERE sp2.fk_product = cd.fk_product AND sp2.origin = 'omitted' AND s2.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_omitted"; $sqlOrderProducts .= " WHERE (sp2.fk_commandedet = cd.rowid OR (sp2.fk_product = cd.fk_product AND cd.fk_product > 0)) AND sp2.origin = 'omitted' AND s2.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_omitted";
$sqlOrderProducts .= " FROM ".MAIN_DB_PREFIX."commandedet as cd"; $sqlOrderProducts .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sqlOrderProducts .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product"; $sqlOrderProducts .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sqlOrderProducts .= " WHERE cd.fk_commande = ".((int)$object->fk_commande); $sqlOrderProducts .= " WHERE cd.fk_commande = ".((int)$object->fk_commande);
$sqlOrderProducts .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))"; $sqlOrderProducts .= " AND (cd.fk_product > 0 OR ((cd.fk_product IS NULL OR cd.fk_product = 0) AND cd.description IS NOT NULL AND cd.description != ''))";
$sqlOrderProducts .= " AND (cd.special_code IS NULL OR cd.special_code = 0)"; $sqlOrderProducts .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
$sqlOrderProducts .= " ORDER BY cd.rang"; $sqlOrderProducts .= " ORDER BY cd.rang";
$resqlOrderProducts = $db->query($sqlOrderProducts); $resqlOrderProducts = $db->query($sqlOrderProducts);
@ -2055,7 +2063,7 @@ elseif ($object->id > 0) {
$sqlDelivered .= " FROM ".MAIN_DB_PREFIX."commandedet as cd"; $sqlDelivered .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sqlDelivered .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product"; $sqlDelivered .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sqlDelivered .= " WHERE cd.fk_commande = ".((int)$object->fk_commande); $sqlDelivered .= " WHERE cd.fk_commande = ".((int)$object->fk_commande);
$sqlDelivered .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))"; $sqlDelivered .= " AND (cd.fk_product > 0 OR ((cd.fk_product IS NULL OR cd.fk_product = 0) AND cd.description IS NOT NULL AND cd.description != ''))";
$sqlDelivered .= " AND (cd.special_code IS NULL OR cd.special_code = 0)"; $sqlDelivered .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
$sqlDelivered .= " ORDER BY cd.rang"; $sqlDelivered .= " ORDER BY cd.rang";
$resqlDelivered = $db->query($sqlDelivered); $resqlDelivered = $db->query($sqlDelivered);
@ -2068,6 +2076,69 @@ elseif ($object->id > 0) {
} }
} }
} }
// Auch verbaute Produkte ohne Auftragszeile berücksichtigen
// (origin='added' mit fk_commandedet IS NULL = in Produktliste hinzugefügt, nicht aus Auftrag)
$alreadyFoundProductIds = array();
foreach ($deliveredProducts as $dp) {
if ($dp->fk_product > 0) {
$alreadyFoundProductIds[] = (int)$dp->fk_product;
}
}
// Verbaute Produkte mit fk_product > 0, ohne Auftragszeile
$sqlAdded = "SELECT sp.fk_product, p.ref as product_ref, p.label as product_label,";
$sqlAdded .= " SUM(sp.qty_done) as qty_delivered,";
$sqlAdded .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sqlAdded .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sqlAdded .= " WHERE sp2.fk_product = sp.fk_product AND sp2.origin = 'returned'";
$sqlAdded .= " AND s2.fk_commande = ".((int)$object->fk_commande)."), 0) as qty_returned";
$sqlAdded .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlAdded .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlAdded .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = sp.fk_product";
$sqlAdded .= " WHERE sp.origin = 'added'";
$sqlAdded .= " AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)";
$sqlAdded .= " AND s.fk_commande = ".((int)$object->fk_commande);
$sqlAdded .= " AND sp.fk_product > 0";
if (!empty($alreadyFoundProductIds)) {
$sqlAdded .= " AND sp.fk_product NOT IN (".implode(',', $alreadyFoundProductIds).")";
}
$sqlAdded .= " GROUP BY sp.fk_product, p.ref, p.label";
$resqlAdded = $db->query($sqlAdded);
if ($resqlAdded) {
while ($objAdd = $db->fetch_object($resqlAdded)) {
$objAdd->qty_available = $objAdd->qty_delivered - $objAdd->qty_returned;
$objAdd->rowid = 0;
$objAdd->description = '';
if ($objAdd->qty_available > 0) {
$deliveredProducts[] = $objAdd;
}
}
}
// Verbaute Freitext-Produkte ohne Auftragszeile (fk_product = 0, fk_commandedet = NULL)
$sqlAddedFree = "SELECT sp.rowid as sp_rowid, sp.description,";
$sqlAddedFree .= " sp.qty_done as qty_delivered, 0 as qty_returned, 0 as fk_product,";
$sqlAddedFree .= " '' as product_ref, '' as product_label";
$sqlAddedFree .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlAddedFree .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlAddedFree .= " WHERE sp.origin = 'added'";
$sqlAddedFree .= " AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)";
$sqlAddedFree .= " AND s.fk_commande = ".((int)$object->fk_commande);
$sqlAddedFree .= " AND (sp.fk_product IS NULL OR sp.fk_product = 0)";
$sqlAddedFree .= " AND sp.description IS NOT NULL AND sp.description != ''";
$resqlAddedFree = $db->query($sqlAddedFree);
if ($resqlAddedFree) {
while ($objFree = $db->fetch_object($resqlAddedFree)) {
$objFree->qty_available = $objFree->qty_delivered;
$objFree->rowid = 0;
$objFree->fk_product = 0;
$objFree->is_mehraufwand_freetext = true;
if ($objFree->qty_available > 0) {
$deliveredProducts[] = $objFree;
}
}
}
} }
$hasDeliveredProducts = (count($deliveredProducts) > 0); $hasDeliveredProducts = (count($deliveredProducts) > 0);
@ -2089,11 +2160,19 @@ elseif ($object->id > 0) {
foreach ($deliveredProducts as $dp) { foreach ($deliveredProducts as $dp) {
if ($dp->fk_product > 0) { if ($dp->fk_product > 0) {
print '<option value="'.$dp->fk_product.'" data-commandedet="'.$dp->rowid.'" data-max="'.formatQty($dp->qty_available).'">'; print '<option value="'.$dp->fk_product.'" data-commandedet="'.($dp->rowid ?? 0).'" data-max="'.formatQty($dp->qty_available).'">';
print $dp->product_ref.' - '.$dp->product_label; print $dp->product_ref.' - '.$dp->product_label;
print ' ('.$langs->trans("QtyDelivered").': '.formatQty($dp->qty_available).')'; print ' ('.$langs->trans("QtyDelivered").': '.formatQty($dp->qty_available).')';
print '</option>'; print '</option>';
} elseif (!empty($dp->is_mehraufwand_freetext)) {
// Freitext-Mehraufwand
$desc = strip_tags($dp->description);
$descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc;
print '<option value="mehraufwand_'.$dp->sp_rowid.'" data-description="'.dol_escape_htmltag($desc).'" data-max="'.formatQty($dp->qty_available).'">';
print $descShort.' ('.$langs->trans("QtyDelivered").': '.formatQty($dp->qty_available).')';
print '</option>';
} else { } else {
// Freitext aus Auftrag
$desc = strip_tags($dp->description); $desc = strip_tags($dp->description);
$descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc; $descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc;
print '<option value="freetext_'.$dp->rowid.'" data-commandedet="'.$dp->rowid.'" data-description="'.dol_escape_htmltag($desc).'" data-max="'.formatQty($dp->qty_available).'">'; print '<option value="freetext_'.$dp->rowid.'" data-commandedet="'.$dp->rowid.'" data-description="'.dol_escape_htmltag($desc).'" data-max="'.formatQty($dp->qty_available).'">';

View file

@ -53,7 +53,7 @@ class modStundenzettel extends DolibarrModules
$this->descriptionlong = "Verwaltet Stundenzettel für Kundenaufträge. Ermöglicht die Dokumentation von Arbeitszeiten, verbrauchten Materialien und Notizen. Integration mit SubtotalTitle für Produktgruppen-Unterstützung."; $this->descriptionlong = "Verwaltet Stundenzettel für Kundenaufträge. Ermöglicht die Dokumentation von Arbeitszeiten, verbrauchten Materialien und Notizen. Integration mit SubtotalTitle für Produktgruppen-Unterstützung.";
// Version // Version
$this->version = '1.5.0'; $this->version = '1.6.0';
// Autor // Autor
$this->editor_name = 'Data IT Solution'; $this->editor_name = 'Data IT Solution';

88
css/stundenzettel-mobile.css Normal file → Executable file
View file

@ -366,19 +366,83 @@
} }
} }
/* ============================================
STUNDENZETTEL HINTERGRUNDFARBEN (Light Mode)
============================================ */
.mod-stundenzettel .stz-info-box {
background-color: var(--colorbacklineimpair1, #fff);
}
.mod-stundenzettel .stz-warning-box {
background-color: #fff8e1;
border-left: 4px solid #ffc107;
}
.mod-stundenzettel .stz-section-header {
background-color: var(--colorbacklineimpair1, #f8f8f8);
}
.mod-stundenzettel .stz-subtable-bg {
background-color: var(--colorbacklineimpair2, #fafafa);
}
.mod-stundenzettel .stz-subtable-header {
background-color: var(--colorbacktitle1, #f0f0f0);
}
.mod-stundenzettel .stz-mehraufwand-header {
background-color: #e8f5e9;
}
.mod-stundenzettel .stz-current-stz {
background-color: #e8f4fc;
}
/* ============================================ /* ============================================
DARK MODE SUPPORT DARK MODE SUPPORT
============================================ */ ============================================ */
@media (prefers-color-scheme: dark) { body.theme-eldy-dark .mod-stundenzettel .tabsAction,
/* Nur anwenden wenn Dolibarr Dark Theme aktiv (body hat dark-Klasse) */ body[class*="dark"] .mod-stundenzettel .tabsAction {
body.theme-eldy-dark .mod-stundenzettel .tabsAction, background: var(--colorbackbody, #1e1e1e);
body[class*="dark"] .mod-stundenzettel .tabsAction { box-shadow: 0 -2px 8px rgba(0,0,0,0.4);
background: var(--colorbackbody, #1e1e1e); }
box-shadow: 0 -2px 8px rgba(0,0,0,0.4);
} body.theme-eldy-dark .mod-stundenzettel .mobile-description-row,
body[class*="dark"] .mod-stundenzettel .mobile-description-row {
body.theme-eldy-dark .mod-stundenzettel .mobile-description-row, background: var(--colorbacklineimpair2, #2d2d2d) !important;
body[class*="dark"] .mod-stundenzettel .mobile-description-row { }
background: var(--colorbacklineimpair2, #2d2d2d) !important;
} body.theme-eldy-dark .mod-stundenzettel .stz-info-box,
body[class*="dark"] .mod-stundenzettel .stz-info-box {
background-color: var(--colorbacklineimpair1, #2d2d2d) !important;
}
body.theme-eldy-dark .mod-stundenzettel .stz-warning-box,
body[class*="dark"] .mod-stundenzettel .stz-warning-box {
background-color: var(--colorbacklineimpair1, #3a3520) !important;
border-left-color: #ffc107;
}
body.theme-eldy-dark .mod-stundenzettel .stz-section-header,
body[class*="dark"] .mod-stundenzettel .stz-section-header {
background-color: var(--colorbacktitle1, #333) !important;
}
body.theme-eldy-dark .mod-stundenzettel .stz-subtable-bg,
body[class*="dark"] .mod-stundenzettel .stz-subtable-bg {
background-color: var(--colorbacklineimpair2, #2a2a2a) !important;
}
body.theme-eldy-dark .mod-stundenzettel .stz-subtable-header,
body[class*="dark"] .mod-stundenzettel .stz-subtable-header {
background-color: var(--colorbacktitle1, #333) !important;
}
body.theme-eldy-dark .mod-stundenzettel .stz-mehraufwand-header,
body[class*="dark"] .mod-stundenzettel .stz-mehraufwand-header {
background-color: var(--colorbacklineimpair1, #1e3320) !important;
}
body.theme-eldy-dark .mod-stundenzettel .stz-current-stz,
body[class*="dark"] .mod-stundenzettel .stz-current-stz {
background-color: var(--colorbacklineimpair2, #1e2e3a) !important;
}
body.theme-eldy-dark .mod-stundenzettel .info,
body[class*="dark"] .mod-stundenzettel .info {
background-color: var(--colorbacklineimpair1, #2d2d2d) !important;
} }

14
sql/llx_commande_extrafields_stundenzettel.sql Normal file → Executable file
View file

@ -12,3 +12,17 @@ ON DUPLICATE KEY UPDATE label = 'StundenzettelStatus';
-- Spalte in commande_extrafields hinzufügen falls nicht vorhanden -- Spalte in commande_extrafields hinzufügen falls nicht vorhanden
ALTER TABLE llx_commande_extrafields ADD COLUMN IF NOT EXISTS stundenzettel_status INT DEFAULT 0; ALTER TABLE llx_commande_extrafields ADD COLUMN IF NOT EXISTS stundenzettel_status INT DEFAULT 0;
-- ============================================================================
-- Extrafeld für Stundenübernahme-Modus am Auftrag
-- grouped = Alle gleichen Leistungspositionen zusammenrechnen (Standard)
-- per_stz = Pro Stundenzettel eine eigene Rechnungszeile
-- ============================================================================
INSERT INTO llx_extrafields (name, entity, elementtype, label, type, size, fieldunique, fieldrequired, pos, alwayseditable, perms, langs, list, printable, fielddefault, fieldcomputed, fk_user_author, fk_user_modif, datec, enabled, help, param)
VALUES ('stundenzettel_hours_mode', 1, 'commande', 'Stundenübernahme', 'select', '', 0, 0, 101, 1, '', '', 1, 0, 'grouped', '', NULL, NULL, NOW(), '1', 'Wie werden Arbeitsstunden in die Rechnung übertragen?',
'a:1:{s:7:"options";a:2:{s:7:"grouped";s:20:"Gruppiert (Standard)";s:7:"per_stz";s:17:"Pro Stundenzettel";}}')
ON DUPLICATE KEY UPDATE label = 'Stundenübernahme', list = 1, enabled = '1', param = 'a:1:{s:7:"options";a:2:{s:7:"grouped";s:20:"Gruppiert (Standard)";s:7:"per_stz";s:17:"Pro Stundenzettel";}}';
-- Spalte in commande_extrafields hinzufügen
ALTER TABLE llx_commande_extrafields ADD COLUMN IF NOT EXISTS stundenzettel_hours_mode VARCHAR(255) DEFAULT 'grouped';

View file

@ -397,19 +397,25 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
} }
} }
// Sammle Mehraufwand (zusätzliche Produkte mit origin = 'additional') // Sammle Mehraufwand und zusätzlich verbaute Produkte
// (origin='additional' = beauftragt, origin='added' ohne fk_commandedet = verbaut ohne Auftragszeile)
$additionalProducts = array(); $additionalProducts = array();
$sqlAdd = "SELECT sp.fk_product, sp.product_label, sp.description, SUM(sp.qty_done) as total_qty"; $sqlAdd = "SELECT sp.fk_product, sp.product_label, sp.description,";
$sqlAdd .= " SUM(CASE WHEN sp.origin = 'additional' THEN sp.qty_done ELSE 0 END) as qty_additional,";
$sqlAdd .= " SUM(CASE WHEN sp.origin IN ('order', 'added') THEN sp.qty_done ELSE 0 END) as qty_added,";
$sqlAdd .= " SUM(sp.qty_done) as total_qty";
$sqlAdd .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp"; $sqlAdd .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlAdd .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel"; $sqlAdd .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlAdd .= " WHERE s.fk_commande = ".((int)$order->id); $sqlAdd .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlAdd .= " AND sp.origin = 'additional'"; $sqlAdd .= " AND (sp.origin = 'additional' OR (sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)))";
$sqlAdd .= " GROUP BY sp.fk_product, sp.product_label, sp.description"; $sqlAdd .= " GROUP BY sp.fk_product, sp.product_label, sp.description";
$resqlAdd = $db->query($sqlAdd); $resqlAdd = $db->query($sqlAdd);
if ($resqlAdd) { if ($resqlAdd) {
while ($objAdd = $db->fetch_object($resqlAdd)) { while ($objAdd = $db->fetch_object($resqlAdd)) {
if (floatval($objAdd->total_qty) > 0) { // Für die Rechnung zählt die verbaute Menge (added), nicht nur beauftragte (additional)
$objAdd->invoice_qty = floatval($objAdd->qty_added) > 0 ? floatval($objAdd->qty_added) : floatval($objAdd->qty_additional);
if ($objAdd->invoice_qty > 0) {
$additionalProducts[] = $objAdd; $additionalProducts[] = $objAdd;
} }
} }
@ -729,7 +735,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$productResult = $facture->addline( $productResult = $facture->addline(
$product->label, // 1: desc $product->label, // 1: desc
$usePrice, // 2: pu_ht - kundenspezifischer Preis $usePrice, // 2: pu_ht - kundenspezifischer Preis
$addProd->total_qty, // 3: qty $addProd->invoice_qty, // 3: qty (verbaute Menge)
$useTvaTx, // 4: txtva - kundenspezifischer MwSt-Satz $useTvaTx, // 4: txtva - kundenspezifischer MwSt-Satz
0, // 5: txlocaltax1 0, // 5: txlocaltax1
0, // 6: txlocaltax2 0, // 6: txlocaltax2
@ -761,7 +767,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$productResult = $facture->addline( $productResult = $facture->addline(
$addProd->description ? $addProd->description : $addProd->product_label, $addProd->description ? $addProd->description : $addProd->product_label,
0, // 2: pu_ht 0, // 2: pu_ht
$addProd->total_qty, // 3: qty $addProd->invoice_qty, // 3: qty (verbaute Menge)
0, // 4: txtva 0, // 4: txtva
0, // 5: txlocaltax1 0, // 5: txlocaltax1
0, // 6: txlocaltax2 0, // 6: txlocaltax2
@ -824,7 +830,9 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
// ============================================ // ============================================
// ARBEITSZEITEN aus Stundenzetteln hinzufügen // ARBEITSZEITEN aus Stundenzetteln hinzufügen
// (Gruppiert nach Leistungsposition/Produkt) // Modus wird über Extrafeld am Auftrag gesteuert:
// grouped = Alle gleichen Leistungen zusammenrechnen (Standard)
// per_stz = Pro Stundenzettel eine eigene Rechnungszeile
// ============================================ // ============================================
// Standard-Leistung vom Kunden laden (Fallback wenn keine Leistung gewählt) // Standard-Leistung vom Kunden laden (Fallback wenn keine Leistung gewählt)
@ -833,19 +841,42 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$societe->fetch_optionals(); $societe->fetch_optionals();
$defaultServiceId = isset($societe->array_options['options_stundenzettel_default_service']) ? (int)$societe->array_options['options_stundenzettel_default_service'] : 0; $defaultServiceId = isset($societe->array_options['options_stundenzettel_default_service']) ? (int)$societe->array_options['options_stundenzettel_default_service'] : 0;
// Alle Arbeitszeiten nach Leistungsposition gruppiert sammeln // Stundenübernahme-Modus aus Auftrags-Extrafeld lesen
$sqlHours = "SELECT "; $hoursMode = isset($order->array_options['options_stundenzettel_hours_mode']) ? $order->array_options['options_stundenzettel_hours_mode'] : 'grouped';
$sqlHours .= " COALESCE(l.fk_product, ".(int)$defaultServiceId.") as product_id,"; if (empty($hoursMode)) $hoursMode = 'grouped';
$sqlHours .= " p.ref as product_ref, p.label as product_label, p.tva_tx, p.fk_unit,";
$sqlHours .= " SUM(l.duration) as total_minutes"; if ($hoursMode == 'per_stz') {
$sqlHours .= " FROM ".MAIN_DB_PREFIX."stundenzettel s"; // === PRO STUNDENZETTEL: Jeder STZ bekommt eigene Zeilen ===
$sqlHours .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel_leistung l ON l.fk_stundenzettel = s.rowid"; $sqlHours = "SELECT ";
$sqlHours .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = COALESCE(l.fk_product, ".(int)$defaultServiceId.")"; $sqlHours .= " s.rowid as stz_id, s.ref as stz_ref, s.date_stundenzettel,";
$sqlHours .= " WHERE s.fk_commande = ".((int)$order->id); $sqlHours .= " COALESCE(l.fk_product, ".(int)$defaultServiceId.") as product_id,";
$sqlHours .= " AND s.status >= 1"; // Nur validierte Stundenzettel $sqlHours .= " p.ref as product_ref, p.label as product_label, p.tva_tx, p.fk_unit,";
$sqlHours .= " GROUP BY COALESCE(l.fk_product, ".(int)$defaultServiceId."), p.ref, p.label, p.tva_tx, p.fk_unit"; $sqlHours .= " SUM(l.duration) as total_minutes,";
$sqlHours .= " HAVING SUM(l.duration) > 0"; $sqlHours .= " GROUP_CONCAT(l.description ORDER BY l.rang SEPARATOR '\n') as leistung_desc";
$sqlHours .= " ORDER BY p.label"; $sqlHours .= " FROM ".MAIN_DB_PREFIX."stundenzettel s";
$sqlHours .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel_leistung l ON l.fk_stundenzettel = s.rowid";
$sqlHours .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = COALESCE(l.fk_product, ".(int)$defaultServiceId.")";
$sqlHours .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlHours .= " AND s.status >= 1";
$sqlHours .= " GROUP BY s.rowid, s.ref, s.date_stundenzettel, COALESCE(l.fk_product, ".(int)$defaultServiceId."), p.ref, p.label, p.tva_tx, p.fk_unit";
$sqlHours .= " HAVING SUM(l.duration) > 0";
$sqlHours .= " ORDER BY s.date_stundenzettel, s.ref, p.label";
} else {
// === GRUPPIERT: Alle gleichen Leistungspositionen zusammen (Standard) ===
$sqlHours = "SELECT ";
$sqlHours .= " COALESCE(l.fk_product, ".(int)$defaultServiceId.") as product_id,";
$sqlHours .= " p.ref as product_ref, p.label as product_label, p.tva_tx, p.fk_unit,";
$sqlHours .= " SUM(l.duration) as total_minutes,";
$sqlHours .= " GROUP_CONCAT(l.description ORDER BY s.date_stundenzettel, l.rang SEPARATOR '\n') as leistung_desc";
$sqlHours .= " FROM ".MAIN_DB_PREFIX."stundenzettel s";
$sqlHours .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel_leistung l ON l.fk_stundenzettel = s.rowid";
$sqlHours .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = COALESCE(l.fk_product, ".(int)$defaultServiceId.")";
$sqlHours .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlHours .= " AND s.status >= 1";
$sqlHours .= " GROUP BY COALESCE(l.fk_product, ".(int)$defaultServiceId."), p.ref, p.label, p.tva_tx, p.fk_unit";
$sqlHours .= " HAVING SUM(l.duration) > 0";
$sqlHours .= " ORDER BY p.label";
}
$resqlHours = $db->query($sqlHours); $resqlHours = $db->query($sqlHours);
$hasWorkHours = ($resqlHours && $db->num_rows($resqlHours) > 0); $hasWorkHours = ($resqlHours && $db->num_rows($resqlHours) > 0);
@ -872,7 +903,7 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
} }
} }
// Arbeitszeiten hinzufügen (pro Leistungsposition) // Arbeitszeiten hinzufügen
$arbeitszeitSubtotal = 0; $arbeitszeitSubtotal = 0;
while ($objHours = $db->fetch_object($resqlHours)) { while ($objHours = $db->fetch_object($resqlHours)) {
$productId = (int)$objHours->product_id; $productId = (int)$objHours->product_id;
@ -883,8 +914,22 @@ if ($action == 'confirm_transfer_invoice' && GETPOST('confirm', 'alpha') == 'yes
$hourlyPrice = $priceInfo['price']; $hourlyPrice = $priceInfo['price'];
$tvaTx = !empty($objHours->tva_tx) ? $objHours->tva_tx : $priceInfo['tva_tx']; $tvaTx = !empty($objHours->tva_tx) ? $objHours->tva_tx : $priceInfo['tva_tx'];
// Beschreibung // Beschreibung je nach Modus
$lineDesc = !empty($objHours->product_label) ? $objHours->product_label : $langs->trans('DefaultService'); $productLabel = !empty($objHours->product_label) ? $objHours->product_label : $langs->trans('DefaultService');
if ($hoursMode == 'per_stz') {
// Pro STZ: Produktname mit STZ-Referenz und Datum
$stzDate = dol_print_date($db->jdate($objHours->date_stundenzettel), 'day');
$lineDesc = $productLabel.' ('.$objHours->stz_ref.', '.$stzDate.')';
} else {
// Gruppiert: Nur Produktname
$lineDesc = $productLabel;
}
// Leistungsbeschreibungen anhängen (wenn vorhanden)
if (!empty($objHours->leistung_desc)) {
$lineDesc .= "\n".$objHours->leistung_desc;
}
// Rechnungszeile hinzufügen // Rechnungszeile hinzufügen
$hoursResult = $facture->addline( $hoursResult = $facture->addline(
@ -1075,7 +1120,7 @@ if ($hasStundenzettel) {
$sqlRemaining .= " FROM ".MAIN_DB_PREFIX."commandedet as cd"; $sqlRemaining .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sqlRemaining .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product"; $sqlRemaining .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sqlRemaining .= " WHERE cd.fk_commande = ".((int)$order->id); $sqlRemaining .= " WHERE cd.fk_commande = ".((int)$order->id);
$sqlRemaining .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))"; $sqlRemaining .= " AND (cd.fk_product > 0 OR ((cd.fk_product IS NULL OR cd.fk_product = 0) AND cd.description IS NOT NULL AND cd.description != ''))";
$sqlRemaining .= " AND (cd.special_code IS NULL OR cd.special_code = 0)"; $sqlRemaining .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
$sqlRemaining .= " HAVING qty_ordered > qty_documented"; $sqlRemaining .= " HAVING qty_ordered > qty_documented";
@ -1276,7 +1321,7 @@ if ($resql && $db->num_rows($resql) > 0) {
print '<div class="info" style="margin-bottom:20px;">'; print '<div class="info" style="margin-bottom:20px;">';
print '<strong>'.img_picto('', 'note', 'class="pictofixedwidth"').$langs->trans("NotesForNextVisit").':</strong><br>'; print '<strong>'.img_picto('', 'note', 'class="pictofixedwidth"').$langs->trans("NotesForNextVisit").':</strong><br>';
while ($obj = $db->fetch_object($resql)) { while ($obj = $db->fetch_object($resql)) {
print '<div style="margin:5px 0; padding:5px; background:#fff; border-left:3px solid #0077b3;">'; print '<div class="stz-info-box" style="margin:5px 0; padding:5px; border-left:3px solid #0077b3;">';
print '<small class="opacitymedium">'.dol_print_date($db->jdate($obj->date_stundenzettel), 'day').' ('.$obj->ref.'):</small><br>'; print '<small class="opacitymedium">'.dol_print_date($db->jdate($obj->date_stundenzettel), 'day').' ('.$obj->ref.'):</small><br>';
print dol_htmlentitiesbr($obj->note_public); print dol_htmlentitiesbr($obj->note_public);
print '</div>'; print '</div>';
@ -1314,7 +1359,7 @@ if ($notesStundenzettel && $notesStundenzettel->id > 0) {
// Merkzettel anzeigen, wenn welche vorhanden sind // Merkzettel anzeigen, wenn welche vorhanden sind
if (count($notesToShow) > 0) { if (count($notesToShow) > 0) {
print '<div class="info" style="margin-bottom:15px; background-color: #fff8e1; border-left: 4px solid #ffc107; padding: 10px;">'; print '<div class="info stz-warning-box" style="margin-bottom:15px; padding: 10px;">';
print '<strong>'.img_picto('', 'list', 'class="pictofixedwidth"').$langs->trans("NotesMemo").'</strong>'; print '<strong>'.img_picto('', 'list', 'class="pictofixedwidth"').$langs->trans("NotesMemo").'</strong>';
print ' <small class="opacitymedium">('.$notesStundenzettel->ref.')</small>'; print ' <small class="opacitymedium">('.$notesStundenzettel->ref.')</small>';
print '<ul style="margin: 5px 0 0 0; padding-left: 20px; list-style: none;">'; print '<ul style="margin: 5px 0 0 0; padding-left: 20px; list-style: none;">';
@ -1387,7 +1432,7 @@ if ($tab == 'stundenzettel') {
if ($numStz > 0) { if ($numStz > 0) {
while ($objStz = $db->fetch_object($resqlStzList)) { while ($objStz = $db->fetch_object($resqlStzList)) {
$isCurrentStz = ($stundenzettel_id > 0 && $objStz->rowid == $stundenzettel_id); $isCurrentStz = ($stundenzettel_id > 0 && $objStz->rowid == $stundenzettel_id);
print '<tr class="oddeven"'.($isCurrentStz ? ' style="background-color: #e8f4fc;"' : '').'>'; print '<tr class="oddeven'.($isCurrentStz ? ' stz-current-stz' : '').'">';
// Ref // Ref
print '<td>'; print '<td>';
@ -1621,11 +1666,11 @@ if ($tab == 'products') {
// qty_removed: Entfällt für dieses Produkt (origin = 'omitted') // qty_removed: Entfällt für dieses Produkt (origin = 'omitted')
$sql .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3"; $sql .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel"; $sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
$sql .= " WHERE sp3.fk_product = cd.fk_product AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_removed,"; $sql .= " WHERE (sp3.fk_commandedet = cd.rowid OR (sp3.fk_product = cd.fk_product AND cd.fk_product > 0)) AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_removed,";
// qty_returned: Rücknahme für dieses Produkt (origin = 'returned') // qty_returned: Rücknahme für dieses Produkt (origin = 'returned')
$sql .= " COALESCE((SELECT SUM(sp4.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp4"; $sql .= " COALESCE((SELECT SUM(sp4.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp4";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s4 ON s4.rowid = sp4.fk_stundenzettel"; $sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s4 ON s4.rowid = sp4.fk_stundenzettel";
$sql .= " WHERE sp4.fk_product = cd.fk_product AND sp4.origin = 'returned' AND s4.fk_commande = ".((int)$order->id)."), 0) as qty_returned"; $sql .= " WHERE (sp4.fk_commandedet = cd.rowid OR (sp4.fk_product = cd.fk_product AND cd.fk_product > 0)) AND sp4.origin = 'returned' AND s4.fk_commande = ".((int)$order->id)."), 0) as qty_returned";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager as m"; $sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager as m";
$sql .= " JOIN ".MAIN_DB_PREFIX."commandedet as cd ON cd.rowid = m.fk_commandedet"; $sql .= " JOIN ".MAIN_DB_PREFIX."commandedet as cd ON cd.rowid = m.fk_commandedet";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
@ -1662,15 +1707,15 @@ if ($tab == 'products') {
// qty_removed: Entfällt für dieses Produkt (origin = 'omitted') // qty_removed: Entfällt für dieses Produkt (origin = 'omitted')
$sql .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3"; $sql .= " COALESCE((SELECT SUM(sp3.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp3";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel"; $sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
$sql .= " WHERE sp3.fk_product = cd.fk_product AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_removed,"; $sql .= " WHERE (sp3.fk_commandedet = cd.rowid OR (sp3.fk_product = cd.fk_product AND cd.fk_product > 0)) AND sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id)."), 0) as qty_removed,";
// qty_returned: Rücknahme für dieses Produkt (origin = 'returned') // qty_returned: Rücknahme für dieses Produkt (origin = 'returned')
$sql .= " COALESCE((SELECT SUM(sp4.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp4"; $sql .= " COALESCE((SELECT SUM(sp4.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp4";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s4 ON s4.rowid = sp4.fk_stundenzettel"; $sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s4 ON s4.rowid = sp4.fk_stundenzettel";
$sql .= " WHERE sp4.fk_product = cd.fk_product AND sp4.origin = 'returned' AND s4.fk_commande = ".((int)$order->id)."), 0) as qty_returned"; $sql .= " WHERE (sp4.fk_commandedet = cd.rowid OR (sp4.fk_product = cd.fk_product AND cd.fk_product > 0)) AND sp4.origin = 'returned' AND s4.fk_commande = ".((int)$order->id)."), 0) as qty_returned";
$sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd"; $sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE cd.fk_commande = ".((int)$order->id); $sql .= " WHERE cd.fk_commande = ".((int)$order->id);
$sql .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))"; $sql .= " AND (cd.fk_product > 0 OR ((cd.fk_product IS NULL OR cd.fk_product = 0) AND cd.description IS NOT NULL AND cd.description != ''))";
$sql .= " AND (cd.special_code IS NULL OR cd.special_code = 0)"; // Keine SubtotalTitle-Spezialzeilen $sql .= " AND (cd.special_code IS NULL OR cd.special_code = 0)"; // Keine SubtotalTitle-Spezialzeilen
$sql .= " ORDER BY cd.rang"; $sql .= " ORDER BY cd.rang";
@ -1845,7 +1890,7 @@ if ($tab == 'products') {
// Section-Header mit Buffering, wird nur ausgegeben wenn Produkte angezeigt werden // Section-Header mit Buffering, wird nur ausgegeben wenn Produkte angezeigt werden
// Dezente Farbgebung: nur linker Rand farbig, heller Hintergrund // Dezente Farbgebung: nur linker Rand farbig, heller Hintergrund
$sectionHeader = '<tr class="liste_titre section-header" data-section="section_'.$sectionId.'" onclick="toggleSection(\'section_'.$sectionId.'\')" style="cursor: pointer;">'; $sectionHeader = '<tr class="liste_titre section-header" data-section="section_'.$sectionId.'" onclick="toggleSection(\'section_'.$sectionId.'\')" style="cursor: pointer;">';
$sectionHeader .= '<td colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid '.$section['color'].'; background-color: #f8f8f8;">'; $sectionHeader .= '<td class="stz-section-header" colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid '.$section['color'].';">';
$sectionHeader .= '<span class="section-toggle" id="toggle_section_'.$sectionId.'" style="margin-right: 10px;">▼</span>'; $sectionHeader .= '<span class="section-toggle" id="toggle_section_'.$sectionId.'" style="margin-right: 10px;">▼</span>';
$sectionHeader .= dol_escape_htmltag($section['title']); $sectionHeader .= dol_escape_htmltag($section['title']);
$sectionHeader .= ' <span style="opacity: 0.6; font-weight: normal;">('.$visibleProductsInSection.' '.$langs->trans("Products").')</span>'; $sectionHeader .= ' <span style="opacity: 0.6; font-weight: normal;">('.$visibleProductsInSection.' '.$langs->trans("Products").')</span>';
@ -1887,7 +1932,7 @@ if ($tab == 'products') {
if (count($sections) > 0) { if (count($sections) > 0) {
// Trennzeile nur wenn es auch Sections gibt - einklappbar, dezente Farbgebung // Trennzeile nur wenn es auch Sections gibt - einklappbar, dezente Farbgebung
print '<tr class="liste_titre section-header" data-section="section_other" onclick="toggleSection(\'section_other\')" style="cursor: pointer;">'; print '<tr class="liste_titre section-header" data-section="section_other" onclick="toggleSection(\'section_other\')" style="cursor: pointer;">';
print '<td colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid #666; background-color: #f8f8f8;">'; print '<td class="stz-section-header" colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid #666;">';
print '<span class="section-toggle" id="toggle_section_other" style="margin-right: 10px;">▼</span>'; print '<span class="section-toggle" id="toggle_section_other" style="margin-right: 10px;">▼</span>';
print $langs->trans("OtherProducts").' <span style="opacity: 0.6; font-weight: normal;">('.$visibleProductsWithoutSection.' '.$langs->trans("Products").')</span>'; print $langs->trans("OtherProducts").' <span style="opacity: 0.6; font-weight: normal;">('.$visibleProductsWithoutSection.' '.$langs->trans("Products").')</span>';
print '</td>'; print '</td>';
@ -1937,7 +1982,7 @@ if ($tab == 'products') {
if ($mehraufwandCount > 0) { if ($mehraufwandCount > 0) {
// Mehraufwand-Header - einklappbar, dezente Farbgebung // Mehraufwand-Header - einklappbar, dezente Farbgebung
print '<tr class="liste_titre section-header" data-section="section_mehraufwand" onclick="toggleSection(\'section_mehraufwand\')" style="cursor: pointer;">'; print '<tr class="liste_titre section-header" data-section="section_mehraufwand" onclick="toggleSection(\'section_mehraufwand\')" style="cursor: pointer;">';
print '<td colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid #e67e22; background-color: #f8f8f8;">'; print '<td class="stz-section-header" colspan="6" style="font-weight: bold; padding: 8px; border-left: 4px solid #e67e22;">';
print '<span class="section-toggle" id="toggle_section_mehraufwand" style="margin-right: 10px;">▼</span>'; print '<span class="section-toggle" id="toggle_section_mehraufwand" style="margin-right: 10px;">▼</span>';
print '<span style="margin-right: 10px; color: #e67e22;">⚠</span>'; print '<span style="margin-right: 10px; color: #e67e22;">⚠</span>';
print $langs->trans("Mehraufwand"); print $langs->trans("Mehraufwand");
@ -2271,7 +2316,7 @@ if ($tab == 'tracking') {
$sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd"; $sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE cd.fk_commande = ".((int)$order->id); $sql .= " WHERE cd.fk_commande = ".((int)$order->id);
$sql .= " AND (cd.fk_product > 0 OR (cd.fk_product = 0 AND cd.description IS NOT NULL AND cd.description != ''))"; $sql .= " AND (cd.fk_product > 0 OR ((cd.fk_product IS NULL OR cd.fk_product = 0) AND cd.description IS NOT NULL AND cd.description != ''))";
$sql .= " AND (cd.special_code IS NULL OR cd.special_code = 0)"; $sql .= " AND (cd.special_code IS NULL OR cd.special_code = 0)";
$sql .= " ORDER BY cd.rang"; $sql .= " ORDER BY cd.rang";
@ -2364,11 +2409,11 @@ if ($tab == 'tracking') {
// Detail-Zeile (standardmäßig eingeklappt) // Detail-Zeile (standardmäßig eingeklappt)
if ($hasDetails) { if ($hasDetails) {
print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">'; print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">';
print '<td colspan="5" style="padding: 0 0 0 30px; background-color: #fafafa;">'; print '<td class="stz-subtable-bg" colspan="5" style="padding: 0 0 0 30px;">';
// Sub-Tabelle für Details // Sub-Tabelle für Details
print '<table class="noborder" style="width:100%; margin: 5px 0;">'; print '<table class="noborder" style="width:100%; margin: 5px 0;">';
print '<tr class="liste_titre" style="background-color: #f0f0f0;">'; print '<tr class="liste_titre stz-subtable-header">';
print '<th style="width:120px;">'.$langs->trans("Stundenzettel").'</th>'; print '<th style="width:120px;">'.$langs->trans("Stundenzettel").'</th>';
print '<th style="width:100px;">'.$langs->trans("Date").'</th>'; print '<th style="width:100px;">'.$langs->trans("Date").'</th>';
print '<th style="width:100px;">'.$langs->trans("Type").'</th>'; print '<th style="width:100px;">'.$langs->trans("Type").'</th>';
@ -2457,18 +2502,19 @@ if ($tab == 'tracking') {
} }
} }
// Mehraufwand-Produkte laden die NICHT im Auftrag sind // Mehraufwand und zusätzlich verbaute Produkte laden die NICHT im Auftrag sind
// Beauftragt (additional) und verbaut (added ohne fk_commandedet) getrennt summieren
$sqlMehraufwand = "SELECT sp.fk_product, sp.description,"; $sqlMehraufwand = "SELECT sp.fk_product, sp.description,";
$sqlMehraufwand .= " p.ref as product_ref, p.label as product_label,"; $sqlMehraufwand .= " p.ref as product_ref, p.label as product_label,";
$sqlMehraufwand .= " SUM(sp.qty) as qty_ordered,"; $sqlMehraufwand .= " SUM(CASE WHEN sp.origin = 'additional' THEN sp.qty_done ELSE 0 END) as qty_additional,";
$sqlMehraufwand .= " SUM(sp.qty_done) as qty_delivered"; $sqlMehraufwand .= " SUM(CASE WHEN sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0) THEN sp.qty_done ELSE 0 END) as qty_added";
$sqlMehraufwand .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp"; $sqlMehraufwand .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlMehraufwand .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel"; $sqlMehraufwand .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlMehraufwand .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = sp.fk_product"; $sqlMehraufwand .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = sp.fk_product";
$sqlMehraufwand .= " WHERE s.fk_commande = ".((int)$order->id); $sqlMehraufwand .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlMehraufwand .= " AND sp.origin = 'additional'"; $sqlMehraufwand .= " AND (sp.origin = 'additional' OR (sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)))";
if (!empty($orderProductIds)) { if (!empty($orderProductIds)) {
$sqlMehraufwand .= " AND (sp.fk_product NOT IN (".implode(',', $orderProductIds).") OR sp.fk_product = 0)"; $sqlMehraufwand .= " AND (sp.fk_product NOT IN (".implode(',', $orderProductIds).") OR sp.fk_product IS NULL OR sp.fk_product = 0)";
} }
$sqlMehraufwand .= " GROUP BY sp.fk_product, sp.description, p.ref, p.label"; $sqlMehraufwand .= " GROUP BY sp.fk_product, sp.description, p.ref, p.label";
$sqlMehraufwand .= " ORDER BY p.ref, sp.description"; $sqlMehraufwand .= " ORDER BY p.ref, sp.description";
@ -2479,14 +2525,18 @@ if ($tab == 'tracking') {
if ($resqlMehr && $db->num_rows($resqlMehr) > 0) { if ($resqlMehr && $db->num_rows($resqlMehr) > 0) {
// Separator-Zeile für Mehraufwand // Separator-Zeile für Mehraufwand
print '<tr class="liste_titre">'; print '<tr class="liste_titre">';
print '<td colspan="5" style="background-color: #e8f5e9;"><strong>'.$langs->trans("Mehraufwand").'</strong> <span class="opacitymedium">('.$langs->trans("MehraufwandDesc").')</span></td>'; print '<td class="stz-mehraufwand-header" colspan="5"><strong>'.$langs->trans("Mehraufwand").'</strong> <span class="opacitymedium">('.$langs->trans("MehraufwandDesc").')</span></td>';
print '</tr>'; print '</tr>';
while ($objMehr = $db->fetch_object($resqlMehr)) { while ($objMehr = $db->fetch_object($resqlMehr)) {
$hasMehraufwandProducts = true; $hasMehraufwandProducts = true;
$detailRowId++; $detailRowId++;
$qty_ordered_mehr = (float)$objMehr->qty_ordered; $qtyAdditional = (float)$objMehr->qty_additional;
$qty_delivered_mehr = (float)$objMehr->qty_delivered; $qtyAdded = (float)$objMehr->qty_added;
// Beauftragt: Wenn Mehraufwand existiert, dessen Menge, sonst die verbaute Menge
$qty_ordered_mehr = ($qtyAdditional > 0) ? $qtyAdditional : $qtyAdded;
// Verbaut: Immer die tatsächlich verbaute Menge
$qty_delivered_mehr = $qtyAdded;
$qty_remaining_mehr = $qty_ordered_mehr - $qty_delivered_mehr; $qty_remaining_mehr = $qty_ordered_mehr - $qty_delivered_mehr;
// Details für Mehraufwand laden // Details für Mehraufwand laden
@ -2565,9 +2615,9 @@ if ($tab == 'tracking') {
// Detail-Zeile für Mehraufwand // Detail-Zeile für Mehraufwand
if ($hasDetailsMehr) { if ($hasDetailsMehr) {
print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">'; print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">';
print '<td colspan="5" style="padding: 0 0 0 30px; background-color: #fafafa;">'; print '<td class="stz-subtable-bg" colspan="5" style="padding: 0 0 0 30px;">';
print '<table class="noborder" style="width:100%; margin: 5px 0;">'; print '<table class="noborder" style="width:100%; margin: 5px 0;">';
print '<tr class="liste_titre" style="background-color: #f0f0f0;">'; print '<tr class="liste_titre stz-subtable-header">';
print '<th style="width:120px;">'.$langs->trans("Stundenzettel").'</th>'; print '<th style="width:120px;">'.$langs->trans("Stundenzettel").'</th>';
print '<th style="width:100px;">'.$langs->trans("Date").'</th>'; print '<th style="width:100px;">'.$langs->trans("Date").'</th>';
print '<th style="width:100px;">'.$langs->trans("Type").'</th>'; print '<th style="width:100px;">'.$langs->trans("Type").'</th>';