diff --git a/README.md b/README.md index ff7ce3c..a18052c 100755 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Stundenzettel Modul für Dolibarr -**Version:** 1.4.0 +**Version:** 1.6.0 **Autor:** Data IT Solution **Kompatibilität:** Dolibarr 16.0+ **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 - **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 @@ -55,6 +57,17 @@ Die Moduleinstellungen finden Sie unter **Einstellungen > Module > Stundenzettel | **Standard-Datum** | Aktuelles Datum oder Datum des letzten offenen Stundenzettels | | **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 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 | | `stundenzettel_status` | Auftrag | Status der Stundenzettel (0=Offen, 1=Freigegeben, 2=Abgerechnet) | | `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 | ## Berechtigungen @@ -100,6 +114,20 @@ Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung ## 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 - **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) diff --git a/card.php b/card.php index 369c2a5..ed4df5c 100644 --- a/card.php +++ b/card.php @@ -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 .= " 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 .= " 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 .= " WHERE cd.fk_commande = ".((int)$object->fk_commande); $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' $result = $object->addProduct( $fk_product, - 0, // fk_commandedet + $commandedet_id, // fk_commandedet (Verknüpfung zur Auftragszeile) 0, // fk_manager_line 0, // qty_original $qty, // qty_done (Menge die entfällt) @@ -640,6 +640,14 @@ if ($action == 'add_ruecknahme' && $permissiontoadd) { if ($resqlDesc && ($objDesc = $db->fetch_object($resqlDesc))) { $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 { $fk_product = (int)$ruecknahme_product_raw; } @@ -1587,11 +1595,11 @@ elseif ($object->id > 0) { // 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 .= " 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 .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product"; $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 .= " ORDER BY cd.rang"; $resqlOrderProducts = $db->query($sqlOrderProducts); @@ -2055,7 +2063,7 @@ elseif ($object->id > 0) { $sqlDelivered .= " FROM ".MAIN_DB_PREFIX."commandedet as cd"; $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 .= " 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 .= " ORDER BY cd.rang"; $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); @@ -2089,11 +2160,19 @@ elseif ($object->id > 0) { foreach ($deliveredProducts as $dp) { if ($dp->fk_product > 0) { - print ''; + } elseif (!empty($dp->is_mehraufwand_freetext)) { + // Freitext-Mehraufwand + $desc = strip_tags($dp->description); + $descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc; + print ''; } else { + // Freitext aus Auftrag $desc = strip_tags($dp->description); $descShort = (strlen($desc) > 50) ? substr($desc, 0, 47).'...' : $desc; print '