dolibarr.stundenzettel/stundenzettel_commande.php
data dabcdbde13 Version 2.1.0: Rechnungsübernahme erweitert
- Neuer Button "Ohne Produktgruppen" für flache Übernahme ohne Sections/Zwischensummen
- Mehraufwand-Produktgruppe über Modul-Einstellung konfigurierbar
- Standard-Bankkonto wird automatisch aus Einstellung gesetzt
- "Ihr Zeichen" (ref_client) vom Auftrag übernommen
- Extrafelder Angebotsnummer/Auftragsnummer vom Auftrag kopiert
- Verwaiste Zwischensummen werden nach Rechnungserstellung bereinigt
- Zwei neue Modul-Einstellungen: Bankkonto und Mehraufwand-Sektion

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 18:39:37 +01:00

3206 lines
160 KiB
PHP
Executable file

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* Stundenzettel - Auftrags-Integration
* Zeigt Produktliste aus Auftrag mit Übernahme-Möglichkeit
*/
// Load Dolibarr environment
$res = 0;
if (!$res && file_exists("../main.inc.php")) $res = @include "../main.inc.php";
if (!$res && file_exists("../../main.inc.php")) $res = @include "../../main.inc.php";
if (!$res && file_exists("../../../main.inc.php")) $res = @include "../../../main.inc.php";
if (!$res) die("Include of main fails");
require_once DOL_DOCUMENT_ROOT.'/commande/class/commande.class.php';
require_once DOL_DOCUMENT_ROOT.'/core/lib/order.lib.php';
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
dol_include_once('/stundenzettel/class/stundenzettel.class.php');
dol_include_once('/stundenzettel/lib/stundenzettel.lib.php');
// Load translation files
$langs->loadLangs(array("stundenzettel@stundenzettel", "orders", "products"));
/**
* Formatiert Mengen: Ganzzahlen ohne Dezimalstellen, sonst max. 2 Stellen
* @param float $qty Menge
* @return string Formatierte Menge
*/
function formatQty($qty) {
$qty = (float)$qty;
if ($qty == floor($qty)) {
return number_format($qty, 0, ',', '.');
}
// Runde auf 2 Stellen und entferne trailing zeros
$formatted = rtrim(rtrim(number_format($qty, 2, ',', '.'), '0'), ',');
return $formatted;
}
// Get parameters
$id = GETPOST('id', 'int');
$action = GETPOST('action', 'aZ09');
$tab = GETPOST('tab', 'aZ09') ?: 'products';
$stundenzettel_id = GETPOST('stundenzettel_id', 'int');
$defaultFilter = getDolGlobalString('STUNDENZETTEL_DEFAULT_FILTER', 'open');
$filter = GETPOST('filter', 'alpha') ?: $defaultFilter; // Filter: all, open, done
// Security check
if (!$user->hasRight('stundenzettel', 'read')) {
accessforbidden();
}
// Load order
$order = new Commande($db);
if ($order->fetch($id) <= 0) {
dol_print_error($db, 'Order not found');
exit;
}
// Berechtigung: Nur zugewiesener Benutzer oder Admin
$canAccess = ($order->fk_user_author == $user->id || $user->admin || $user->hasRight('commande', 'lire'));
if (!$canAccess) {
accessforbidden('You are not assigned to this order');
}
// Auto-Redirect: Wenn ein aktiver Stundenzettel für heute existiert, dorthin weiterleiten
// Nur bei erstem Aufruf
$noRedirect = GETPOST('noredirect', 'int');
if (!$noRedirect && empty($action)) {
// Suche nach offenem Stundenzettel für diesen Auftrag (heute oder generell offen)
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
$sql .= " WHERE fk_commande = ".((int)$order->id);
$sql .= " AND status = 0"; // Nur Entwürfe
$sql .= " ORDER BY date_stundenzettel DESC, rowid DESC";
$sql .= " LIMIT 1";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
// Weiterleitung zum gefundenen Stundenzettel
header('Location: '.dol_buildpath('/stundenzettel/card.php?id='.$obj->rowid.'&tab=products', 1));
exit;
}
// Kein Stundenzettel vorhanden - bleibe auf dieser Seite um einen zu erstellen
}
/*
* Actions
*/
// Produkte auf Stundenzettel übernehmen
if ($action == 'transfer_products' && $user->hasRight('stundenzettel', 'write')) {
// Prüfe ob Auftrag freigegeben ist
$order->fetch_optionals();
$stzStatus = isset($order->array_options['options_stundenzettel_status']) ? (int)$order->array_options['options_stundenzettel_status'] : 0;
if ($stzStatus >= 1) {
setEventMessages($langs->trans('ErrorStundenzettelReleased'), null, 'errors');
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1');
exit;
}
$selected = GETPOST('selected', 'array');
$selected_mehraufwand = GETPOST('selected_mehraufwand', 'array');
$date_stundenzettel = GETPOST('date_stundenzettel', 'alpha');
$target_stundenzettel_id = GETPOST('stundenzettel_id', 'int'); // Vorausgewählter Stundenzettel
// Wenn nichts ausgewählt: Stundenzettel finden/erstellen und dorthin navigieren
if (empty($selected)) {
$selected = array();
}
if (empty($selected_mehraufwand)) {
$selected_mehraufwand = array();
}
{
// Produkte werden direkt übernommen (falls ausgewählt)
$stundenzettel = new Stundenzettel($db);
$use_stundenzettel_id = 0;
$forceNewStundenzettel = GETPOST('force_new_stundenzettel', 'int');
// Wenn ein Stundenzettel vorgegeben wurde, prüfen ob er von heute ist
if ($target_stundenzettel_id > 0 && !$forceNewStundenzettel) {
if ($stundenzettel->fetch($target_stundenzettel_id) > 0) {
// Prüfe ob der Stundenzettel von heute ist
$stzDateStr = date('Y-m-d', $stundenzettel->date_stundenzettel);
$todayStr = date('Y-m-d');
if ($stzDateStr == $todayStr) {
// Stundenzettel ist von heute - verwenden
$use_stundenzettel_id = $target_stundenzettel_id;
}
// Sonst: neuen für heute erstellen (weiter unten)
}
}
// Wenn kein passender Stundenzettel, nach Datum suchen oder neu erstellen
if ($use_stundenzettel_id <= 0) {
// Bei force_new oder wenn Stundenzettel von anderem Tag: heutiges Datum verwenden
$date = ($forceNewStundenzettel || !$date_stundenzettel) ? dol_now() : $date_stundenzettel;
// Existierenden Stundenzettel für diesen Tag suchen oder neuen erstellen
$sql = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
$sql .= " WHERE fk_commande = ".((int)$order->id);
$sql .= " AND date_stundenzettel = '".$db->idate($date)."'";
$sql .= " AND status = 0"; // Nur Entwürfe
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
$obj = $db->fetch_object($resql);
$use_stundenzettel_id = $obj->rowid;
$stundenzettel->fetch($use_stundenzettel_id);
} else {
// Neuen erstellen
$stundenzettel->fk_commande = $order->id;
$stundenzettel->fk_soc = $order->socid;
$stundenzettel->date_stundenzettel = $date;
$use_stundenzettel_id = $stundenzettel->create($user);
}
}
if ($use_stundenzettel_id > 0) {
// Produkte hinzufügen
foreach ($selected as $line_id) {
// Hole Zeile aus commandedet
$sql = "SELECT cd.rowid, cd.fk_product, cd.qty, cd.description,";
$sql .= " p.ref as product_ref, p.label as product_label";
$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.rowid = ".((int)$line_id);
$resql = $db->query($sql);
if ($resql && ($obj = $db->fetch_object($resql))) {
// Prüfe ob schon auf diesem Stundenzettel
$sql2 = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel_product";
$sql2 .= " WHERE fk_stundenzettel = ".((int)$use_stundenzettel_id);
$sql2 .= " AND fk_commandedet = ".((int)$line_id);
$resql2 = $db->query($sql2);
if ($resql2 && $db->num_rows($resql2) == 0) {
// Noch nicht vorhanden, hinzufügen (immer mit Menge 1)
$stundenzettel->addProduct(
$obj->fk_product,
$obj->rowid,
null,
$obj->qty,
1,
'order',
$obj->description // Beschreibung für Freitext-Produkte
);
}
}
}
// Mehraufwand-Produkte in Produktliste übernehmen (falls ausgewählt)
// Erstellt eine neue Produktzeile (origin='added') im Ziel-Stundenzettel
if (!empty($selected_mehraufwand)) {
foreach ($selected_mehraufwand as $ma_ids) {
// $ma_ids kann mehrere IDs enthalten (kommasepariert)
$ids = explode(',', $ma_ids);
foreach ($ids as $sp_id) {
$sp_id = (int)$sp_id;
if ($sp_id <= 0) continue;
// Produktinfo aus der Mehraufwand-Zeile holen
$sqlMa = "SELECT sp.fk_product, sp.product_ref, sp.product_label, sp.description, sp.qty_done";
$sqlMa .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlMa .= " WHERE sp.rowid = ".((int)$sp_id);
$resqlMa = $db->query($sqlMa);
if ($resqlMa && ($objMa = $db->fetch_object($resqlMa))) {
$qty = 1; // Immer Menge 1 uebernehmen
// Prüfe ob Produkt schon auf diesem Stundenzettel (in Produktliste) existiert
$sqlCheck = "SELECT rowid, qty_done FROM ".MAIN_DB_PREFIX."stundenzettel_product";
$sqlCheck .= " WHERE fk_stundenzettel = ".((int)$use_stundenzettel_id);
$sqlCheck .= " AND origin = 'added'";
if ($objMa->fk_product > 0) {
$sqlCheck .= " AND fk_product = ".((int)$objMa->fk_product);
} else {
$sqlCheck .= " AND fk_product IS NULL AND description = '".$db->escape($objMa->description)."'";
}
$resqlCheck = $db->query($sqlCheck);
if ($resqlCheck && $db->num_rows($resqlCheck) > 0) {
// Existiert bereits - Menge erhöhen
$objExist = $db->fetch_object($resqlCheck);
$newQty = (float)$objExist->qty_done + $qty;
$sqlUpd = "UPDATE ".MAIN_DB_PREFIX."stundenzettel_product";
$sqlUpd .= " SET qty_done = ".$newQty;
$sqlUpd .= " WHERE rowid = ".((int)$objExist->rowid);
$db->query($sqlUpd);
} else {
// Neu als Produktzeile anlegen (origin='added')
$stundenzettel->addProduct(
$objMa->fk_product,
null, // kein fk_commandedet (nicht im Auftrag)
null, // qty_original
0, // qty_ordered
$qty, // qty_done
'added', // origin = in Produktliste hinzugefügt
$objMa->description
);
}
}
}
}
}
if (!empty($selected) || !empty($selected_mehraufwand)) {
setEventMessages($langs->trans('ProductsTransferred'), null, 'mesgs');
}
header('Location: '.dol_buildpath('/stundenzettel/card.php?id='.$use_stundenzettel_id.'&tab=products', 1));
exit;
} else {
setEventMessages($langs->trans('ErrorCreatingStundenzettel'), null, 'errors');
}
}
}
// Notiz abhaken (von stundenzettel_commande aus)
if ($action == 'toggle_note' && $user->hasRight('stundenzettel', 'write')) {
$note_id = GETPOST('note_id', 'int');
$checked = GETPOST('checked', 'int');
$stz_id = GETPOST('stundenzettel_id', 'int');
if ($stz_id > 0) {
$stzObj = new Stundenzettel($db);
if ($stzObj->fetch($stz_id) > 0) {
$result = $stzObj->updateNoteStatus($note_id, $checked);
if ($result > 0) {
setEventMessages($langs->trans('RecordModified'), null, 'mesgs');
}
}
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1'.($stundenzettel_id > 0 ? '&stundenzettel_id='.$stundenzettel_id : ''));
exit;
}
// Stundenzettel für Auftrag freigeben (direkt oder nach Bestätigung)
// Berechtigung: write ist ausreichend (validate ist optional)
$canRelease = $user->hasRight('stundenzettel', 'write') || $user->hasRight('stundenzettel', 'validate') || $user->admin;
$doRelease = false;
if ($action == 'release_stundenzettel' && $canRelease) {
$doRelease = true;
}
if ($action == 'release_stundenzettel_confirmed' && GETPOST('confirm', 'alpha') == 'yes' && $canRelease) {
$doRelease = true;
}
// Auch confirm_release akzeptieren (Fallback)
if ($action == 'confirm_release' && GETPOST('confirm', 'alpha') == 'yes' && $canRelease) {
$doRelease = true;
}
if ($doRelease) {
// Direkt in Datenbank updaten (zuverlässiger als insertExtraFields)
$sql = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields SET stundenzettel_status = 1 WHERE fk_object = ".((int)$order->id);
$resql = $db->query($sql);
if ($resql) {
// Falls noch kein Eintrag existiert, einen erstellen
if ($db->affected_rows($resql) == 0) {
$sql2 = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_status) VALUES (".((int)$order->id).", 1)";
$db->query($sql2);
}
// Netto-Wert aller Stundenzettel berechnen und speichern
updateOrderNettoSTZ($db, $order->id);
setEventMessages($langs->trans('StundenzettelReleased'), null, 'mesgs');
} else {
setEventMessages($db->lasterror(), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&filter='.$filter);
exit;
}
// Stundenzettel für Auftrag wiedereröffnen
$canReopen = $user->hasRight('stundenzettel', 'write') || $user->hasRight('stundenzettel', 'validate') || $user->admin;
if ($action == 'reopen_stundenzettel' && $canReopen) {
// Direkt in Datenbank updaten (zuverlässiger als insertExtraFields)
$sql = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields SET stundenzettel_status = 0 WHERE fk_object = ".((int)$order->id);
$resql = $db->query($sql);
if ($resql) {
// Falls noch kein Eintrag existiert, einen erstellen
if ($db->affected_rows($resql) == 0) {
$sql2 = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_status) VALUES (".((int)$order->id).", 0)";
$db->query($sql2);
}
// Netto-Wert neu berechnen (wird 0 wenn keine freigegebenen Stundenzettel mehr)
updateOrderNettoSTZ($db, $order->id);
setEventMessages($langs->trans('StundenzettelReopened'), null, 'mesgs');
} else {
setEventMessages($db->lasterror(), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&filter='.$filter);
exit;
}
// Stundenzettel komplett zurücksetzen (von Status 2 auf 0)
$canReset = $user->hasRight('stundenzettel', 'write') || $user->admin;
if ($action == 'reset_stundenzettel' && $canReset) {
// Direkt in Datenbank updaten (zuverlässiger als insertExtraFields)
$sql = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields SET stundenzettel_status = 0 WHERE fk_object = ".((int)$order->id);
$resql = $db->query($sql);
if ($resql) {
// Falls noch kein Eintrag existiert, einen erstellen
if ($db->affected_rows($resql) == 0) {
$sql2 = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_status) VALUES (".((int)$order->id).", 0)";
$db->query($sql2);
}
setEventMessages($langs->trans('StundenzettelReset'), null, 'mesgs');
} else {
setEventMessages($db->lasterror(), null, 'errors');
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&filter='.$filter);
exit;
}
// In Rechnung übernehmen (mit oder ohne Produktgruppen)
if (in_array($action, array('confirm_transfer_invoice', 'confirm_transfer_invoice_flat')) && $user->hasRight('facture', 'creer')) {
require_once DOL_DOCUMENT_ROOT.'/compta/facture/class/facture.class.php';
// Steuerung: Mit oder ohne Produktgruppen
$withSections = ($action == 'confirm_transfer_invoice');
$db->begin();
$error = 0;
// Neue Rechnung erstellen
$facture = new Facture($db);
$facture->socid = $order->socid;
$facture->type = Facture::TYPE_STANDARD;
$facture->date = dol_now();
$facture->fk_project = $order->fk_project;
$facture->cond_reglement_id = $order->cond_reglement_id;
$facture->mode_reglement_id = $order->mode_reglement_id;
$facture->note_private = $langs->trans('CreatedFromStundenzettel').' - '.$order->ref;
$facture->linked_objects['commande'] = $order->id;
// "Ihr Zeichen" vom Auftrag übernehmen
if (!empty($order->ref_client)) {
$facture->ref_customer = $order->ref_client;
}
// Bankkonto aus Modul-Einstellung
$bankAccount = getDolGlobalString('STUNDENZETTEL_INVOICE_BANK_ACCOUNT', 0);
if ($bankAccount > 0) {
$facture->fk_account = (int)$bankAccount;
}
$facture_id = $facture->create($user);
if ($facture_id > 0) {
// Extrafelder vom Auftrag übernehmen (Angebotsnummer, Auftragsnummer)
$order->fetch_optionals();
$facture->fetch_optionals();
$extrafieldsUpdated = false;
if (!empty($order->array_options['options_angebotsnummer'])) {
$facture->array_options['options_angebotsnummer'] = $order->array_options['options_angebotsnummer'];
$extrafieldsUpdated = true;
}
if (!empty($order->array_options['options_auftragsnummer'])) {
$facture->array_options['options_auftragsnummer'] = $order->array_options['options_auftragsnummer'];
$extrafieldsUpdated = true;
}
if ($extrafieldsUpdated) {
$facture->insertExtraFields();
}
// Einkaufspreise aller Produkte vorladen
// Priorität: 1. cost_price (manuell gesetzt), 2. bester Lieferanten-Stückpreis, 3. PMP
$buyPrices = array(); // fk_product => buy_price
$sqlBuyPrices = "SELECT p.rowid, p.cost_price, p.pmp,";
// Bester Lieferanten-Stückpreis (günstigster unitprice)
$sqlBuyPrices .= " (SELECT MIN(pfp.unitprice) FROM ".MAIN_DB_PREFIX."product_fournisseur_price pfp";
$sqlBuyPrices .= " WHERE pfp.fk_product = p.rowid AND pfp.unitprice > 0) as best_supplier_unitprice";
$sqlBuyPrices .= " FROM ".MAIN_DB_PREFIX."product p";
$sqlBuyPrices .= " WHERE p.rowid IN (";
$sqlBuyPrices .= " SELECT DISTINCT cd.fk_product FROM ".MAIN_DB_PREFIX."commandedet cd WHERE cd.fk_commande = ".((int)$order->id)." AND cd.fk_product IS NOT NULL";
$sqlBuyPrices .= " UNION";
$sqlBuyPrices .= " SELECT DISTINCT sp.fk_product FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlBuyPrices .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlBuyPrices .= " WHERE s.fk_commande = ".((int)$order->id)." AND sp.fk_product IS NOT NULL";
$sqlBuyPrices .= " )";
$resqlBuyPrices = $db->query($sqlBuyPrices);
if ($resqlBuyPrices) {
while ($objBp = $db->fetch_object($resqlBuyPrices)) {
// Priorität: cost_price > Lieferanten-Stückpreis > PMP
if (!empty($objBp->cost_price) && $objBp->cost_price > 0) {
$buyPrices[(int)$objBp->rowid] = (float)$objBp->cost_price;
} elseif (!empty($objBp->best_supplier_unitprice) && $objBp->best_supplier_unitprice > 0) {
$buyPrices[(int)$objBp->rowid] = (float)$objBp->best_supplier_unitprice;
} elseif (!empty($objBp->pmp) && $objBp->pmp > 0) {
$buyPrices[(int)$objBp->rowid] = (float)$objBp->pmp;
} else {
$buyPrices[(int)$objBp->rowid] = 0;
}
}
}
// Sammle alle Produkt-Mengen aus Stundenzetteln (gruppiert nach fk_commandedet)
$productQtys = array();
$sqlQty = "SELECT sp.fk_commandedet, SUM(sp.qty_done) as total_qty";
$sqlQty .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlQty .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlQty .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlQty .= " AND sp.origin IN ('order', 'added')";
$sqlQty .= " AND sp.fk_commandedet IS NOT NULL";
$sqlQty .= " GROUP BY sp.fk_commandedet";
$resqlQty = $db->query($sqlQty);
if ($resqlQty) {
while ($objQty = $db->fetch_object($resqlQty)) {
$productQtys[$objQty->fk_commandedet] = floatval($objQty->total_qty);
}
}
// Rücknahmen laden für Abzug bei Mehraufwand und Auftragspositionen
// (wird vor beiden Bereichen geladen, da beide Rücknahmen berücksichtigen müssen)
$returnedQtys = array(); // key => qty (für Mehraufwand: prod_ID oder desc_MD5)
$returnedByCommandedet = array(); // fk_commandedet => qty (für Auftragspositionen)
$sqlReturned = "SELECT sp.fk_product, sp.fk_commandedet, sp.product_label, sp.description, SUM(sp.qty_done) as qty_returned";
$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.fk_commandedet, sp.product_label, sp.description";
$resqlReturned = $db->query($sqlReturned);
if ($resqlReturned) {
while ($objRet = $db->fetch_object($resqlReturned)) {
// Für Mehraufwand-Matching (fk_product oder description)
if ($objRet->fk_product > 0) {
$retKey = 'prod_'.$objRet->fk_product;
} else {
$retKey = 'desc_'.md5(trim(strip_tags($objRet->description)));
}
$returnedQtys[$retKey] = (isset($returnedQtys[$retKey]) ? $returnedQtys[$retKey] : 0) + (float)$objRet->qty_returned;
// Für Auftragspositionen-Matching (fk_commandedet)
if (!empty($objRet->fk_commandedet)) {
$returnedByCommandedet[$objRet->fk_commandedet] = (isset($returnedByCommandedet[$objRet->fk_commandedet]) ? $returnedByCommandedet[$objRet->fk_commandedet] : 0) + (float)$objRet->qty_returned;
}
}
}
// Rücknahmen von Auftragspositionen abziehen
foreach ($returnedByCommandedet as $cmdDetId => $retQty) {
if (isset($productQtys[$cmdDetId])) {
$productQtys[$cmdDetId] -= $retQty;
if ($productQtys[$cmdDetId] <= 0) {
unset($productQtys[$cmdDetId]);
}
}
}
// Sammle Mehraufwand und zusätzlich verbaute Produkte
// (origin='additional' = beauftragt, origin='added' ohne fk_commandedet = verbaut ohne Auftragszeile)
$additionalProducts = array();
$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 .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlAdd .= " WHERE s.fk_commande = ".((int)$order->id);
$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";
$resqlAdd = $db->query($sqlAdd);
if ($resqlAdd) {
while ($objAdd = $db->fetch_object($resqlAdd)) {
// Für die Rechnung zählt die verbaute Menge (added), nicht nur beauftragte (additional)
$invoiceQty = floatval($objAdd->qty_added) > 0 ? floatval($objAdd->qty_added) : floatval($objAdd->qty_additional);
// Rücknahmen abziehen
if ($objAdd->fk_product > 0) {
$retKey = 'prod_'.$objAdd->fk_product;
} else {
$retKey = 'desc_'.md5(trim(strip_tags($objAdd->description)));
}
if (isset($returnedQtys[$retKey])) {
$invoiceQty -= $returnedQtys[$retKey];
}
$objAdd->invoice_qty = $invoiceQty;
if ($objAdd->invoice_qty > 0) {
$additionalProducts[] = $objAdd;
}
}
}
// Lade Section-Hierarchie aus llx_facture_lines_manager
$sqlManager = "SELECT m.rowid, m.line_type, m.fk_commandedet, m.title, m.parent_section, m.line_order, m.show_subtotal,";
$sqlManager .= " cd.fk_product, cd.qty as qty_ordered, cd.subprice, cd.tva_tx, cd.remise_percent,";
$sqlManager .= " cd.description, cd.product_type, cd.special_code, cd.fk_unit,";
$sqlManager .= " p.ref as product_ref, p.label as product_label";
$sqlManager .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager m";
$sqlManager .= " LEFT JOIN ".MAIN_DB_PREFIX."commandedet cd ON cd.rowid = m.fk_commandedet";
$sqlManager .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = cd.fk_product";
$sqlManager .= " WHERE m.fk_commande = ".((int)$order->id);
$sqlManager .= " AND m.document_type = 'order'";
$sqlManager .= " ORDER BY m.line_order";
$resqlManager = $db->query($sqlManager);
if ($resqlManager) {
// Sammle alle Zeilen nach Parent-Section gruppiert
$sections = array(); // section_id => section data
$sectionProducts = array(); // section_id => array of products
$orphanProducts = array(); // Products without section
while ($obj = $db->fetch_object($resqlManager)) {
if ($obj->line_type == 'section') {
$sections[$obj->rowid] = $obj;
if (!isset($sectionProducts[$obj->rowid])) {
$sectionProducts[$obj->rowid] = array();
}
} elseif ($obj->line_type == 'product') {
$parentId = $obj->parent_section ? $obj->parent_section : 0;
if ($parentId > 0) {
if (!isset($sectionProducts[$parentId])) {
$sectionProducts[$parentId] = array();
}
$sectionProducts[$parentId][] = $obj;
} else {
$orphanProducts[] = $obj;
}
}
// subtotal wird automatisch nach Section hinzugefügt wenn show_subtotal=1
}
$rang = 0;
$invoiceManagerLines = array(); // Für llx_facture_lines_manager der Rechnung
// Verarbeite jede Section
foreach ($sections as $sectionId => $section) {
$products = isset($sectionProducts[$sectionId]) ? $sectionProducts[$sectionId] : array();
$sectionHasInvoicedProducts = false;
$sectionSubtotal = 0;
// Prüfe ob Section Produkte mit qty > 0 hat
foreach ($products as $prod) {
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$sectionHasInvoicedProducts = true;
break;
}
}
// Nur Sections mit tatsächlich verwendeten Produkten hinzufügen
if ($sectionHasInvoicedProducts) {
$currentSectionDetId = 0;
// Section-Titel hinzufügen (nur bei Übernahme mit Produktgruppen)
if ($withSections) {
$result = $facture->addline(
$section->title,
0, // subprice
0, // qty
0, // tva_tx
0, 0, // localtax
0, // fk_product
0, // remise_percent
'', '', // dates
0, 0, '', 'HT', 0,
9, // product_type (9 = Title)
$rang++,
100, // special_code = 100 für Section
0, '', 0, 0
);
if ($result > 0) {
$invoiceManagerLines[] = array(
'type' => 'section',
'fk_facturedet' => $result,
'title' => $section->title,
'parent' => null
);
$currentSectionDetId = $result;
}
}
// Produkte der Section hinzufügen
foreach ($products as $prod) {
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$prodBuyPrice = isset($buyPrices[(int)$prod->fk_product]) ? $buyPrices[(int)$prod->fk_product] : 0;
$result = $facture->addline(
$prod->description, // 1: desc
$prod->subprice, // 2: pu_ht
$qtyToInvoice, // 3: qty
$prod->tva_tx, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
$prod->fk_product, // 7: fk_product
$prod->remise_percent, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
$prod->product_type, // 16: type
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
$prodBuyPrice, // 23: pa_ht (Einkaufspreis)
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
$prod->fk_unit // 28: fk_unit
);
if ($result > 0) {
if ($withSections && $currentSectionDetId > 0) {
$invoiceManagerLines[] = array(
'type' => 'product',
'fk_facturedet' => $result,
'title' => null,
'parent' => $currentSectionDetId
);
}
$sectionSubtotal += $prod->subprice * $qtyToInvoice;
} elseif ($result < 0) {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
}
}
}
// Zwischensumme hinzufügen wenn Section show_subtotal hat (nur mit Produktgruppen)
if ($withSections && $section->show_subtotal && $sectionSubtotal > 0) {
$subtotalLabel = 'Zwischensumme: '.$section->title;
$result = $facture->addline(
$subtotalLabel,
0, // wird automatisch berechnet
1, // qty
0, // tva_tx
0, 0, // localtax
0, // fk_product
0, // remise_percent
'', '', // dates
0, 0, '', 'HT', 0,
9, // product_type
$rang++,
102, // special_code = 102 für Subtotal
0, '', 0, 0
);
if ($result > 0) {
$invoiceManagerLines[] = array(
'type' => 'subtotal',
'fk_facturedet' => $result,
'title' => $subtotalLabel,
'parent' => $currentSectionDetId
);
}
}
}
}
// Prüfe ob im Auftrag überhaupt Sections vorhanden sind
$orderHasSections = (count($sections) > 0);
// Orphan-Produkte (ohne Section) hinzufügen
// Nur als eigene Section "Sonstige Produkte" wenn im Auftrag Sections vorhanden sind
$hasOrphansWithQty = false;
foreach ($orphanProducts as $prod) {
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$hasOrphansWithQty = true;
break;
}
}
if ($hasOrphansWithQty) {
$sonstigeSectionResult = 0;
// Nur Section erstellen wenn mit Produktgruppen und im Auftrag Sections vorhanden
if ($withSections && $orderHasSections) {
$sonstigeSectionResult = $facture->addline(
$langs->trans('OtherProducts'),
0, 0, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, // product_type für Titel
$rang++,
100 // special_code = 100 für Section
);
if ($sonstigeSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'section',
'fk_facturedet' => $sonstigeSectionResult,
'title' => $langs->trans('OtherProducts'),
'parent' => null
);
}
}
foreach ($orphanProducts as $prod) {
$qtyToInvoice = isset($productQtys[$prod->fk_commandedet]) ? $productQtys[$prod->fk_commandedet] : 0;
if ($qtyToInvoice > 0) {
$prodBuyPrice = isset($buyPrices[(int)$prod->fk_product]) ? $buyPrices[(int)$prod->fk_product] : 0;
$result = $facture->addline(
$prod->description, // 1: desc
$prod->subprice, // 2: pu_ht
$qtyToInvoice, // 3: qty
$prod->tva_tx, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
$prod->fk_product, // 7: fk_product
$prod->remise_percent, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
$prod->product_type, // 16: type
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
$prodBuyPrice, // 23: pa_ht (Einkaufspreis)
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
$prod->fk_unit // 28: fk_unit
);
if ($result > 0 && $withSections && $orderHasSections && $sonstigeSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'product',
'fk_facturedet' => $result,
'title' => null,
'parent' => $sonstigeSectionResult
);
} elseif ($result < 0) {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
}
}
}
// Zwischensumme für Sonstige Produkte (nur mit Produktgruppen)
if ($withSections && $sonstigeSectionResult > 0) {
$subtotalLabel = 'Zwischensumme: '.$langs->trans('OtherProducts');
$subtotalResult = $facture->addline(
$subtotalLabel,
0, 1, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, $rang++, 102, 0, '', 0, 0
);
if ($subtotalResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'subtotal',
'fk_facturedet' => $subtotalResult,
'title' => $subtotalLabel,
'parent' => $sonstigeSectionResult
);
}
}
}
// Mehraufwand hinzufügen (am Ende)
// Section nur wenn: mit Produktgruppen UND Sections im Auftrag UND Einstellung aktiv
$mehraufwandAsSection = getDolGlobalString('STUNDENZETTEL_INVOICE_MEHRAUFWAND_SECTION', '1') == '1';
if (count($additionalProducts) > 0) {
$mehraufwandSectionResult = 0;
// Section nur erstellen wenn mit Produktgruppen, Sections vorhanden und Einstellung aktiv
if ($withSections && $orderHasSections && $mehraufwandAsSection) {
$mehraufwandSectionResult = $facture->addline(
$langs->trans('Mehraufwand'),
0, 0, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, // product_type für Titel
$rang++,
100 // special_code = 100 für Section
);
if ($mehraufwandSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'section',
'fk_facturedet' => $mehraufwandSectionResult,
'title' => $langs->trans('Mehraufwand'),
'parent' => null
);
}
}
foreach ($additionalProducts as $addProd) {
$productResult = 0;
if ($addProd->fk_product > 0) {
// Katalog-Produkt
$product = new Product($db);
$product->fetch($addProd->fk_product);
// Kundenspezifischen Preis holen (falls vorhanden)
$customerPriceInfo = getCustomerPrice($db, $addProd->fk_product, $order->socid, $product);
$usePrice = $customerPriceInfo['price'];
$useTvaTx = $customerPriceInfo['tva_tx'];
$addBuyPrice = isset($buyPrices[(int)$addProd->fk_product]) ? $buyPrices[(int)$addProd->fk_product] : 0;
$productResult = $facture->addline(
$product->label, // 1: desc
$usePrice, // 2: pu_ht - kundenspezifischer Preis
$addProd->invoice_qty, // 3: qty (verbaute Menge)
$useTvaTx, // 4: txtva - kundenspezifischer MwSt-Satz
0, // 5: txlocaltax1
0, // 6: txlocaltax2
$addProd->fk_product, // 7: fk_product
0, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
$product->type, // 16: type
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
$addBuyPrice, // 23: pa_ht (Einkaufspreis)
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
$product->fk_unit // 28: fk_unit
);
} else {
// Freitext
$productResult = $facture->addline(
$addProd->description ? $addProd->description : $addProd->product_label,
0, // 2: pu_ht
$addProd->invoice_qty, // 3: qty (verbaute Menge)
0, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
0, // 7: fk_product
0, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
0, // 16: type
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
0, // 23: pa_ht
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
0 // 28: fk_unit
);
}
if ($productResult > 0 && $withSections && $orderHasSections && $mehraufwandAsSection && $mehraufwandSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'product',
'fk_facturedet' => $productResult,
'title' => null,
'parent' => $mehraufwandSectionResult
);
} elseif ($productResult < 0) {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
}
}
// Zwischensumme für Mehraufwand (nur mit Produktgruppen und Einstellung aktiv)
if ($withSections && $mehraufwandAsSection && $mehraufwandSectionResult > 0) {
$subtotalLabel = 'Zwischensumme: '.$langs->trans('Mehraufwand');
$subtotalResult = $facture->addline(
$subtotalLabel,
0, 1, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, $rang++, 102, 0, '', 0, 0
);
if ($subtotalResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'subtotal',
'fk_facturedet' => $subtotalResult,
'title' => $subtotalLabel,
'parent' => $mehraufwandSectionResult
);
}
}
}
// ============================================
// ARBEITSZEITEN aus Stundenzetteln hinzufügen
// 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)
$societe = new Societe($db);
$societe->fetch($order->socid);
$societe->fetch_optionals();
$defaultServiceId = isset($societe->array_options['options_stundenzettel_default_service']) ? (int)$societe->array_options['options_stundenzettel_default_service'] : 0;
// Stundenübernahme-Modus aus Auftrags-Extrafeld lesen
$hoursMode = isset($order->array_options['options_stundenzettel_hours_mode']) ? $order->array_options['options_stundenzettel_hours_mode'] : 'grouped';
if (empty($hoursMode)) $hoursMode = 'grouped';
if ($hoursMode == 'per_stz') {
// === PRO STUNDENZETTEL: Jeder STZ bekommt eigene Zeilen ===
$sqlHours = "SELECT ";
$sqlHours .= " s.rowid as stz_id, s.ref as stz_ref, s.date_stundenzettel,";
$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 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 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);
$hasWorkHours = ($resqlHours && $db->num_rows($resqlHours) > 0);
if ($hasWorkHours) {
// Arbeitszeit-Section erstellen (nur wenn Sections im Auftrag)
$arbeitszeitSectionResult = 0;
if ($withSections && $orderHasSections) {
$arbeitszeitSectionResult = $facture->addline(
$langs->trans('Leistungen'),
0, 0, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, // product_type für Titel
$rang++,
100 // special_code = 100 für Section
);
if ($arbeitszeitSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'section',
'fk_facturedet' => $arbeitszeitSectionResult,
'title' => $langs->trans('Leistungen'),
'parent' => null
);
}
}
// Arbeitszeiten hinzufügen
$arbeitszeitSubtotal = 0;
while ($objHours = $db->fetch_object($resqlHours)) {
$productId = (int)$objHours->product_id;
$hoursWorked = $objHours->total_minutes / 60;
// Produkt-Preis ermitteln (kundenspezifisch oder Standard)
$priceInfo = getCustomerPrice($db, $productId, $order->socid);
$hourlyPrice = $priceInfo['price'];
$tvaTx = !empty($objHours->tva_tx) ? $objHours->tva_tx : $priceInfo['tva_tx'];
// Beschreibung je nach Modus
$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
$serviceBuyPrice = isset($buyPrices[$productId]) ? $buyPrices[$productId] : 0;
$hoursResult = $facture->addline(
$lineDesc, // 1: desc
$hourlyPrice, // 2: pu_ht
$hoursWorked, // 3: qty (Stunden)
$tvaTx, // 4: txtva
0, // 5: txlocaltax1
0, // 6: txlocaltax2
$productId, // 7: fk_product
0, // 8: remise_percent
'', // 9: date_start
'', // 10: date_end
0, // 11: fk_code_ventilation
0, // 12: info_bits
0, // 13: fk_remise_except
'HT', // 14: price_base_type
0, // 15: pu_ttc
1, // 16: type (1 = Dienstleistung)
$rang++, // 17: rang
0, // 18: special_code
'', // 19: origin
0, // 20: origin_id
0, // 21: fk_parent_line
null, // 22: fk_fournprice
$serviceBuyPrice, // 23: pa_ht (Einkaufspreis)
'', // 24: label
array(), // 25: array_options
100, // 26: situation_percent
0, // 27: fk_prev_id
$objHours->fk_unit // 28: fk_unit
);
if ($hoursResult > 0) {
if ($withSections && $orderHasSections && $arbeitszeitSectionResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'product',
'fk_facturedet' => $hoursResult,
'title' => null,
'parent' => $arbeitszeitSectionResult
);
}
$arbeitszeitSubtotal += $hourlyPrice * $hoursWorked;
} elseif ($hoursResult < 0) {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
}
}
// Zwischensumme für Arbeitszeit (nur mit Produktgruppen)
if ($withSections && $arbeitszeitSectionResult > 0 && $arbeitszeitSubtotal > 0) {
$subtotalLabel = 'Zwischensumme: '.$langs->trans('Leistungen');
$subtotalResult = $facture->addline(
$subtotalLabel,
0, 1, 0, 0, 0, 0, 0, '', '', 0, 0, '', 'HT', 0,
9, $rang++, 102, 0, '', 0, 0
);
if ($subtotalResult > 0) {
$invoiceManagerLines[] = array(
'type' => 'subtotal',
'fk_facturedet' => $subtotalResult,
'title' => $subtotalLabel,
'parent' => $arbeitszeitSectionResult
);
}
}
}
// llx_facture_lines_manager für Rechnung erstellen (nur mit Produktgruppen)
if ($withSections && !$error && count($invoiceManagerLines) > 0) {
$lineOrder = 1;
$sectionMap = array(); // old_facturedet_id => new_manager_rowid
foreach ($invoiceManagerLines as $mLine) {
$sqlInsert = "INSERT INTO ".MAIN_DB_PREFIX."facture_lines_manager";
$sqlInsert .= " (fk_facture, document_type, line_type, fk_facturedet, title, parent_section, line_order, show_subtotal, date_creation)";
$sqlInsert .= " VALUES (";
$sqlInsert .= ((int)$facture_id).", 'invoice', ";
$sqlInsert .= "'".$db->escape($mLine['type'])."', ";
$sqlInsert .= ($mLine['fk_facturedet'] ? (int)$mLine['fk_facturedet'] : "NULL").", ";
$sqlInsert .= ($mLine['title'] ? "'".$db->escape($mLine['title'])."'" : "NULL").", ";
// Parent-Section mapping
if ($mLine['parent'] && isset($sectionMap[$mLine['parent']])) {
$sqlInsert .= (int)$sectionMap[$mLine['parent']].", ";
} else {
$sqlInsert .= "NULL, ";
}
$sqlInsert .= $lineOrder++.", ";
$sqlInsert .= ($mLine['type'] == 'section' ? "1" : "0").", ";
$sqlInsert .= "NOW())";
$db->query($sqlInsert);
// Track section IDs for parent mapping
if ($mLine['type'] == 'section') {
$sectionMap[$mLine['fk_facturedet']] = $db->last_insert_id(MAIN_DB_PREFIX.'facture_lines_manager');
}
}
}
} else {
$error++;
}
if (!$error) {
// ============================================
// Rang-Werte in facturedet korrigieren (nur mit Produktgruppen)
// Synchronisiert rang mit line_order aus facture_lines_manager
// ============================================
if ($withSections) {
$sqlFixRang = "UPDATE ".MAIN_DB_PREFIX."facturedet fd
INNER JOIN ".MAIN_DB_PREFIX."facture_lines_manager flm ON flm.fk_facturedet = fd.rowid
SET fd.rang = flm.line_order
WHERE flm.fk_facture = ".((int)$facture_id);
$db->query($sqlFixRang);
}
// ============================================
// Verwaiste Zwischensummen/Sections bereinigen
// SubtotalTitle syncManagerTable() kann beim Seitenaufruf
// manager-Einträge ohne gültigen fk_facturedet erstellen.
// Außerdem können addline()-Fehler zu Orphans führen.
// ============================================
// 1. Verwaiste facture_lines_manager-Einträge löschen (fk_facturedet=NULL oder ungültig)
$sqlCleanManager = "DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager";
$sqlCleanManager .= " WHERE fk_facture = ".((int)$facture_id);
$sqlCleanManager .= " AND (fk_facturedet IS NULL OR fk_facturedet NOT IN (";
$sqlCleanManager .= " SELECT rowid FROM ".MAIN_DB_PREFIX."facturedet WHERE fk_facture = ".((int)$facture_id);
$sqlCleanManager .= " ))";
$db->query($sqlCleanManager);
// 2. Verwaiste Section-Titel in facturedet löschen (product_type=9 ohne zugehörige Produkte)
// Ein Section-Titel (special_code=100) ist verwaist wenn keine Produkte zwischen ihm
// und dem nächsten Section-Titel/Ende existieren
$sqlOrphanSections = "SELECT fd.rowid, fd.rang, fd.special_code, fd.description";
$sqlOrphanSections .= " FROM ".MAIN_DB_PREFIX."facturedet fd";
$sqlOrphanSections .= " WHERE fd.fk_facture = ".((int)$facture_id);
$sqlOrphanSections .= " AND fd.product_type = 9";
$sqlOrphanSections .= " ORDER BY fd.rang ASC";
$resqlOrphan = $db->query($sqlOrphanSections);
if ($resqlOrphan) {
$titleLines = array();
while ($obj = $db->fetch_object($resqlOrphan)) {
$titleLines[] = $obj;
}
// Alle regulären Produkt-/Dienstleistungszeilen laden
$sqlProducts = "SELECT rowid, rang FROM ".MAIN_DB_PREFIX."facturedet";
$sqlProducts .= " WHERE fk_facture = ".((int)$facture_id);
$sqlProducts .= " AND product_type < 9";
$sqlProducts .= " ORDER BY rang ASC";
$resqlProducts = $db->query($sqlProducts);
$productRangs = array();
if ($resqlProducts) {
while ($objP = $db->fetch_object($resqlProducts)) {
$productRangs[] = (int)$objP->rang;
}
}
// Section-Titel und Zwischensummen prüfen
$orphanIds = array();
for ($i = 0; $i < count($titleLines); $i++) {
$line = $titleLines[$i];
// Zwischensummen (special_code=102) ohne zugehörigen Section-Titel
if ($line->special_code == 102) {
// Prüfe ob es einen vorherigen Section-Titel gibt
$hasSection = false;
for ($j = $i - 1; $j >= 0; $j--) {
if ($titleLines[$j]->special_code == 100 && !in_array($titleLines[$j]->rowid, $orphanIds)) {
$hasSection = true;
break;
}
}
if (!$hasSection) {
$orphanIds[] = (int)$line->rowid;
}
continue;
}
// Section-Titel (special_code=100): verwaist wenn keine Produkte zwischen
// diesem Titel und dem nächsten Titel/Zwischensumme
if ($line->special_code == 100) {
$currentRang = (int)$line->rang;
// Nächsten Titel/Zwischensumme finden
$nextTitleRang = PHP_INT_MAX;
for ($j = $i + 1; $j < count($titleLines); $j++) {
$nextTitleRang = (int)$titleLines[$j]->rang;
break;
}
// Prüfen ob Produkte zwischen currentRang und nextTitleRang existieren
$hasProducts = false;
foreach ($productRangs as $pRang) {
if ($pRang > $currentRang && $pRang < $nextTitleRang) {
$hasProducts = true;
break;
}
}
if (!$hasProducts) {
$orphanIds[] = (int)$line->rowid;
}
}
}
// Verwaiste Zeilen aus facturedet und facture_lines_manager löschen
if (!empty($orphanIds)) {
$orphanIdList = implode(',', $orphanIds);
$db->query("DELETE FROM ".MAIN_DB_PREFIX."facturedet WHERE rowid IN (".$orphanIdList.")");
$db->query("DELETE FROM ".MAIN_DB_PREFIX."facture_lines_manager WHERE fk_facture = ".((int)$facture_id)." AND fk_facturedet IN (".$orphanIdList.")");
}
}
// Status auf "in Rechnung übertragen" setzen (direkt per SQL)
$sqlUpdate = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields SET stundenzettel_status = 2 WHERE fk_object = ".((int)$order->id);
$resqlUpdate = $db->query($sqlUpdate);
if (!$resqlUpdate || $db->affected_rows($resqlUpdate) == 0) {
$sqlInsert = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_status) VALUES (".((int)$order->id).", 2)";
$db->query($sqlInsert);
}
$db->commit();
setEventMessages($langs->trans('InvoiceCreated'), null, 'mesgs');
header('Location: '.DOL_URL_ROOT.'/compta/facture/card.php?id='.$facture_id);
exit;
} else {
$db->rollback();
}
} else {
$error++;
setEventMessages($facture->error, $facture->errors, 'errors');
$db->rollback();
}
header('Location: '.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1');
exit;
}
/*
* View
*/
// Linkes Menü aktivieren
$_GET['mainmenu'] = 'stundenzettel';
// Stundenzettel laden wenn ID übergeben wurde
$stundenzettelObj = null;
if ($stundenzettel_id > 0) {
$stundenzettelObj = new Stundenzettel($db);
if ($stundenzettelObj->fetch($stundenzettel_id) <= 0) {
$stundenzettelObj = null;
$stundenzettel_id = 0;
}
}
// Lade Extrafields für Stundenzettel-Status - direkt aus DB lesen für Zuverlässigkeit
$sqlStatus = "SELECT stundenzettel_status FROM ".MAIN_DB_PREFIX."commande_extrafields WHERE fk_object = ".((int)$order->id);
$resqlStatus = $db->query($sqlStatus);
$stundenzettelStatus = 0;
if ($resqlStatus && ($objStatus = $db->fetch_object($resqlStatus))) {
$stundenzettelStatus = (int)$objStatus->stundenzettel_status;
}
// 0 = Offen, 1 = Freigegeben, 2 = In Rechnung übertragen
// Prüfe ob alle Stundenzettel validiert sind
$allValidated = false;
$hasStundenzettel = false;
$sqlCheck = "SELECT COUNT(*) as total, SUM(CASE WHEN status >= 1 THEN 1 ELSE 0 END) as validated";
$sqlCheck .= " FROM ".MAIN_DB_PREFIX."stundenzettel WHERE fk_commande = ".((int)$order->id);
$resqlCheck = $db->query($sqlCheck);
if ($resqlCheck) {
$objCheck = $db->fetch_object($resqlCheck);
$hasStundenzettel = ($objCheck->total > 0);
$allValidated = ($objCheck->total > 0 && $objCheck->total == $objCheck->validated);
}
// Berechne verbleibende Produkte
$remainingProducts = array();
if ($hasStundenzettel) {
$sqlRemaining = "SELECT cd.rowid, cd.fk_product, cd.qty as qty_ordered, cd.description,";
$sqlRemaining .= " p.ref as product_ref, p.label as product_label,";
$sqlRemaining .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlRemaining .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlRemaining .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added', 'omitted')), 0) as qty_documented";
$sqlRemaining .= " FROM ".MAIN_DB_PREFIX."commandedet as cd";
$sqlRemaining .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sqlRemaining .= " WHERE cd.fk_commande = ".((int)$order->id);
$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 .= " HAVING qty_ordered > qty_documented";
$resqlRemaining = $db->query($sqlRemaining);
if ($resqlRemaining) {
while ($objRem = $db->fetch_object($resqlRemaining)) {
$remainingProducts[] = $objRem;
}
}
}
$hasRemainingProducts = (count($remainingProducts) > 0);
$title = $langs->trans("Stundenzettel").' - '.$order->ref;
llxHeader('', $title, '', '', 0, 0, '', '', '', 'mod-stundenzettel page-commande');
// Mobile CSS einbinden
$mobileCssFile = dol_buildpath('/stundenzettel/css/stundenzettel-mobile.css', 0);
if (file_exists($mobileCssFile)) {
print '<link rel="stylesheet" type="text/css" href="'.dol_buildpath('/stundenzettel/css/stundenzettel-mobile.css', 1).'?v='.filemtime($mobileCssFile).'">';
}
// Tabs - Immer die Stundenzettel-Commande-Tabs verwenden
// Aktiven Tab-Key basierend auf $tab bestimmen
$activeTabKey = $tab; // products, stundenzettel, oder tracking
$head = stundenzettel_commande_prepare_head($order, $stundenzettel_id);
dol_fiche_head($head, $activeTabKey, $langs->trans("Stundenzettel"), -1, 'clock');
$form = new Form($db);
// Bestätigung für Freigabe mit Warnung (verbleibende Produkte)
if ($action == 'confirm_release_warning') {
// Zeige Warnung mit verbleibenden Produkten
if ($hasRemainingProducts && count($remainingProducts) > 0) {
// Erstelle HTML für die Produktliste
$remainingHtml = '<div class="warning" style="margin-top:10px; padding:10px;">';
$remainingHtml .= '<strong>'.$langs->trans("RemainingProductsWarning").':</strong><ul style="margin:10px 0;">';
foreach ($remainingProducts as $prod) {
$productName = $prod->fk_product > 0 ? $prod->product_ref.' - '.$prod->product_label : strip_tags($prod->description);
$qtyRemaining = $prod->qty_ordered - $prod->qty_documented;
$remainingHtml .= '<li>'.$productName.' - <strong>'.formatQty($qtyRemaining).'</strong> '.$langs->trans("QtyRemaining").'</li>';
}
$remainingHtml .= '</ul></div>';
$confirmMessage = $langs->trans('ConfirmReleaseWithRemaining').$remainingHtml.'<br><br><strong>'.$langs->trans('ConfirmReleaseFinal').'</strong>';
} else {
// Keine verbleibenden Produkte - einfache Bestätigung
$confirmMessage = $langs->trans('ConfirmReleaseFinal');
}
// Direkt zur Freigabe-Action - nur EIN Dialog
print $form->formconfirm(
$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1',
$langs->trans('ConfirmReleaseWithRemainingTitle'),
$confirmMessage,
'confirm_release', // Einfacher Action-Name
array(),
0,
0 // useajax=0 für zuverlässigere Darstellung
);
}
// Bestätigung für Rechnungsübertragung (mit Auswahl: mit/ohne Produktgruppen)
if ($action == 'transfer_invoice') {
$baseUrl = $_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1';
print '<div class="confirmmessage">';
print '<div class="confirmmessagebox" style="max-width:600px; margin:10px auto; padding:20px; border:1px solid #c0c0c0; border-radius:5px; background:#f8f8f8;">';
print '<h3 style="margin-top:0;">'.img_picto('', 'bill', 'class="pictofixedwidth"').$langs->trans('ConfirmTransferInvoiceTitle').'</h3>';
print '<p>'.$langs->trans('ConfirmTransferInvoiceChoice').'</p>';
print '<div style="display:flex; gap:10px; justify-content:center; flex-wrap:wrap; margin-top:15px;">';
// Button: Mit Produktgruppen
print '<a class="butAction" href="'.$baseUrl.'&action=confirm_transfer_invoice">';
print img_picto('', 'object_list', 'class="pictofixedwidth"').$langs->trans('TransferWithSections');
print '</a>';
// Button: Ohne Produktgruppen
print '<a class="butAction" href="'.$baseUrl.'&action=confirm_transfer_invoice_flat">';
print img_picto('', 'object_line', 'class="pictofixedwidth"').$langs->trans('TransferWithoutSections');
print '</a>';
print '</div>';
print '<div style="display:flex; gap:10px; justify-content:center; margin-top:10px;">';
print '<a class="butActionDelete" href="'.$baseUrl.'">'.$langs->trans('Cancel').'</a>';
print '</div>';
print '<div style="margin-top:12px; font-size:0.9em; color:#666;">';
print '<p style="margin:3px 0;"><strong>'.$langs->trans('TransferWithSections').':</strong> '.$langs->trans('ConfirmTransferInvoiceWithSections').'</p>';
print '<p style="margin:3px 0;"><strong>'.$langs->trans('TransferWithoutSections').':</strong> '.$langs->trans('ConfirmTransferInvoiceWithoutSections').'</p>';
print '</div>';
print '</div>';
print '</div>';
}
// Info banner - Produktliste zeigt immer den Auftrag, andere Tabs den Stundenzettel
if ($tab == 'products') {
// Produktliste ist tagesbasiert - immer Auftrag anzeigen
$linkback = '<a href="'.DOL_URL_ROOT.'/commande/list.php?restore_lastsearch_values=1">'.$langs->trans("BackToList").'</a>';
dol_banner_tab($order, 'ref', $linkback, 1, 'ref', 'ref');
} elseif ($stundenzettelObj) {
$linkback = '<a href="'.dol_buildpath('/stundenzettel/list.php', 1).'?restore_lastsearch_values=1">'.$langs->trans("BackToList").'</a>';
dol_banner_tab($stundenzettelObj, 'id', $linkback, 1, 'rowid', 'ref');
} else {
$linkback = '<a href="'.DOL_URL_ROOT.'/commande/list.php?restore_lastsearch_values=1">'.$langs->trans("BackToList").'</a>';
dol_banner_tab($order, 'ref', $linkback, 1, 'ref', 'ref');
}
print '<div class="fichecenter">';
print '<div class="underbanner clearboth"></div>';
// Standard-Leistung und geplante Stunden laden
$societe = new Societe($db);
$societe->fetch($order->socid);
$societe->fetch_optionals();
// Standard-Leistung vom Kunden
$defaultServiceId = isset($societe->array_options['options_stundenzettel_default_service']) ? (int)$societe->array_options['options_stundenzettel_default_service'] : 0;
$defaultServiceProduct = null;
if ($defaultServiceId > 0) {
$defaultServiceProduct = new Product($db);
if ($defaultServiceProduct->fetch($defaultServiceId) <= 0) {
$defaultServiceProduct = null;
}
}
// Geplante Stunden aus dem Auftrag suchen (Dienstleistungen = fk_product_type = 1)
$plannedHours = 0;
$plannedHoursLine = null;
foreach ($order->lines as $line) {
// Prüfe ob es eine Dienstleistung ist
if ($line->fk_product > 0) {
$prod = new Product($db);
if ($prod->fetch($line->fk_product) > 0 && $prod->type == 1) {
// Es ist eine Dienstleistung - summiere die Menge
$plannedHours += $line->qty;
if (!$plannedHoursLine) {
$plannedHoursLine = $line; // Erste Dienstleistung merken
}
}
}
}
// Info-Box für Standard-Leistung und geplante Stunden
if ($defaultServiceProduct || $plannedHours > 0) {
print '<div class="info" style="margin-bottom:15px;">';
print '<table class="noborder" style="width:auto;">';
// Standard-Leistung
print '<tr>';
print '<td style="padding-right:20px;"><strong>'.img_picto('', 'service', 'class="pictofixedwidth"').$langs->trans("DefaultService").':</strong></td>';
print '<td>';
if ($defaultServiceProduct) {
// Kundenspezifischen Preis für die Standard-Leistung holen
$defaultServicePriceInfo = getCustomerPrice($db, $defaultServiceProduct->id, $order->socid, $defaultServiceProduct);
$displayPrice = $defaultServicePriceInfo['price'];
$isCustomerPrice = $defaultServicePriceInfo['is_customer_price'];
print $defaultServiceProduct->getNomUrl(1).' - '.$defaultServiceProduct->label;
print ' <span class="opacitymedium">('.price($displayPrice, 0, $langs, 1, -1, -1, $conf->currency).'/Std.)';
if ($isCustomerPrice) {
print ' <span class="badge badge-status4" title="'.$langs->trans("CustomerSpecificPrice").'">Kundenpreis</span>';
}
print '</span>';
} else {
print '<span class="opacitymedium">'.$langs->trans("NoDefaultServiceSet").'</span>';
print ' <a href="'.DOL_URL_ROOT.'/societe/card.php?socid='.$order->socid.'" class="button small">'.$langs->trans("SetDefaultServiceInCustomer").'</a>';
}
print '</td>';
print '</tr>';
// Geplante Stunden
if ($plannedHours > 0) {
print '<tr>';
print '<td style="padding-right:20px;"><strong>'.img_picto('', 'clock', 'class="pictofixedwidth"').$langs->trans("PlannedHours").':</strong></td>';
print '<td><span class="badge badge-info">'.formatQty($plannedHours).' Std.</span> '.$langs->trans("HoursFromOrder").'</td>';
print '</tr>';
}
print '</table>';
print '</div>';
}
// Auftragsbeschreibung anzeigen (Extrafeld auftragsbeschreibung)
// Extrafelder laden falls noch nicht geladen
if (!isset($order->array_options) || empty($order->array_options)) {
$order->fetch_optionals();
}
$orderDescription = isset($order->array_options['options_auftragsbeschreibung']) ? $order->array_options['options_auftragsbeschreibung'] : '';
if (!empty($orderDescription)) {
print '<div class="fichehalfleft" style="margin-bottom: 15px;">';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.img_picto('', 'note', 'class="pictofixedwidth"').$langs->trans("OrderDescription").'</th>';
print '</tr>';
print '<tr class="oddeven">';
print '<td>'.dol_htmlentitiesbr($orderDescription).'</td>';
print '</tr>';
print '</table>';
print '</div>';
print '</div>';
print '<div class="clearboth"></div>';
}
// Notizen für nächsten Besuch (aus vorherigen Stundenzetteln)
$sql = "SELECT s.ref, s.note_public, s.date_stundenzettel";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel as s";
$sql .= " WHERE s.fk_commande = ".((int)$order->id);
$sql .= " AND s.note_public IS NOT NULL AND s.note_public != ''";
$sql .= " ORDER BY s.date_stundenzettel DESC";
$sql .= " LIMIT 3";
$resql = $db->query($sql);
if ($resql && $db->num_rows($resql) > 0) {
print '<div class="info" style="margin-bottom:20px;">';
print '<strong>'.img_picto('', 'note', 'class="pictofixedwidth"').$langs->trans("NotesForNextVisit").':</strong><br>';
while ($obj = $db->fetch_object($resql)) {
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 dol_htmlentitiesbr($obj->note_public);
print '</div>';
}
print '</div>';
}
// Merkzettel/Notizen aus verknüpftem Stundenzettel anzeigen
$notesStundenzettel = null;
$notesToShow = array();
// Stundenzettel für Notizen bestimmen
if ($stundenzettelObj) {
$notesStundenzettel = $stundenzettelObj;
} else {
// Offenen Stundenzettel für diesen Auftrag suchen
$sqlStz = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
$sqlStz .= " WHERE fk_commande = ".((int)$order->id);
$sqlStz .= " AND status = 0"; // Nur Entwürfe
$sqlStz .= " ORDER BY date_stundenzettel DESC, rowid DESC";
$sqlStz .= " LIMIT 1";
$resqlStz = $db->query($sqlStz);
if ($resqlStz && $db->num_rows($resqlStz) > 0) {
$objStz = $db->fetch_object($resqlStz);
$notesStundenzettel = new Stundenzettel($db);
$notesStundenzettel->fetch($objStz->rowid);
}
}
// Notizen aus dem Stundenzettel laden (falls vorhanden)
if ($notesStundenzettel && $notesStundenzettel->id > 0) {
$notesStundenzettel->fetchNotes();
$notesToShow = $notesStundenzettel->notes;
}
// Merkzettel anzeigen, wenn welche vorhanden sind
if (count($notesToShow) > 0) {
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 ' <small class="opacitymedium">('.$notesStundenzettel->ref.')</small>';
print '<ul style="margin: 5px 0 0 0; padding-left: 20px; list-style: none;">';
foreach ($notesToShow as $note) {
$isChecked = ($note->checked == 1);
print '<li style="margin: 5px 0; display: flex; align-items: flex-start; gap: 8px;">';
// Checkbox zum Abhaken
if ($notesStundenzettel->status == Stundenzettel::STATUS_DRAFT && $user->hasRight('stundenzettel', 'write')) {
$newChecked = $isChecked ? 0 : 1;
print '<a href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&action=toggle_note&note_id='.$note->rowid.'&checked='.$newChecked.'&stundenzettel_id='.$notesStundenzettel->id.'&token='.newToken().'" style="text-decoration: none;">';
if ($isChecked) {
print '<span class="fas fa-check-square" style="color: #28a745; font-size: 1.1em;"></span>';
} else {
print '<span class="far fa-square" style="color: #6c757d; font-size: 1.1em;"></span>';
}
print '</a>';
} else {
if ($isChecked) {
print '<span class="fas fa-check-square" style="color: #28a745; font-size: 1.1em;"></span>';
} else {
print '<span class="far fa-square" style="color: #6c757d; font-size: 1.1em;"></span>';
}
}
// Notiz-Text
print '<span'.($isChecked ? ' style="text-decoration: line-through; color: #6c757d;"' : '').'>';
print dol_escape_htmltag($note->note);
print '</span>';
print '</li>';
}
print '</ul>';
print '</div>';
}
// =============================================
// TAB: ALLE STUNDENZETTEL
// =============================================
if ($tab == 'stundenzettel') {
// Auftrag-ID aus Stundenzettel verwenden wenn vorhanden, sonst aus URL
// Wichtig: Gleiche Logik wie in lib/stundenzettel.lib.php für Badge-Berechnung
$orderIdForQuery = $stundenzettelObj ? (int)$stundenzettelObj->fk_commande : (int)$order->id;
$sql = "SELECT s.rowid, s.ref, s.date_stundenzettel, s.status, s.fk_user_author,";
$sql .= " u.firstname, u.lastname,";
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp WHERE sp.fk_stundenzettel = s.rowid AND sp.origin IN ('order', 'added')), 0) as total_qty_products,";
$sql .= " COALESCE((SELECT SUM(sl.duration) FROM ".MAIN_DB_PREFIX."stundenzettel_leistung sl WHERE sl.fk_stundenzettel = s.rowid), 0) as total_duration_minutes";
$sql .= " FROM ".MAIN_DB_PREFIX."stundenzettel as s";
$sql .= " LEFT JOIN ".MAIN_DB_PREFIX."user as u ON u.rowid = s.fk_user_author";
$sql .= " WHERE s.fk_commande = ".((int)$orderIdForQuery);
$sql .= " ORDER BY s.date_stundenzettel DESC, s.rowid DESC";
$resqlStzList = $db->query($sql);
$numStz = $resqlStzList ? $db->num_rows($resqlStzList) : 0;
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Ref").'</th>';
print '<th>'.$langs->trans("Date").'</th>';
print '<th>'.$langs->trans("Author").'</th>';
print '<th class="right">'.$langs->trans("Products").'</th>';
print '<th class="right">'.$langs->trans("LeistungDuration").'</th>';
print '<th>'.$langs->trans("Status").'</th>';
print '</tr>';
if ($numStz > 0) {
while ($objStz = $db->fetch_object($resqlStzList)) {
$isCurrentStz = ($stundenzettel_id > 0 && $objStz->rowid == $stundenzettel_id);
print '<tr class="oddeven'.($isCurrentStz ? ' stz-current-stz' : '').'">';
// Ref
print '<td>';
print '<a href="'.dol_buildpath('/stundenzettel/card.php?id='.$objStz->rowid, 1).'">';
print img_picto('', 'clock', 'class="pictofixedwidth"').$objStz->ref;
print '</a>';
if ($isCurrentStz) {
print ' <span class="badge badge-info">aktuell</span>';
}
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($objStz->date_stundenzettel), 'day').'</td>';
// Autor
print '<td>'.$objStz->firstname.' '.$objStz->lastname.'</td>';
// Gesamtmenge Produkte
print '<td class="right">';
if ($objStz->total_qty_products > 0) {
print formatQty($objStz->total_qty_products);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Gesamtzeit Leistungen
print '<td class="right">';
if ($objStz->total_duration_minutes > 0) {
$hours = floor($objStz->total_duration_minutes / 60);
$minutes = $objStz->total_duration_minutes % 60;
print sprintf('%d:%02d h', $hours, $minutes);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Status
$stzTemp = new Stundenzettel($db);
print '<td>'.$stzTemp->LibStatut($objStz->status, 5).'</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="6" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
print '</table>';
print '</div>';
// Button: Neuen Stundenzettel erstellen
print '<div class="center" style="margin-top: 20px;">';
print '<a class="butAction" href="'.dol_buildpath('/stundenzettel/card.php?action=create&fk_commande='.$orderIdForQuery, 1).'">';
print img_picto('', 'add', 'class="pictofixedwidth"').$langs->trans("CreateStundenzettel");
print '</a>';
print '</div>';
}
// =============================================
// TAB: PRODUKTLISTE AUS AUFTRAG
// =============================================
if ($tab == 'products') {
// Farben für Sections (wie im SubtotalTitle-Modul)
$sectionColors = array('#4a90d9', '#50b87d', '#e67e22', '#9b59b6', '#e74c3c', '#1abc9c', '#f39c12', '#3498db');
// Filter-Dropdown mit localStorage-Speicherung pro Auftrag
print '<div class="marginbottomonly" style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">';
// Filter
print '<div>';
print '<label for="filter" style="margin-right: 5px;">'.$langs->trans("Filter").':</label>';
print '<select name="filter" id="filter" class="flat" onchange="saveFilterAndRedirect(this.value)">';
print '<option value="open"'.($filter == 'open' ? ' selected' : '').'>'.$langs->trans("FilterOpen").'</option>';
print '<option value="done"'.($filter == 'done' ? ' selected' : '').'>'.$langs->trans("FilterDone").'</option>';
print '<option value="all"'.($filter == 'all' ? ' selected' : '').'>'.$langs->trans("FilterAll").'</option>';
print '</select>';
print '</div>';
// Alle ein-/ausklappen Buttons
print '<div>';
print '<button type="button" class="button small" onclick="expandAllSections()" title="'.$langs->trans("ExpandAll").'">';
print '<span class="fas fa-expand-arrows-alt"></span> '.$langs->trans("ExpandAll");
print '</button>';
print ' ';
print '<button type="button" class="button small" onclick="collapseAllSections()" title="'.$langs->trans("CollapseAll").'">';
print '<span class="fas fa-compress-arrows-alt"></span> '.$langs->trans("CollapseAll");
print '</button>';
print '</div>';
print '</div>';
// JavaScript für Filter-Speicherung pro Auftrag
print '<script>
var orderId = '.$order->id.';
var stundenzettelId = '.($stundenzettel_id > 0 ? $stundenzettel_id : 0).';
// Beim Laden: Filter aus localStorage holen (falls nicht per URL gesetzt)
document.addEventListener("DOMContentLoaded", function() {
var urlParams = new URLSearchParams(window.location.search);
// WICHTIG: Nicht umleiten wenn eine action aktiv ist (z.B. Bestätigungsdialog)
if (urlParams.has("action")) {
return; // Bei Actions keine Filter-Weiterleitung
}
if (!urlParams.has("filter")) {
var savedFilter = localStorage.getItem("stz_filter_" + orderId);
if (savedFilter && savedFilter !== "'.$filter.'") {
// Redirect mit gespeichertem Filter
saveFilterAndRedirect(savedFilter);
}
}
});
function saveFilterAndRedirect(filterValue) {
// Filter in localStorage speichern
localStorage.setItem("stz_filter_" + orderId, filterValue);
// Redirect (noredirect beibehalten um Auto-Redirect zu verhindern)
var url = "?id=" + orderId + "&tab=products&filter=" + filterValue + "&noredirect=1";
if (stundenzettelId > 0) {
url += "&stundenzettel_id=" + stundenzettelId;
}
window.location.href = url;
}
</script>';
print '<form method="POST" action="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'">';
print '<input type="hidden" name="token" value="'.newToken().'">';
print '<input type="hidden" name="action" value="transfer_products">';
if ($stundenzettel_id > 0) {
print '<input type="hidden" name="stundenzettel_id" value="'.$stundenzettel_id.'">';
}
// Datum für Stundenzettel nur anzeigen, wenn kein Stundenzettel ausgewählt
if (!$stundenzettel_id) {
// Standard-Datum ermitteln basierend auf Einstellung
$defaultDateSetting = getDolGlobalString('STUNDENZETTEL_DEFAULT_DATE', 'today');
$defaultDate = date('Y-m-d');
if ($defaultDateSetting == 'last_open') {
// Datum des letzten offenen Stundenzettels für diesen Auftrag suchen
$sqlLastOpen = "SELECT date_stundenzettel FROM ".MAIN_DB_PREFIX."stundenzettel";
$sqlLastOpen .= " WHERE fk_commande = ".((int)$order->id);
$sqlLastOpen .= " AND status = 0"; // Nur Entwürfe
$sqlLastOpen .= " ORDER BY date_stundenzettel DESC, rowid DESC LIMIT 1";
$resqlLastOpen = $db->query($sqlLastOpen);
if ($resqlLastOpen && $db->num_rows($resqlLastOpen) > 0) {
$objLastOpen = $db->fetch_object($resqlLastOpen);
if ($objLastOpen->date_stundenzettel) {
$defaultDate = date('Y-m-d', $db->jdate($objLastOpen->date_stundenzettel));
}
}
}
print '<div class="marginbottomonly">';
print '<label for="date_stundenzettel">'.$langs->trans("StundenzettelDate").':</label> ';
print '<input type="date" name="date_stundenzettel" id="date_stundenzettel" value="'.$defaultDate.'" class="flat">';
print '</div>';
} else {
// Prüfen ob der ausgewählte Stundenzettel von heute ist
$stzDateStr = date('Y-m-d', $stundenzettelObj->date_stundenzettel);
$todayStr = date('Y-m-d');
print '<div class="marginbottomonly">';
if ($stzDateStr == $todayStr) {
// Stundenzettel ist von heute - normal anzeigen
print '<span class="opacitymedium">'.$langs->trans("TransferToStundenzettel").': </span>';
print '<strong>'.$stundenzettelObj->ref.'</strong> ('.dol_print_date($stundenzettelObj->date_stundenzettel, 'day').')';
} else {
// Stundenzettel ist von einem anderen Tag - Hinweis dass neuer erstellt wird
print '<span class="opacitymedium">'.$langs->trans("TransferToStundenzettel").': </span>';
print '<strong style="color: #28a745;">'.$langs->trans("NewStundenzettelForToday").'</strong>';
print ' <span class="opacitymedium">('.dol_print_date(dol_now(), 'day').')</span>';
print '<br><small class="opacitymedium">'.$langs->trans("SelectedStundenzettelNotToday", $stundenzettelObj->ref, dol_print_date($stundenzettelObj->date_stundenzettel, 'day')).'</small>';
// Hidden field für das heutige Datum
print '<input type="hidden" name="force_new_stundenzettel" value="1">';
print '<input type="hidden" name="date_stundenzettel" value="'.date('Y-m-d').'">';
}
print '</div>';
}
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th class="center" style="width:30px;"><input type="checkbox" id="checkall" onclick="toggleAll(this)"></th>';
print '<th>'.$langs->trans("Product").'</th>';
print '<th class="right">'.$langs->trans("QtyOrdered").'</th>';
print '<th class="right">'.$langs->trans("QtyDelivered").'</th>';
print '<th class="right">'.$langs->trans("QtyRemaining").'</th>';
print '<th>'.$langs->trans("Status").'</th>';
print '</tr>';
// Lade Sections und Produkte aus facture_lines_manager
$sections = array();
$products_by_section = array();
$products_without_section = array();
// Erst alle Sections laden
$sql = "SELECT m.rowid, m.title, m.line_order";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_lines_manager as m";
$sql .= " WHERE m.fk_commande = ".((int)$order->id);
$sql .= " AND m.document_type = 'order'";
$sql .= " AND m.line_type = 'section'";
$sql .= " ORDER BY m.line_order";
$resql = $db->query($sql);
if ($resql) {
$colorIndex = 0;
while ($obj = $db->fetch_object($resql)) {
$sections[$obj->rowid] = array(
'title' => $obj->title,
'line_order' => $obj->line_order,
'color' => $sectionColors[$colorIndex % count($sectionColors)]
);
$products_by_section[$obj->rowid] = array();
$colorIndex++;
}
}
// Dann alle Produkte laden mit Section-Zuordnung
// Berechne qty_delivered, qty_added (Mehraufwand), qty_removed (Entfällt) und qty_returned (Rücknahme) direkt aus Stundenzetteln
$sql = "SELECT m.rowid as manager_id, m.fk_commandedet, m.parent_section, m.line_order,";
$sql .= " cd.rowid, cd.fk_product, cd.qty, cd.description,";
$sql .= " p.ref as product_ref, p.label as product_label,";
// qty_delivered: Summe aller qty_done für diese Auftragszeile (origin = 'order' oder 'added')
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sql .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
// qty_added: Mehraufwand für dieses Produkt (origin = 'additional')
$sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sql .= " WHERE sp2.fk_product = cd.fk_product AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$order->id)."), 0) as qty_added,";
// 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 .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
$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')
$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.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 .= " 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 .= " WHERE m.fk_commande = ".((int)$order->id);
$sql .= " AND m.document_type = 'order'";
$sql .= " AND m.line_type = 'product'";
$sql .= " ORDER BY m.line_order";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
if ($obj->parent_section && isset($products_by_section[$obj->parent_section])) {
$products_by_section[$obj->parent_section][] = $obj;
} else {
$products_without_section[] = $obj;
}
}
}
// Falls keine Manager-Einträge, direkt aus commandedet laden
$hasManagerData = (count($sections) > 0 || count($products_without_section) > 0);
if (!$hasManagerData) {
// Berechne qty_delivered, qty_added (Mehraufwand), qty_removed (Entfällt) und qty_returned (Rücknahme) direkt aus Stundenzetteln
$sql = "SELECT cd.rowid, cd.fk_product, cd.qty, cd.description,";
$sql .= " p.ref as product_ref, p.label as product_label,";
// qty_delivered: Summe aller qty_done für diese Auftragszeile (origin = 'order' oder 'added')
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sql .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
// qty_added: Mehraufwand für dieses Produkt (origin = 'additional')
$sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sql .= " WHERE sp2.fk_product = cd.fk_product AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$order->id)."), 0) as qty_added,";
// 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 .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s3 ON s3.rowid = sp3.fk_stundenzettel";
$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')
$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.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 .= " LEFT JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = cd.fk_product";
$sql .= " WHERE cd.fk_commande = ".((int)$order->id);
$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 .= " ORDER BY cd.rang";
$resql = $db->query($sql);
if ($resql) {
while ($obj = $db->fetch_object($resql)) {
$products_without_section[] = $obj;
}
}
}
// Bereits auf dem ausgewählten Stundenzettel vorhandene Produkte laden
// NUR wenn der Stundenzettel von heute ist - ansonsten wird ein neuer erstellt
$alreadyOnStundenzettel = array();
$stzIsToday = false;
if ($stundenzettel_id > 0 && $stundenzettelObj) {
$stzDateStr = date('Y-m-d', $stundenzettelObj->date_stundenzettel);
$todayStr = date('Y-m-d');
$stzIsToday = ($stzDateStr == $todayStr);
// Nur wenn der Stundenzettel von heute ist, die bereits vorhandenen Produkte laden
if ($stzIsToday) {
$sqlExisting = "SELECT sp.fk_commandedet FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlExisting .= " WHERE sp.fk_stundenzettel = ".((int)$stundenzettel_id);
$sqlExisting .= " AND sp.fk_commandedet IS NOT NULL AND sp.fk_commandedet > 0";
$resqlExisting = $db->query($sqlExisting);
if ($resqlExisting) {
while ($objExisting = $db->fetch_object($resqlExisting)) {
$alreadyOnStundenzettel[$objExisting->fk_commandedet] = true;
}
}
}
}
// Hilfsfunktion für Produktzeile - gibt true zurück wenn angezeigt, false wenn übersprungen
$printProductRow = function($obj, $color = null, $sectionId = null) use ($langs, $filter, $alreadyOnStundenzettel) {
$qty_added = isset($obj->qty_added) ? (float)$obj->qty_added : 0;
$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 - 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;
$isDone = ($remaining <= 0);
// Filter anwenden
if ($filter == 'open' && $isDone) {
return false;
}
if ($filter == 'done' && !$isDone) {
return false;
}
// 'all' zeigt alles
// Styling: Nur erste Zelle bekommt border-left
$styleFirst = $color ? ' style="border-left: 4px solid '.$color.';"' : '';
$sectionClass = $sectionId ? ' section-product section_'.$sectionId : '';
print '<tr class="oddeven'.$sectionClass.'">';
// Checkbox immer anzeigen
print '<td class="center"'.$styleFirst.'>';
print '<input type="checkbox" name="selected[]" value="'.$obj->rowid.'" class="product-checkbox">';
print '</td>';
// Produkt
print '<td>';
if ($obj->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$obj->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $obj->product_ref.' - '.$obj->product_label;
print '</a>';
} else {
// Freitext-Produkt: Beschreibung anzeigen
print img_picto('', 'generic', 'class="pictofixedwidth"');
$desc = !empty($obj->description) ? strip_tags($obj->description) : '-';
// Beschreibung kürzen wenn zu lang
if (strlen($desc) > 80) {
$desc = substr($desc, 0, 77).'...';
}
print '<span class="opacitymedium">'.$desc.'</span>';
}
print '</td>';
// Menge bestellt - zeigt Gesamtzahl mit Info über Änderungen
print '<td class="right">';
// Gesamtzahl (effektiv) als Hauptzahl
print '<strong>'.formatQty($effectiveTotal).'</strong>';
// Änderungen als kleine Info-Badges
if ($qty_added > 0) {
print ' <span class="badge" style="background-color: #28a745; color: #fff; font-size: 0.75em;" title="'.$langs->trans("AdditionalQty").'">+'.formatQty($qty_added).'</span>';
}
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 badge-danger" title="'.$langs->trans("Ruecknahme").'">-'.formatQty($qty_returned).'</span>';
}
print '</td>';
// Verbleibend
print '<td class="right">';
if ($remaining > 0) {
print '<span class="badge badge-warning">'.formatQty($remaining).'</span>';
} elseif ($remaining == 0) {
print '<span class="badge badge-success">0</span>';
} else {
// Mehr verbaut als bestellt
print '<span class="badge badge-info">'.formatQty($remaining).'</span>';
}
print '</td>';
// Status
print '<td>';
if ($isDone) {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
} elseif ($obj->qty_delivered > 0) {
print '<span class="badge badge-warning">'.$langs->trans("TrackingPartial").'</span>';
} else {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
}
print '</td>';
print '</tr>';
return true;
};
$totalProducts = 0;
// Sections mit Produkten anzeigen
foreach ($sections as $sectionId => $section) {
$sectionProducts = $products_by_section[$sectionId];
if (count($sectionProducts) == 0) continue;
// Prüfen, wie viele Produkte nach Filter angezeigt werden
$visibleProductsInSection = 0;
foreach ($sectionProducts as $prod) {
$prodQtyAdded = isset($prod->qty_added) ? (float)$prod->qty_added : 0;
$prodQtyRemoved = isset($prod->qty_removed) ? (float)$prod->qty_removed : 0;
$prodQtyReturned = isset($prod->qty_returned) ? (float)$prod->qty_returned : 0;
$prodEffectiveTotal = $prod->qty + $prodQtyAdded - $prodQtyRemoved;
$prodEffectiveDelivered = $prod->qty_delivered - $prodQtyReturned;
$remaining = $prodEffectiveTotal - $prodEffectiveDelivered;
$isDone = ($remaining <= 0);
// Filter-Logik
if ($filter == 'open' && !$isDone) {
$visibleProductsInSection++;
} elseif ($filter == 'done' && $isDone) {
$visibleProductsInSection++;
} elseif ($filter == 'all') {
$visibleProductsInSection++;
}
}
// Section nur anzeigen wenn Produkte nach Filter vorhanden
if ($visibleProductsInSection == 0) continue;
// Section-Header mit Buffering, wird nur ausgegeben wenn Produkte angezeigt werden
// 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 .= '<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 .= dol_escape_htmltag($section['title']);
$sectionHeader .= ' <span style="opacity: 0.6; font-weight: normal;">('.$visibleProductsInSection.' '.$langs->trans("Products").')</span>';
$sectionHeader .= '</td>';
$sectionHeader .= '</tr>';
print $sectionHeader;
// Produkte dieser Section
foreach ($sectionProducts as $prod) {
if ($printProductRow($prod, $section['color'], $sectionId)) {
$totalProducts++;
}
}
}
// Produkte ohne Section
$visibleProductsWithoutSection = 0;
foreach ($products_without_section as $prod) {
$prodQtyAdded = isset($prod->qty_added) ? (float)$prod->qty_added : 0;
$prodQtyRemoved = isset($prod->qty_removed) ? (float)$prod->qty_removed : 0;
$prodQtyReturned = isset($prod->qty_returned) ? (float)$prod->qty_returned : 0;
$prodEffectiveTotal = $prod->qty + $prodQtyAdded - $prodQtyRemoved;
$prodEffectiveDelivered = $prod->qty_delivered - $prodQtyReturned;
$remaining = $prodEffectiveTotal - $prodEffectiveDelivered;
$isDone = ($remaining <= 0);
// Filter-Logik
if ($filter == 'open' && !$isDone) {
$visibleProductsWithoutSection++;
} elseif ($filter == 'done' && $isDone) {
$visibleProductsWithoutSection++;
} elseif ($filter == 'all') {
$visibleProductsWithoutSection++;
}
}
if ($visibleProductsWithoutSection > 0) {
if (count($sections) > 0) {
// 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 '<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 $langs->trans("OtherProducts").' <span style="opacity: 0.6; font-weight: normal;">('.$visibleProductsWithoutSection.' '.$langs->trans("Products").')</span>';
print '</td>';
print '</tr>';
}
foreach ($products_without_section as $prod) {
if ($printProductRow($prod, null, 'other')) {
$totalProducts++;
}
}
}
if ($totalProducts == 0) {
print '<tr class="oddeven"><td colspan="6" class="opacitymedium">'.$langs->trans("AllProductsDocumented").'</td></tr>';
}
// =============================================
// BEREICH: MEHRAUFWAND-PRODUKTE (aus allen Stundenzetteln dieses Auftrags)
// =============================================
// Mehraufwand umfasst:
// 1. origin='additional' → Mehraufwand-Bestellung (Beauftragt)
// 2. origin='added' ohne fk_commandedet → Produkt nicht im Auftrag, in Produktliste hinzugefügt (Verbaut)
// 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,";
// qty_added = Menge aus Produktliste (nicht im Auftrag)
$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 .= " GROUP_CONCAT(DISTINCT s.ref SEPARATOR ', ') as stundenzettel_refs";
$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);
// 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";
// 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;
}
}
$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;">('.$mehraufwandVisibleCount.' '.$langs->trans("Products").')</span>';
print '</td>';
print '</tr>';
// 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)
// Wenn NUR 'added' existiert: Beauftragt = Verbaut = qty_added
// Wenn 'additional' existiert: Beauftragt = qty_additional, Verbaut = qty_added
$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
// 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
// Product IDs für Checkbox holen (beide origin-Typen)
$sqlIds = "SELECT GROUP_CONCAT(DISTINCT sp.rowid ORDER BY sp.rowid) as product_ids";
$sqlIds .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlIds .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlIds .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlIds .= " AND (sp.origin = 'additional' OR (sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)))";
if ($objMa->fk_product > 0) {
$sqlIds .= " AND sp.fk_product = ".((int)$objMa->fk_product);
} else {
$sqlIds .= " AND sp.fk_product IS NULL AND sp.description = '".$db->escape($objMa->description)."'";
}
$resqlIds = $db->query($sqlIds);
$productIds = '';
if ($resqlIds && ($objIds = $db->fetch_object($resqlIds))) {
$productIds = $objIds->product_ids;
}
// 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">';
// Checkbox - Mehraufwand immer anzeigen (für Auswahl/Übersicht)
print '<td class="center">';
if ($productIds) {
print '<input type="checkbox" name="selected_mehraufwand[]" value="'.$productIds.'" class="product-checkbox">';
}
print '</td>';
// Produkt
print '<td>';
if ($objMa->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$objMa->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $objMa->product_ref.' - '.$objMa->product_label;
print '</a>';
} else {
print img_picto('', 'generic', 'class="pictofixedwidth"');
$desc = !empty($objMa->description) ? strip_tags($objMa->description) : $langs->trans("FreeText");
if (strlen($desc) > 80) {
$desc = substr($desc, 0, 77).'...';
}
print '<span class="opacitymedium">'.$desc.'</span>';
}
print ' <span class="badge badge-warning">'.$langs->trans("Mehraufwand").'</span>';
print '</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 (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">';
if ($qtyRemaining > 0) {
print '<span class="badge badge-warning">'.formatQty($qtyRemaining).'</span>';
} elseif ($qtyRemaining < 0) {
// Mehr verbaut als geplant
print '<span class="badge badge-info">'.formatQty($qtyRemaining).'</span>';
} else {
print '<span class="badge badge-success">0</span>';
}
print '</td>';
// Status
print '<td>';
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 && $qtyTarget > 0) {
print '<span class="badge badge-secondary">'.$langs->trans("TrackingOpen").'</span>';
} else {
print '<span class="badge badge-success">'.$langs->trans("TrackingDone").'</span>';
}
print ' <small class="opacitymedium" title="'.$objMa->stundenzettel_refs.'">('.$langs->trans("FromStundenzettel").')</small>';
print '</td>';
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
// Die Entfällt-Mengen werden bereits in der Beauftragt-Spalte der normalen Produkte
// als Abzug berücksichtigt (effectiveTotal = qty + qty_added - qty_removed)
print '</table>';
print '</div>';
// Button nur anzeigen wenn nicht freigegeben
if ($stundenzettelStatus == 0) {
print '<div class="center" style="margin-top:20px;">';
print '<input type="submit" class="button button-primary" value="'.$langs->trans("OpenStundenzettel").'">';
print '</div>';
} elseif ($stundenzettelStatus == 1) {
// Status 1: Freigegeben - zeige Meldung mit Wiedereröffnen-Button
print '<div class="warning" style="margin-top:20px;">';
print img_picto('', 'lock', 'class="pictofixedwidth"');
print $langs->trans("StundenzettelReleasedNoChanges");
print ' <a class="button small" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=reopen_stundenzettel" style="margin-left:10px;">';
print img_picto('', 'unlock', 'class="pictofixedwidth"').$langs->trans("ReopenStundenzettel");
print '</a>';
print '</div>';
} else {
// Status 2: In Rechnung übertragen
print '<div class="info" style="margin-top:20px;">';
print img_picto('', 'check', 'class="pictofixedwidth"');
print $langs->trans("StundenzettelTransferredToInvoice");
print ' <a class="button small" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=reset_stundenzettel" style="margin-left:10px;">';
print img_picto('', 'undo', 'class="pictofixedwidth"').$langs->trans("ResetStundenzettel");
print '</a>';
print '</div>';
}
print '</form>';
// JavaScript für "Alle auswählen" und Section-Toggle
print '<script>
function toggleAll(source) {
var checkboxes = document.getElementsByClassName("product-checkbox");
for (var i = 0; i < checkboxes.length; i++) {
checkboxes[i].checked = source.checked;
}
}
// Section ein-/ausklappen
function toggleSection(sectionId) {
var rows = document.querySelectorAll("tr." + sectionId);
var toggle = document.getElementById("toggle_" + sectionId);
var isCollapsed = toggle.textContent.trim() === "▶";
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = isCollapsed ? "" : "none";
}
toggle.textContent = isCollapsed ? "▼" : "▶";
// Zustand in localStorage speichern
saveSectionState(sectionId, !isCollapsed);
}
// Alle Sections ausklappen
function expandAllSections() {
var headers = document.querySelectorAll(".section-header");
headers.forEach(function(header) {
var sectionId = header.dataset.section;
var rows = document.querySelectorAll("tr." + sectionId);
var toggle = document.getElementById("toggle_" + sectionId);
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = "";
}
if (toggle) toggle.textContent = "▼";
saveSectionState(sectionId, false);
});
}
// Alle Sections einklappen
function collapseAllSections() {
var headers = document.querySelectorAll(".section-header");
headers.forEach(function(header) {
var sectionId = header.dataset.section;
var rows = document.querySelectorAll("tr." + sectionId);
var toggle = document.getElementById("toggle_" + sectionId);
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = "none";
}
if (toggle) toggle.textContent = "▶";
saveSectionState(sectionId, true);
});
}
// Zustand in localStorage speichern
function saveSectionState(sectionId, collapsed) {
var states = JSON.parse(localStorage.getItem("stz_sections_" + orderId) || "{}");
states[sectionId] = collapsed;
localStorage.setItem("stz_sections_" + orderId, JSON.stringify(states));
}
// Zustand beim Laden wiederherstellen
document.addEventListener("DOMContentLoaded", function() {
var states = JSON.parse(localStorage.getItem("stz_sections_" + orderId) || "{}");
for (var sectionId in states) {
if (states[sectionId]) {
var rows = document.querySelectorAll("tr." + sectionId);
var toggle = document.getElementById("toggle_" + sectionId);
for (var i = 0; i < rows.length; i++) {
rows[i].style.display = "none";
}
if (toggle) toggle.textContent = "▶";
}
}
});
</script>';
// =============================================
// AKTIONSBUTTONS FÜR STUNDENZETTEL-FREIGABE
// =============================================
if ($stundenzettelStatus < 2) {
print '<div class="tabsAction" style="margin-top: 20px;">';
if ($stundenzettelStatus == 0) {
// Status: Offen - Zeige Freigeben-Button wenn Stundenzettel vorhanden
if ($hasStundenzettel && $allValidated) {
if ($hasRemainingProducts) {
// Mit Warnung
print '<a class="butActionDelete" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=confirm_release_warning">';
print img_picto('', 'lock', 'class="pictofixedwidth"').$langs->trans("ReleaseStundenzettel");
print '</a>';
} else {
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=release_stundenzettel">';
print img_picto('', 'lock', 'class="pictofixedwidth"').$langs->trans("ReleaseStundenzettel");
print '</a>';
}
} elseif ($hasStundenzettel && !$allValidated) {
print '<span class="butActionRefused" title="'.$langs->trans("AllStundenzettelMustBeValidated").'">';
print img_picto('', 'lock', 'class="pictofixedwidth"').$langs->trans("ReleaseStundenzettel");
print '</span>';
}
} elseif ($stundenzettelStatus == 1) {
// Status: Freigegeben - Zeige Wiedereröffnen und Rechnung-Button
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=reopen_stundenzettel">';
print img_picto('', 'unlock', 'class="pictofixedwidth"').$langs->trans("ReopenStundenzettel");
print '</a>';
if ($user->hasRight('facture', 'creer')) {
print '<a class="butAction" href="'.$_SERVER['PHP_SELF'].'?id='.$order->id.'&tab=products&noredirect=1&action=transfer_invoice">';
print img_picto('', 'bill', 'class="pictofixedwidth"').$langs->trans("TransferToInvoice");
print '</a>';
}
}
print '</div>';
}
}
// =============================================
// TAB: LIEFERAUFLISTUNG / TRACKING
// =============================================
if ($tab == 'tracking') {
$total_delivered = 0;
// Alle Stundenzettel-Details pro Produkt laden (für ausklappbare Ansicht)
$trackingDetails = array(); // Array[fk_commandedet] => array of entries
$sqlDetails = "SELECT sp.rowid, sp.fk_stundenzettel, sp.fk_commandedet, sp.fk_product,";
$sqlDetails .= " sp.qty_done, sp.origin, sp.description,";
$sqlDetails .= " s.ref as stz_ref, s.date_stundenzettel";
$sqlDetails .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sqlDetails .= " INNER JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sqlDetails .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlDetails .= " AND sp.qty_done > 0";
$sqlDetails .= " ORDER BY s.date_stundenzettel DESC, sp.rowid";
$resqlDetails = $db->query($sqlDetails);
if ($resqlDetails) {
while ($objD = $db->fetch_object($resqlDetails)) {
// Key-Logik: fk_commandedet > prod_X > freetext_hash
if ($objD->fk_commandedet > 0) {
$key = $objD->fk_commandedet;
} elseif ($objD->fk_product > 0) {
$key = 'prod_'.$objD->fk_product;
} else {
// Freitext: Hash der Beschreibung als Key
$key = 'freetext_'.md5(trim($objD->description));
}
if (!isset($trackingDetails[$key])) {
$trackingDetails[$key] = array();
}
$trackingDetails[$key][] = $objD;
}
}
// Buttons für Details anzeigen/verbergen
print '<div class="marginbottomonly" style="display:flex; justify-content:flex-end; gap:10px;">';
print '<button type="button" class="button small" onclick="expandAllTrackingDetails()" title="'.$langs->trans("ShowDetails").'">';
print '<span class="fas fa-list"></span> '.$langs->trans("ShowDetails");
print '</button>';
print '<button type="button" class="button small" onclick="collapseAllTrackingDetails()" title="'.$langs->trans("HideDetails").'">';
print '<span class="fas fa-minus"></span> '.$langs->trans("HideDetails");
print '</button>';
print '</div>';
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent" id="tracking-table">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Product").'</th>';
print '<th class="right">'.$langs->trans("QtyDelivered").'</th>';
print '</tr>';
// Live-Berechnung aus allen Stundenzetteln (inkl. Entwürfe)
$sql = "SELECT cd.rowid, cd.fk_product, cd.qty as qty_ordered, cd.description,";
$sql .= " p.ref as product_ref, p.label as product_label,";
$sql .= " COALESCE((SELECT SUM(sp.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = sp.fk_stundenzettel";
$sql .= " WHERE sp.fk_commandedet = cd.rowid AND sp.origin IN ('order', 'added')), 0) as qty_delivered,";
// Mehraufwand - nur für Produkte mit fk_product > 0 (Freitext-Mehraufwand wird separat angezeigt)
$sql .= " COALESCE((SELECT SUM(sp2.qty_done) FROM ".MAIN_DB_PREFIX."stundenzettel_product sp2";
$sql .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s2 ON s2.rowid = sp2.fk_stundenzettel";
$sql .= " WHERE sp2.fk_product = cd.fk_product AND cd.fk_product > 0 AND sp2.origin = 'additional' AND s2.fk_commande = ".((int)$order->id)."), 0) as qty_additional,";
// Entfällt - für Produkte via fk_product, für Freitext via fk_commandedet
$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,";
// 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);
$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 .= " ORDER BY cd.rang";
$resql = $db->query($sql);
$detailRowId = 0;
if ($resql) {
$num = $db->num_rows($resql);
if ($num > 0) {
while ($obj = $db->fetch_object($resql)) {
$detailRowId++;
$qty_additional = (float)$obj->qty_additional;
$qty_omitted = (float)$obj->qty_omitted;
$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();
// Falls keine Details über fk_commandedet, versuche über fk_product
if (empty($details) && $obj->fk_product > 0) {
$prodKey = 'prod_'.$obj->fk_product;
if (isset($trackingDetails[$prodKey])) {
$details = $trackingDetails[$prodKey];
}
}
$hasDetails = !empty($details);
print '<tr class="oddeven">';
// Produkt mit Toggle
print '<td>';
if ($hasDetails) {
print '<span class="tracking-toggle" onclick="toggleTrackingDetails(\'tdetail_'.$detailRowId.'\')" style="cursor:pointer; margin-right:5px;">';
print '<span class="fas fa-chevron-right tracking-arrow" id="tarrow_tdetail_'.$detailRowId.'"></span>';
print '</span>';
}
if ($obj->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$obj->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $obj->product_ref.' - '.$obj->product_label;
print '</a>';
} else {
print img_picto('', 'generic', 'class="pictofixedwidth"');
$desc = !empty($obj->description) ? strip_tags($obj->description) : '-';
if (strlen($desc) > 60) $desc = substr($desc, 0, 57).'...';
print '<span class="opacitymedium">'.$desc.'</span>';
}
print '</td>';
// Verbaut (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;
print '</tr>';
// Detail-Zeile (standardmäßig eingeklappt)
if ($hasDetails) {
print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">';
print '<td class="stz-subtable-bg" colspan="2" style="padding: 0 0 0 30px;">';
// Sub-Tabelle für Details
print '<table class="noborder" style="width:100%; margin: 5px 0;">';
print '<tr class="liste_titre stz-subtable-header">';
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("Type").'</th>';
print '<th class="right" style="width:80px;">'.$langs->trans("Qty").'</th>';
print '<th>'.$langs->trans("Reason").'</th>';
print '</tr>';
foreach ($details as $det) {
print '<tr class="oddeven">';
// Stundenzettel-Referenz mit Link
print '<td>';
print '<a href="'.dol_buildpath('/stundenzettel/card.php', 1).'?id='.$det->fk_stundenzettel.'">';
print $det->stz_ref;
print '</a>';
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($det->date_stundenzettel), 'day').'</td>';
// Typ (origin)
print '<td>';
switch ($det->origin) {
case 'order':
case 'added':
print '<span class="badge badge-primary">'.$langs->trans("QtyDelivered").'</span>';
break;
case 'additional':
print '<span class="badge badge-success">'.$langs->trans("Mehraufwand").'</span>';
break;
case 'omitted':
print '<span class="badge badge-danger">'.$langs->trans("Entfaellt").'</span>';
break;
default:
print $det->origin;
}
print '</td>';
// Menge
print '<td class="right">'.formatQty($det->qty_done).'</td>';
// Grund/Beschreibung
print '<td>';
if (!empty($det->description)) {
print dol_escape_htmltag($det->description);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
print '</tr>';
}
print '</table>';
print '</td>';
print '</tr>';
}
}
// Summenzeile
print '<tr class="liste_total">';
print '<td><strong>'.$langs->trans("Total").'</strong></td>';
print '<td class="right"><strong>'.formatQty($total_delivered).'</strong></td>';
print '</tr>';
} else {
print '<tr class="oddeven"><td colspan="2" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
}
// =============================================
// ZUSÄTZLICHE PRODUKTE: Mehraufwand die NICHT im Auftrag sind
// (Produkte und Freitext die direkt als Mehraufwand hinzugefügt wurden)
// =============================================
// Alle Produkt-IDs aus dem Auftrag sammeln (für Ausschluss)
$orderProductIds = array();
$sqlOrderProds = "SELECT fk_product FROM ".MAIN_DB_PREFIX."commandedet WHERE fk_commande = ".((int)$order->id)." AND fk_product > 0";
$resOrderProds = $db->query($sqlOrderProds);
if ($resOrderProds) {
while ($objOP = $db->fetch_object($resOrderProds)) {
$orderProductIds[] = $objOP->fk_product;
}
}
// Mehraufwand und zusätzlich verbaute Produkte laden die NICHT im Auftrag sind
// 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,";
$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 .= " 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 .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlMehraufwand .= " AND (sp.origin = 'additional' OR (sp.origin = 'added' AND (sp.fk_commandedet IS NULL OR sp.fk_commandedet = 0)))";
if (!empty($orderProductIds)) {
$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 .= " 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;
if ($resqlMehr && $db->num_rows($resqlMehr) > 0) {
// Separator-Zeile für Mehraufwand
print '<tr class="liste_titre">';
print '<td class="stz-mehraufwand-header" colspan="2"><strong>'.$langs->trans("Mehraufwand").'</strong> <span class="opacitymedium">('.$langs->trans("MehraufwandDesc").')</span></td>';
print '</tr>';
while ($objMehr = $db->fetch_object($resqlMehr)) {
$hasMehraufwandProducts = true;
$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
// 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
$detailsMehr = array();
if ($objMehr->fk_product > 0) {
$prodKey = 'prod_'.$objMehr->fk_product;
if (isset($trackingDetails[$prodKey])) {
$detailsMehr = $trackingDetails[$prodKey];
}
} else {
// Freitext: Hash der Beschreibung als Key
$freetextKey = 'freetext_'.md5(trim($objMehr->description));
if (isset($trackingDetails[$freetextKey])) {
$detailsMehr = $trackingDetails[$freetextKey];
}
}
$hasDetailsMehr = !empty($detailsMehr);
print '<tr class="oddeven">';
// Produkt/Beschreibung
print '<td>';
if ($hasDetailsMehr) {
print '<span class="tracking-toggle" onclick="toggleTrackingDetails(\'tdetail_'.$detailRowId.'\')" style="cursor:pointer; margin-right:5px;">';
print '<span class="fas fa-chevron-right tracking-arrow" id="tarrow_tdetail_'.$detailRowId.'"></span>';
print '</span>';
}
print '<span class="badge badge-success" style="margin-right:5px;">'.$langs->trans("Mehraufwand").'</span>';
if ($objMehr->fk_product > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$objMehr->fk_product.'">';
print img_picto('', 'product', 'class="pictofixedwidth"');
print $objMehr->product_ref.' - '.$objMehr->product_label;
print '</a>';
} else {
// Freitext
print img_picto('', 'generic', 'class="pictofixedwidth"');
$desc = !empty($objMehr->description) ? strip_tags($objMehr->description) : '-';
if (strlen($desc) > 50) $desc = substr($desc, 0, 47).'...';
print '<span class="opacitymedium">'.$desc.'</span>';
}
print '</td>';
// Verbaut (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;
print '</tr>';
// Detail-Zeile für Mehraufwand
if ($hasDetailsMehr) {
print '<tr id="tdetail_'.$detailRowId.'" class="tracking-detail-row" style="display:none;">';
print '<td class="stz-subtable-bg" colspan="2" style="padding: 0 0 0 30px;">';
print '<table class="noborder" style="width:100%; margin: 5px 0;">';
print '<tr class="liste_titre stz-subtable-header">';
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("Type").'</th>';
print '<th class="right" style="width:80px;">'.$langs->trans("Qty").'</th>';
print '<th>'.$langs->trans("Reason").'</th>';
print '</tr>';
foreach ($detailsMehr as $det) {
print '<tr class="oddeven">';
print '<td><a href="'.dol_buildpath('/stundenzettel/card.php', 1).'?id='.$det->fk_stundenzettel.'">'.$det->stz_ref.'</a></td>';
print '<td>'.dol_print_date($db->jdate($det->date_stundenzettel), 'day').'</td>';
print '<td><span class="badge badge-success">'.$langs->trans("Mehraufwand").'</span></td>';
print '<td class="right">'.formatQty($det->qty_done).'</td>';
print '<td>'.((!empty($det->description)) ? dol_escape_htmltag($det->description) : '<span class="opacitymedium">-</span>').'</td>';
print '</tr>';
}
print '</table>';
print '</td>';
print '</tr>';
}
}
// Neue Summenzeile mit Mehraufwand
print '<tr class="liste_total">';
print '<td><strong>'.$langs->trans("Total").' ('.$langs->trans("Mehraufwand").' '.$langs->trans("incl").')</strong></td>';
print '<td class="right"><strong>'.formatQty($total_delivered).'</strong></td>';
print '</tr>';
}
print '</table>';
print '</div>';
// JavaScript für Tracking-Details
print '<script>
function toggleTrackingDetails(detailId) {
var detailRow = document.getElementById(detailId);
var arrow = document.getElementById("tarrow_" + detailId);
if (detailRow) {
var isHidden = detailRow.style.display === "none";
detailRow.style.display = isHidden ? "" : "none";
if (arrow) {
arrow.classList.toggle("fa-chevron-right", !isHidden);
arrow.classList.toggle("fa-chevron-down", isHidden);
}
}
}
function expandAllTrackingDetails() {
var detailRows = document.querySelectorAll(".tracking-detail-row");
detailRows.forEach(function(row) {
row.style.display = "";
var arrow = document.getElementById("tarrow_" + row.id);
if (arrow) {
arrow.classList.remove("fa-chevron-right");
arrow.classList.add("fa-chevron-down");
}
});
}
function collapseAllTrackingDetails() {
var detailRows = document.querySelectorAll(".tracking-detail-row");
detailRows.forEach(function(row) {
row.style.display = "none";
var arrow = document.getElementById("tarrow_" + row.id);
if (arrow) {
arrow.classList.remove("fa-chevron-down");
arrow.classList.add("fa-chevron-right");
}
});
}
</script>';
// =============================================
// BEREICH: LEISTUNGEN / ARBEITSZEITEN
// =============================================
print '<div class="fichehalfleft" style="margin-top: 20px;">';
print '<div class="titre inline-block">'.$langs->trans("Leistungen").' / '.$langs->trans("TotalHours").'</div>';
// Leistungen nach Leistungsposition gruppiert laden
$sqlLeistungen = "SELECT ";
$sqlLeistungen .= " COALESCE(l.fk_product, 0) as service_id,";
$sqlLeistungen .= " p.ref as service_ref, p.label as service_label,";
$sqlLeistungen .= " SUM(l.duration) as total_minutes,";
$sqlLeistungen .= " COUNT(l.rowid) as entry_count";
$sqlLeistungen .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung l";
$sqlLeistungen .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = l.fk_stundenzettel";
$sqlLeistungen .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = l.fk_product";
$sqlLeistungen .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlLeistungen .= " GROUP BY COALESCE(l.fk_product, 0), p.ref, p.label";
$sqlLeistungen .= " ORDER BY p.ref, p.label";
$resqlLeistungen = $db->query($sqlLeistungen);
$totalMinutesAll = 0;
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("DefaultService").'</th>';
print '<th class="right">'.$langs->trans("TotalHours").'</th>';
print '<th class="right">'.$langs->trans("Entries").'</th>';
print '</tr>';
if ($resqlLeistungen) {
$numLeistungen = $db->num_rows($resqlLeistungen);
if ($numLeistungen > 0) {
while ($objL = $db->fetch_object($resqlLeistungen)) {
$totalMinutesAll += $objL->total_minutes;
$hours = floor($objL->total_minutes / 60);
$mins = $objL->total_minutes % 60;
print '<tr class="oddeven">';
// Leistungsposition
print '<td>';
if ($objL->service_id > 0) {
print '<a href="'.DOL_URL_ROOT.'/product/card.php?id='.$objL->service_id.'">';
print img_picto('', 'service', 'class="pictofixedwidth"');
print $objL->service_ref.' - '.$objL->service_label;
print '</a>';
} else {
print '<span class="opacitymedium">'.img_picto('', 'service', 'class="pictofixedwidth"').$langs->trans("NotSet").'</span>';
}
print '</td>';
// Stunden
print '<td class="right"><strong>'.sprintf('%d:%02d h', $hours, $mins).'</strong></td>';
// Anzahl Einträge
print '<td class="right">'.$objL->entry_count.'</td>';
print '</tr>';
}
// Summenzeile
$totalHours = floor($totalMinutesAll / 60);
$totalMins = $totalMinutesAll % 60;
print '<tr class="liste_total">';
print '<td><strong>'.$langs->trans("Total").'</strong></td>';
print '<td class="right"><strong>'.sprintf('%d:%02d h', $totalHours, $totalMins).'</strong></td>';
print '<td class="right"></td>';
print '</tr>';
} else {
print '<tr class="oddeven"><td colspan="3" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
}
print '</table>';
print '</div>';
// Details pro Stundenzettel
print '<br>';
print '<div class="titre inline-block">'.$langs->trans("Leistungen").' '.$langs->trans("perStundenzettel").'</div>';
$sqlLeistDetail = "SELECT l.rowid, l.fk_stundenzettel, l.fk_product, l.date_leistung,";
$sqlLeistDetail .= " l.time_start, l.time_end, l.duration, l.description,";
$sqlLeistDetail .= " s.ref as stz_ref, s.date_stundenzettel,";
$sqlLeistDetail .= " p.ref as service_ref, p.label as service_label";
$sqlLeistDetail .= " FROM ".MAIN_DB_PREFIX."stundenzettel_leistung l";
$sqlLeistDetail .= " JOIN ".MAIN_DB_PREFIX."stundenzettel s ON s.rowid = l.fk_stundenzettel";
$sqlLeistDetail .= " LEFT JOIN ".MAIN_DB_PREFIX."product p ON p.rowid = l.fk_product";
$sqlLeistDetail .= " WHERE s.fk_commande = ".((int)$order->id);
$sqlLeistDetail .= " ORDER BY s.date_stundenzettel DESC, l.date_leistung, l.time_start";
$resqlLeistDetail = $db->query($sqlLeistDetail);
print '<div class="div-table-responsive-no-min">';
print '<table class="noborder centpercent">';
print '<tr class="liste_titre">';
print '<th>'.$langs->trans("Stundenzettel").'</th>';
print '<th>'.$langs->trans("Date").'</th>';
print '<th>'.$langs->trans("LeistungTimeStart").' - '.$langs->trans("LeistungTimeEnd").'</th>';
print '<th class="right">'.$langs->trans("LeistungDuration").'</th>';
print '<th>'.$langs->trans("DefaultService").'</th>';
print '<th>'.$langs->trans("Description").'</th>';
print '</tr>';
if ($resqlLeistDetail) {
$numDetail = $db->num_rows($resqlLeistDetail);
if ($numDetail > 0) {
while ($objLD = $db->fetch_object($resqlLeistDetail)) {
$hours = floor($objLD->duration / 60);
$mins = $objLD->duration % 60;
print '<tr class="oddeven">';
// Stundenzettel
print '<td>';
print '<a href="'.dol_buildpath('/stundenzettel/card.php', 1).'?id='.$objLD->fk_stundenzettel.'">';
print $objLD->stz_ref;
print '</a>';
print '</td>';
// Datum
print '<td>'.dol_print_date($db->jdate($objLD->date_leistung), 'day').'</td>';
// Zeit
print '<td>';
if ($objLD->time_start && $objLD->time_end) {
print substr($objLD->time_start, 0, 5).' - '.substr($objLD->time_end, 0, 5);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Dauer
print '<td class="right">'.sprintf('%d:%02d h', $hours, $mins).'</td>';
// Leistungsposition
print '<td>';
if ($objLD->fk_product > 0) {
print '<span class="badge badge-primary">'.$objLD->service_ref.'</span>';
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
// Beschreibung
print '<td>';
if (!empty($objLD->description)) {
$desc = strip_tags($objLD->description);
if (strlen($desc) > 50) $desc = substr($desc, 0, 47).'...';
print dol_escape_htmltag($desc);
} else {
print '<span class="opacitymedium">-</span>';
}
print '</td>';
print '</tr>';
}
} else {
print '<tr class="oddeven"><td colspan="6" class="opacitymedium">'.$langs->trans("NoRecordFound").'</td></tr>';
}
}
print '</table>';
print '</div>';
print '</div>'; // fichehalfleft
// Button: In Rechnung übertragen (nur wenn alles erledigt)
if ($total_remaining <= 0 && $total_delivered > 0) {
print '<div class="center clearboth" style="margin-top:20px;">';
print '<a class="butAction" href="?id='.$order->id.'&action=transfer_to_invoice">';
print img_picto('', 'bill', 'class="pictofixedwidth"').$langs->trans("TransferToInvoice");
print '</a>';
print '</div>';
}
}
print '</div>'; // fichecenter
dol_fiche_end();
llxFooter();
$db->close();