Version 1.7.0: Rücknahme-Anzeige und Filter verbessert

- Rücknahmen mit rotem Badge (-X) bei Beauftragt und Verbaut
- Rücknahmen werden von der Zielmenge abgezogen (nicht wieder verbauen)
- Status zeigt "Erledigt" wenn alles zurückgenommen wurde
- Mehraufwand-Bereich wird nach Filter (offen/erledigt) gefiltert
- Tracking-Tab berücksichtigt Rücknahmen korrekt
- Freitext-Rücknahmen werden über description gematcht
- Bugfix: product_label bei Freitext-Rücknahmen korrekt gesetzt
- Dokumentation und Berechtigungen aktualisiert

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Eduard Wisch 2026-02-23 10:17:06 +01:00
parent 41166811dd
commit a69e308a68
6 changed files with 257 additions and 51 deletions

View file

@ -1,6 +1,6 @@
# Stundenzettel Modul für Dolibarr
**Version:** 1.6.0
**Version:** 1.7.0
**Autor:** Data IT Solution
**Kompatibilität:** Dolibarr 16.0+
**Lizenz:** GPL v3
@ -97,10 +97,13 @@ Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung
| Berechtigung | Beschreibung |
|--------------|--------------|
| Lesen | Stundenzettel anzeigen |
| Erstellen/Bearbeiten | Stundenzettel erstellen und bearbeiten |
| Eigene Stundenzettel lesen | Eigene Stundenzettel anzeigen |
| Alle Stundenzettel lesen | Alle Stundenzettel anzeigen |
| Stundenzettel erstellen | Stundenzettel erstellen |
| Alle Stundenzettel bearbeiten | Alle Stundenzettel bearbeiten (Admin) |
| Freigeben | Stundenzettel freigeben/sperren |
| Löschen | Stundenzettel löschen |
| Eigene Stundenzettel löschen | Eigene Stundenzettel löschen |
| Alle Stundenzettel löschen | Alle Stundenzettel löschen (Admin) |
## Workflow
@ -114,6 +117,15 @@ Sie können beim Kunden (unter **Kunden > Kundenkarte**) eine Standard-Leistung
## Changelog
### Version 1.7.0
- **Rücknahme-Anzeige verbessert**: Rücknahmen werden mit rotem Badge (-X) angezeigt
- **Rücknahme beeinflusst Beauftragt**: Zurückgenommene Mengen werden von der Zielmenge abgezogen (soll nicht wieder verbaut werden)
- **Status korrekt bei Rücknahme**: Wenn alles zurückgenommen wurde, zeigt der Status "Erledigt" statt "Offen"
- **Mehraufwand-Filter**: Der Mehraufwand-Bereich wird jetzt auch nach dem aktiven Filter (offen/erledigt/alle) gefiltert
- **Tracking-Tab Rücknahmen**: Rücknahmen werden auch im Tracking-Tab (Lieferauflistung) korrekt berücksichtigt
- **Freitext-Rücknahmen**: Rücknahmen von Freitext-Produkten werden über description gematcht und korrekt zugeordnet
- **Bugfix Freitext-Label**: Bei Rücknahme von Freitext-Produkten wird jetzt auch product_label korrekt gesetzt
### 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

View file

@ -629,6 +629,7 @@ if ($action == 'add_ruecknahme' && $permissiontoadd) {
// Prüfen ob es ein Freitext-Produkt oder normales Produkt ist
$fk_product = 0;
$freetext_description = '';
$freetext_label = ''; // Label für Freitext-Produkte (wird in product_label gespeichert)
$commandedet_id = 0;
if (strpos($ruecknahme_product_raw, 'freetext_') === 0) {
@ -639,24 +640,24 @@ if ($action == 'add_ruecknahme' && $permissiontoadd) {
$resqlDesc = $db->query($sqlDesc);
if ($resqlDesc && ($objDesc = $db->fetch_object($resqlDesc))) {
$freetext_description = $objDesc->description;
$freetext_label = strip_tags($objDesc->description); // Label = Freitext ohne HTML
}
} 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);
$sqlDesc = "SELECT description, product_label 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;
// Bei Freitext-Mehraufwand ist product_label leer, also description als Label nutzen
$freetext_label = !empty($objDesc->product_label) ? $objDesc->product_label : strip_tags($objDesc->description);
}
} else {
$fk_product = (int)$ruecknahme_product_raw;
}
// Beschreibung: Freitext-Beschreibung + Grund
// Beschreibung für die Rücknahme: nur der Grund (das Label steht separat)
$description = $reason;
if (!empty($freetext_description)) {
$description = strip_tags($freetext_description) . (!empty($reason) ? ' - ' . $reason : '');
}
$error = 0;
@ -715,7 +716,8 @@ if ($action == 'add_ruecknahme' && $permissiontoadd) {
0, // qty_original
$qty, // qty_done (Menge die zurückgenommen wird)
'returned', // origin (rücknahme)
$description // description (Grund)
$description, // description (Grund)
$freetext_label // product_label für Freitext-Produkte
);
if ($result > 0) {
setEventMessages($langs->trans('RecordSaved'), null, 'mesgs');

View file

@ -608,9 +608,10 @@ class Stundenzettel extends CommonObject
* @param float $qty_done Done qty
* @param string $origin Origin (order or added)
* @param string $description Description (for free-text products)
* @param string $product_label_override Label für Freitext-Produkte (ohne fk_product)
* @return int <0 if KO, >0 if OK
*/
public function addProduct($fk_product, $fk_commandedet = null, $fk_manager_line = null, $qty_original = 0, $qty_done = 0, $origin = 'order', $description = '')
public function addProduct($fk_product, $fk_commandedet = null, $fk_manager_line = null, $qty_original = 0, $qty_done = 0, $origin = 'order', $description = '', $product_label_override = '')
{
global $db;
@ -626,6 +627,11 @@ class Stundenzettel extends CommonObject
}
}
// Override für Freitext-Produkte (kein fk_product, aber Label vorhanden)
if (!empty($product_label_override) && empty($product_label)) {
$product_label = $product_label_override;
}
// Get next rang
$sql = "SELECT MAX(rang) as maxrang FROM ".MAIN_DB_PREFIX."stundenzettel_product WHERE fk_stundenzettel = ".((int)$this->id);
$resql = $this->db->query($sql);

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.";
// Version
$this->version = '1.6.0';
$this->version = '1.7.0';
// Autor
$this->editor_name = 'Data IT Solution';

View file

@ -180,6 +180,8 @@ ErrorAlreadyValidated = Stundenzettel bereits freigegeben
ErrorQtyExceedsAvailable = Menge überschreitet verfügbare Menge (max. %s)
ErrorTimeOverlap = Zeitüberschneidung mit bestehender Leistung (%s - %s)
ErrorStundenzettelReleased = Die Stundenzettel für diesen Auftrag wurden bereits freigegeben und können nicht mehr geändert werden
RuecknahmeOhneProdukt = Rücknahme ohne passendes Produkt gefunden
Error = Fehler
# Widgets
BoxRecentStundenzettel = Zuletzt bearbeitete Stundenzettel

View file

@ -1756,8 +1756,10 @@ if ($tab == 'products') {
$qty_removed = isset($obj->qty_removed) ? (float)$obj->qty_removed : 0;
$qty_returned = isset($obj->qty_returned) ? (float)$obj->qty_returned : 0;
// Effektive Gesamtmenge = Original + Hinzugefügt - Entfallen
$effectiveTotal = $obj->qty + $qty_added - $qty_removed;
// Effektive Gesamtmenge = Original + Hinzugefügt - Entfallen - Rücknahmen
// Rücknahmen werden auch von der Zielmenge abgezogen (soll nicht wieder verbaut werden)
$effectiveTotalBase = $obj->qty + $qty_added - $qty_removed;
$effectiveTotal = $effectiveTotalBase - $qty_returned;
// Effektive Liefermenge = Geliefert - Zurückgenommen
$effectiveDelivered = $obj->qty_delivered - $qty_returned;
$remaining = $effectiveTotal - $effectiveDelivered;
@ -1818,13 +1820,16 @@ if ($tab == 'products') {
if ($qty_removed > 0) {
print ' <span class="badge" style="background-color: #dc3545; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Entfaellt").'">-'.formatQty($qty_removed).'</span>';
}
if ($qty_returned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("Ruecknahme").'">-'.formatQty($qty_returned).'</span>';
}
print '</td>';
// Menge geliefert/verbaut (abzüglich Rücknahmen)
print '<td class="right">';
print formatQty($effectiveDelivered);
if ($qty_returned > 0) {
print ' <span class="badge" style="background-color: #6c757d; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Ruecknahme").'">-'.formatQty($qty_returned).'</span>';
print ' <span class="badge badge-danger" title="'.$langs->trans("Ruecknahme").'">-'.formatQty($qty_returned).'</span>';
}
print '</td>';
@ -1959,6 +1964,7 @@ if ($tab == 'products') {
// Logik:
// - Wenn NUR 'added' existiert: Beauftragt = Verbaut = Menge
// - Wenn 'additional' existiert: Beauftragt von 'additional', Verbaut von 'added'
// - Rücknahmen werden separat behandelt und müssen ein passendes Produkt finden
$sqlMehraufwand = "SELECT sp.fk_product, sp.product_ref, sp.product_label, sp.description,";
// qty_additional = Menge aus Mehraufwand-Zeilen
$sqlMehraufwand .= " SUM(CASE WHEN sp.origin = 'additional' THEN sp.qty_done ELSE 0 END) as qty_additional,";
@ -1968,29 +1974,82 @@ if ($tab == 'products') {
$sqlMehraufwand .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlMehraufwand .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlMehraufwand .= " WHERE s.fk_commande = ".((int)$order->id);
// Beide Typen: Mehraufwand ODER hinzugefügt ohne Auftragszeile
// Nur Mehraufwand und hinzugefügt (Rücknahmen werden separat geladen)
$sqlMehraufwand .= " AND (sp.origin = 'additional' OR (sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)))";
$sqlMehraufwand .= " GROUP BY sp.fk_product, sp.product_ref, sp.product_label, sp.description";
$sqlMehraufwand .= " ORDER BY sp.product_ref, sp.description";
$resqlMehraufwand = $db->query($sqlMehraufwand);
$mehraufwandCount = 0;
if ($resqlMehraufwand) {
$mehraufwandCount = $db->num_rows($resqlMehraufwand);
// Rücknahmen separat laden um sie den Produkten zuzuordnen
$sqlReturned = "SELECT sp.fk_product, sp.product_label, sp.description, SUM(sp.qty_done) as qty_returned,";
$sqlReturned .= " GROUP_CONCAT(DISTINCT s.ref SEPARATOR ', ') as stundenzettel_refs";
$sqlReturned .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlReturned .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlReturned .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlReturned .= " AND sp.origin = 'returned'";
$sqlReturned .= " GROUP BY sp.fk_product, sp.product_label, sp.description";
// Rücknahmen in Array laden für schnellen Zugriff
$returnedProducts = array();
$unmatchedReturns = array(); // Rücknahmen ohne passendes Produkt
$resqlReturned = $db->query($sqlReturned);
if ($resqlReturned) {
while ($objRet = $db->fetch_object($resqlReturned)) {
$key = '';
if ($objRet->fk_product > 0) {
$key = 'prod_'.$objRet->fk_product;
} else {
// Bei Freitext: Über description matchen
$key = 'desc_'.md5(trim(strip_tags($objRet->description)));
}
$returnedProducts[$key] = (float)$objRet->qty_returned;
}
}
if ($mehraufwandCount > 0) {
$resqlMehraufwand = $db->query($sqlMehraufwand);
$mehraufwandRows = array();
$mehraufwandVisibleCount = 0;
// Erst alle Zeilen laden und filtern
if ($resqlMehraufwand) {
while ($objMa = $db->fetch_object($resqlMehraufwand)) {
// Berechne Status für Filter
$qtyAdditionalPre = (float)$objMa->qty_additional;
$qtyAddedPre = (float)$objMa->qty_added;
$returnKeyPre = ($objMa->fk_product > 0) ? 'prod_'.$objMa->fk_product : 'desc_'.md5(trim(strip_tags($objMa->description)));
$qtyReturnedPre = isset($returnedProducts[$returnKeyPre]) ? $returnedProducts[$returnKeyPre] : 0;
$qtyTargetPre = (($qtyAdditionalPre > 0) ? $qtyAdditionalPre : $qtyAddedPre) - $qtyReturnedPre;
$qtyDonePre = $qtyAddedPre - $qtyReturnedPre;
$qtyRemainingPre = $qtyTargetPre - $qtyDonePre;
$isDonePre = ($qtyRemainingPre <= 0) || ($qtyTargetPre <= 0 && $qtyReturnedPre > 0);
// Filter anwenden
if ($filter == 'open' && $isDonePre) {
continue;
}
if ($filter == 'done' && !$isDonePre) {
continue;
}
$mehraufwandRows[] = $objMa;
$mehraufwandVisibleCount++;
}
}
if ($mehraufwandVisibleCount > 0) {
// Mehraufwand-Header - einklappbar, dezente Farbgebung
print '<tr class="liste_titre section-header" data-section="section_mehraufwand" onclick="toggleSection(\'section_mehraufwand\')" style="cursor: pointer;">';
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 style="margin-right: 10px; color: #e67e22;">⚠</span>';
print $langs->trans("Mehraufwand");
print ' <span style="opacity: 0.6; font-weight: normal;">('.$mehraufwandCount.' '.$langs->trans("Products").')</span>';
print ' <span style="opacity: 0.6; font-weight: normal;">('.$mehraufwandVisibleCount.' '.$langs->trans("Products").')</span>';
print '</td>';
print '</tr>';
while ($objMa = $db->fetch_object($resqlMehraufwand)) {
// Verfolge welche Rücknahmen zugeordnet wurden
$matchedReturnKeys = array();
foreach ($mehraufwandRows as $objMa) {
// Logik für Beauftragt und Verbaut:
// - qty_additional = Menge aus Mehraufwand-Zeilen (origin='additional')
// - qty_added = Menge aus Produktliste ohne Auftragszeile (origin='added', kein fk_commandedet)
@ -1999,10 +2058,25 @@ if ($tab == 'products') {
$qtyAdditional = (float)$objMa->qty_additional;
$qtyAdded = (float)$objMa->qty_added;
// Rücknahmen für dieses Produkt finden
$qtyReturned = 0;
$returnKey = '';
if ($objMa->fk_product > 0) {
$returnKey = 'prod_'.$objMa->fk_product;
} else {
$returnKey = 'desc_'.md5(trim(strip_tags($objMa->description)));
}
if (isset($returnedProducts[$returnKey])) {
$qtyReturned = $returnedProducts[$returnKey];
$matchedReturnKeys[$returnKey] = true;
}
// Beauftragt: Wenn Mehraufwand existiert, dessen Menge nehmen, sonst die hinzugefügte Menge
$qtyTarget = ($qtyAdditional > 0) ? $qtyAdditional : $qtyAdded;
// Verbaut: Immer die hinzugefügte Menge (was tatsächlich installiert wurde)
$qtyDone = $qtyAdded;
// MINUS Rücknahmen (was zurückgenommen wurde soll nicht wieder verbaut werden)
$qtyTargetBase = ($qtyAdditional > 0) ? $qtyAdditional : $qtyAdded;
$qtyTarget = $qtyTargetBase - $qtyReturned;
// Verbaut: Hinzugefügte Menge MINUS Rücknahmen
$qtyDone = $qtyAdded - $qtyReturned;
$qtyRemaining = $qtyTarget - $qtyDone; // Verbleibend
@ -2023,7 +2097,8 @@ if ($tab == 'products') {
$productIds = $objIds->product_ids;
}
$isDone = ($qtyRemaining <= 0 && $qtyDone > 0);
// Status berechnen: Erledigt wenn alles verbaut ODER alles zurückgenommen
$isDone = ($qtyRemaining <= 0) || ($qtyTarget <= 0 && $qtyReturned > 0);
print '<tr class="oddeven section-product section_mehraufwand">';
@ -2052,11 +2127,21 @@ if ($tab == 'products') {
print ' <span class="badge badge-warning">'.$langs->trans("Mehraufwand").'</span>';
print '</td>';
// Menge beauftragt (Zielmenge - was verbaut werden soll)
print '<td class="right">'.formatQty($qtyTarget).'</td>';
// Menge beauftragt (Zielmenge - was verbaut werden soll, MINUS Rücknahmen)
print '<td class="right">';
print formatQty($qtyTarget);
if ($qtyReturned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("QtyReturned").'">-'.formatQty($qtyReturned).'</span>';
}
print '</td>';
// Menge tatsächlich verbaut
print '<td class="right">'.formatQty($qtyDone).'</td>';
// Menge tatsächlich verbaut (mit Rücknahme-Hinweis wenn vorhanden)
print '<td class="right">';
print formatQty($qtyDone);
if ($qtyReturned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("QtyReturned").'">-'.formatQty($qtyReturned).'</span>';
}
print '</td>';
// Verbleibend (Zielmenge - Verbaut)
print '<td class="right">';
@ -2072,9 +2157,12 @@ if ($tab == 'products') {
// Status
print '<td>';
if ($qtyDone > 0 && $qtyRemaining > 0) {
if ($qtyTarget <= 0 && $qtyReturned > 0) {
// Alles zurückgenommen - Erledigt (mit Rücknahme-Hinweis)
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($qtyDone > 0 && $qtyRemaining > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';
} elseif ($qtyDone == 0) {
} elseif ($qtyDone == 0 && $qtyTarget > 0) {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
} else {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
@ -2084,6 +2172,44 @@ if ($tab == 'products') {
print '</tr>';
}
// Nicht zugeordnete Rücknahmen anzeigen (Warnung)
foreach ($returnedProducts as $retKey => $retQty) {
if (!isset($matchedReturnKeys[$retKey])) {
// Diese Rücknahme hat kein passendes Mehraufwand-Produkt gefunden
// Lade die Details aus der DB
$sqlUnmatched = "SELECT sp.product_label, sp.description, GROUP_CONCAT(DISTINCT s.ref SEPARATOR ', ') as stz_refs";
$sqlUnmatched .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlUnmatched .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlUnmatched .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlUnmatched .= " AND sp.origin = 'returned'";
if (strpos($retKey, 'prod_') === 0) {
$sqlUnmatched .= " AND sp.fk_product = ".((int)substr($retKey, 5));
} else {
// Freitext - über MD5 der description bereits gefiltert, hole alle
$sqlUnmatched .= " AND sp.fk_product IS NULL";
}
$sqlUnmatched .= " GROUP BY sp.product_label, sp.description";
$resqlUnmatched = $db->query($sqlUnmatched);
if ($resqlUnmatched && ($objUnm = $db->fetch_object($resqlUnmatched))) {
print '<tr class="oddeven section-product section_mehraufwand" style="background-color: #fff3cd;">';
print '<td class="center"></td>';
print '<td>';
print '<span class="fas fa-exclamation-triangle" style="color: #856404; margin-right: 5px;"></span>';
$desc = !empty($objUnm->product_label) ? $objUnm->product_label : strip_tags($objUnm->description);
print '<span class="opacitymedium">'.$desc.'</span>';
print ' <span class="badge badge-danger">'.$langs->trans("Ruecknahme").'</span>';
print '</td>';
print '<td class="right">-</td>';
print '<td class="right"><span style="color: #dc3545;">-'.formatQty($retQty).'</span></td>';
print '<td class="right"><span class="badge badge-danger" title="'.$langs->trans("RuecknahmeOhneProdukt").'">!</span></td>';
print '<td><span class="badge badge-danger">'.$langs->trans("Error").'</span>';
print ' <small class="opacitymedium">('.$objUnm->stz_refs.')</small></td>';
print '</tr>';
}
}
}
}
// HINWEIS: Entfällt-Produkte werden nicht separat angezeigt
@ -2312,7 +2438,12 @@ if ($tab == 'tracking') {
$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 .= " WHERE sp3.origin = 'omitted' AND s3.fk_commande = ".((int)$order->id);
$sql .= " AND ((sp3.fk_product = cd.fk_product AND cd.fk_product > 0) OR sp3.fk_commandedet = cd.rowid)), 0) as qty_omitted";
$sql .= " AND ((sp3.fk_product = cd.fk_product AND cd.fk_product > 0) OR sp3.fk_commandedet = cd.rowid)), 0) as qty_omitted,";
// Rücknahmen - für Produkte via fk_product, für Freitext via fk_commandedet
$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 .= " WHERE sp4.origin = 'returned' AND s4.fk_commande = ".((int)$order->id);
$sql .= " AND ((sp4.fk_product = cd.fk_product AND cd.fk_product > 0) OR sp4.fk_commandedet = cd.rowid)), 0) as qty_returned";
$sql .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE cd.fk_commande = ".((int)$order->id);
@ -2329,8 +2460,12 @@ if ($tab == 'tracking') {
$detailRowId++;
$qty_additional = (float)$obj->qty_additional;
$qty_omitted = (float)$obj->qty_omitted;
$effective_ordered = $obj->qty_ordered + $qty_additional - $qty_omitted;
$qty_remaining = $effective_ordered - $obj->qty_delivered;
$qty_returned = isset($obj->qty_returned) ? (float)$obj->qty_returned : 0;
// Effektiv bestellt = Original + Mehraufwand - Entfällt - Rücknahmen
$effective_ordered = $obj->qty_ordered + $qty_additional - $qty_omitted - $qty_returned;
// Effektiv geliefert = Geliefert - Rücknahmen
$effective_delivered = $obj->qty_delivered - $qty_returned;
$qty_remaining = $effective_ordered - $effective_delivered;
// Details für dieses Produkt
$details = isset($trackingDetails[$obj->rowid]) ? $trackingDetails[$obj->rowid] : array();
@ -2365,7 +2500,7 @@ if ($tab == 'tracking') {
}
print '</td>';
// Bestellt (mit Badges für Mehraufwand/Entfällt)
// Bestellt (mit Badges für Mehraufwand/Entfällt/Rücknahmen)
print '<td class="right">';
print '<strong>'.formatQty($effective_ordered).'</strong>';
if ($qty_additional > 0) {
@ -2374,12 +2509,20 @@ if ($tab == 'tracking') {
if ($qty_omitted > 0) {
print ' <span class="badge" style="background-color: #dc3545; color: #fff; font-size: 0.75em;" title="'.$langs->trans("Entfaellt").'">-'.formatQty($qty_omitted).'</span>';
}
if ($qty_returned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("Ruecknahme").'">-'.formatQty($qty_returned).'</span>';
}
print '</td>';
$total_ordered += $effective_ordered;
// Geliefert/Erfasst
print '<td class="right">'.formatQty($obj->qty_delivered).'</td>';
$total_delivered += $obj->qty_delivered;
// Geliefert/Erfasst (mit Rücknahme-Hinweis)
print '<td class="right">';
print formatQty($effective_delivered);
if ($qty_returned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("Ruecknahme").'">-'.formatQty($qty_returned).'</span>';
}
print '</td>';
$total_delivered += $effective_delivered;
// Verbleibend
print '<td class="right">';
@ -2395,9 +2538,12 @@ if ($tab == 'tracking') {
// Status
print '<td>';
if ($qty_remaining <= 0) {
if ($effective_ordered <= 0 && $qty_returned > 0) {
// Alles zurückgenommen - Erledigt
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($obj->qty_delivered > 0) {
} elseif ($qty_remaining <= 0) {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($effective_delivered > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';
} else {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
@ -2503,7 +2649,7 @@ if ($tab == 'tracking') {
}
// Mehraufwand und zusätzlich verbaute Produkte laden die NICHT im Auftrag sind
// Beauftragt (additional) und verbaut (added ohne fk_commandedet) getrennt summieren
// Beauftragt (additional) und verbaut (added ohne fk_commandedet) summieren
$sqlMehraufwand = "SELECT sp.fk_product, sp.description,";
$sqlMehraufwand .= " p.ref as product_ref, p.label as product_label,";
$sqlMehraufwand .= " SUM(CASE WHEN sp.origin = 'additional' THEN sp.qty_done ELSE 0 END) as qty_additional,";
@ -2519,6 +2665,21 @@ if ($tab == 'tracking') {
$sqlMehraufwand .= " GROUP BY sp.fk_product, sp.description, p.ref, p.label";
$sqlMehraufwand .= " ORDER BY p.ref, sp.description";
// Rücknahmen separat laden für Zuordnung (gleiche Logik wie in Produktliste)
$returnedMehr = array();
$sqlRetMehr = "SELECT sp.fk_product, sp.description, SUM(sp.qty_done) as qty_returned";
$sqlRetMehr .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlRetMehr .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlRetMehr .= " WHERE s.fk_commande = ".((int)$order->id)." AND sp.origin = 'returned'";
$sqlRetMehr .= " GROUP BY sp.fk_product, sp.description";
$resRetMehr = $db->query($sqlRetMehr);
if ($resRetMehr) {
while ($objRetM = $db->fetch_object($resRetMehr)) {
$keyM = ($objRetM->fk_product > 0) ? 'prod_'.$objRetM->fk_product : 'desc_'.md5(trim(strip_tags($objRetM->description)));
$returnedMehr[$keyM] = (float)$objRetM->qty_returned;
}
}
$resqlMehr = $db->query($sqlMehraufwand);
$hasMehraufwandProducts = false;
@ -2533,10 +2694,20 @@ if ($tab == 'tracking') {
$detailRowId++;
$qtyAdditional = (float)$objMehr->qty_additional;
$qtyAdded = (float)$objMehr->qty_added;
// Rücknahmen für dieses Produkt finden
$qtyReturned = 0;
$retKeyM = ($objMehr->fk_product > 0) ? 'prod_'.$objMehr->fk_product : 'desc_'.md5(trim(strip_tags($objMehr->description)));
if (isset($returnedMehr[$retKeyM])) {
$qtyReturned = $returnedMehr[$retKeyM];
}
// 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;
// MINUS Rücknahmen (was zurückgenommen wurde soll nicht wieder verbaut werden)
$qty_ordered_base = ($qtyAdditional > 0) ? $qtyAdditional : $qtyAdded;
$qty_ordered_mehr = $qty_ordered_base - $qtyReturned;
// Verbaut: Tatsächlich verbaute Menge MINUS Rücknahmen
$qty_delivered_mehr = $qtyAdded - $qtyReturned;
$qty_remaining_mehr = $qty_ordered_mehr - $qty_delivered_mehr;
// Details für Mehraufwand laden
@ -2579,12 +2750,22 @@ if ($tab == 'tracking') {
}
print '</td>';
// Bestellt (Mehraufwand-Menge)
print '<td class="right"><strong>'.formatQty($qty_ordered_mehr).'</strong></td>';
// Bestellt (Mehraufwand-Menge, MINUS Rücknahmen)
print '<td class="right">';
print '<strong>'.formatQty($qty_ordered_mehr).'</strong>';
if ($qtyReturned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("QtyReturned").'">-'.formatQty($qtyReturned).'</span>';
}
print '</td>';
$total_ordered += $qty_ordered_mehr;
// Geliefert
print '<td class="right">'.formatQty($qty_delivered_mehr).'</td>';
// Geliefert (mit Rücknahme-Hinweis wenn vorhanden)
print '<td class="right">';
print formatQty($qty_delivered_mehr);
if ($qtyReturned > 0) {
print ' <span class="badge badge-danger" title="'.$langs->trans("QtyReturned").'">-'.formatQty($qtyReturned).'</span>';
}
print '</td>';
$total_delivered += $qty_delivered_mehr;
// Verbleibend
@ -2601,7 +2782,10 @@ if ($tab == 'tracking') {
// Status
print '<td>';
if ($qty_remaining_mehr <= 0) {
if ($qty_ordered_mehr <= 0 && $qtyReturned > 0) {
// Alles zurückgenommen - Erledigt
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($qty_remaining_mehr <= 0) {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($qty_delivered_mehr > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';