- 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>
3206 lines
160 KiB
PHP
Executable file
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¬e_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();
|