PWA (neue Dateien): - Vollständige Progressive Web App mit Token-basierter Auth - 4 Swipe-Panels: Alle STZ, Stundenzettel, Produktliste, Lieferauflistung - Kundensuche, Leistungen-Accordion, Mehraufwand-Sektion - Produkt-Übernahme aus Auftrag + Mehraufwand in STZ - Service Worker, Manifest, App-Icons für Installation Desktop-Änderungen: - Produktliste: Checkboxen immer sichtbar (außer bereits auf STZ) - Lieferauflistung: Vereinfachte Ansicht (nur Verbaut-Spalte) - Admin: PWA-Link in Einstellungen - Sprachdatei: PWA-Übersetzungen Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
408 lines
16 KiB
PHP
Executable file
408 lines
16 KiB
PHP
Executable file
<?php
|
|
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
|
|
*
|
|
* Stundenzettel - Library functions
|
|
*/
|
|
|
|
/**
|
|
* Prepare array of tabs for Stundenzettel card
|
|
*
|
|
* @param Stundenzettel $object Object
|
|
* @return array Array of tabs
|
|
*/
|
|
function stundenzettel_prepare_head($object)
|
|
{
|
|
global $db, $langs, $conf, $user;
|
|
|
|
$langs->load("stundenzettel@stundenzettel");
|
|
|
|
$h = 0;
|
|
$head = array();
|
|
|
|
// Tab 1: Kundenauftrag (Link zum Auftrag) - immer am Anfang
|
|
if ($object->fk_commande > 0) {
|
|
$head[$h][0] = DOL_URL_ROOT.'/commande/card.php?id='.$object->fk_commande;
|
|
$head[$h][1] = $langs->trans("Order");
|
|
$head[$h][2] = 'order';
|
|
$h++;
|
|
}
|
|
|
|
// Tab 2: Produktliste (Link zu stundenzettel_commande.php mit Produktliste aus Auftrag)
|
|
if ($object->fk_commande > 0) {
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$object->fk_commande.'&tab=products&noredirect=1&stundenzettel_id='.$object->id;
|
|
$head[$h][1] = $langs->trans("ProductList");
|
|
$head[$h][2] = 'productlist';
|
|
$h++;
|
|
}
|
|
|
|
// Tab 3: Stundenzettel (Link zum aktiven Stundenzettel - card.php)
|
|
$nbLeistungen = 0;
|
|
if (!empty($object->leistungen)) {
|
|
$nbLeistungen = count($object->leistungen);
|
|
}
|
|
$nbProducts = 0;
|
|
if (!empty($object->products)) {
|
|
$nbProducts = count($object->products);
|
|
}
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/card.php', 1).'?id='.$object->id;
|
|
$head[$h][1] = $langs->trans("Stundenzettel");
|
|
$totalItems = $nbLeistungen + $nbProducts;
|
|
if ($totalItems > 0) {
|
|
$head[$h][1] .= '<span class="badge marginleftonlyshort">'.$totalItems.'</span>';
|
|
}
|
|
$head[$h][2] = 'card';
|
|
$h++;
|
|
|
|
// Tab 4: Alle Stundenzettel (Liste aller Stundenzettel für diesen Auftrag)
|
|
if ($object->fk_commande > 0) {
|
|
// Anzahl Stundenzettel für diesen Auftrag zählen
|
|
$sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."stundenzettel WHERE fk_commande = ".((int)$object->fk_commande);
|
|
$resql = $db->query($sql);
|
|
$nbStundenzettel = 0;
|
|
if ($resql && ($obj = $db->fetch_object($resql))) {
|
|
$nbStundenzettel = $obj->nb;
|
|
}
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$object->fk_commande.'&tab=stundenzettel&noredirect=1&stundenzettel_id='.$object->id;
|
|
$head[$h][1] = $langs->trans("StundenzettelList");
|
|
if ($nbStundenzettel > 0) {
|
|
$head[$h][1] .= '<span class="badge marginleftonlyshort">'.$nbStundenzettel.'</span>';
|
|
}
|
|
$head[$h][2] = 'stundenzettel_list';
|
|
$h++;
|
|
}
|
|
|
|
// Tab 5: Lieferauflistung (Tracking)
|
|
if ($object->fk_commande > 0) {
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$object->fk_commande.'&tab=tracking&noredirect=1&stundenzettel_id='.$object->id;
|
|
$head[$h][1] = $langs->trans("DeliveryTracking");
|
|
$head[$h][2] = 'tracking';
|
|
$h++;
|
|
}
|
|
|
|
// Tab 6: Notizen
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/card.php', 1).'?id='.$object->id.'&tab=notes';
|
|
$head[$h][1] = $langs->trans("Notes");
|
|
$head[$h][2] = 'notes';
|
|
$h++;
|
|
|
|
return $head;
|
|
}
|
|
|
|
/**
|
|
* Prepare array of tabs for Stundenzettel Commande page (order-level view)
|
|
* Diese Tabs werden immer angezeigt, unabhängig davon ob ein Stundenzettel ausgewählt ist
|
|
*
|
|
* @param Commande $order Das Auftrags-Objekt
|
|
* @param int $stundenzettel_id Optional: ID des ausgewählten Stundenzettels
|
|
* @return array Array of tabs
|
|
*/
|
|
function stundenzettel_commande_prepare_head($order, $stundenzettel_id = 0)
|
|
{
|
|
global $db, $langs, $conf, $user;
|
|
|
|
$langs->load("stundenzettel@stundenzettel");
|
|
|
|
$h = 0;
|
|
$head = array();
|
|
|
|
// Tab 1: Kundenauftrag (Link zurück zum Auftrag)
|
|
$head[$h][0] = DOL_URL_ROOT.'/commande/card.php?id='.$order->id;
|
|
$head[$h][1] = $langs->trans("Order");
|
|
$head[$h][2] = 'order';
|
|
$h++;
|
|
|
|
// Tab 2: Produktliste
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$order->id.'&tab=products&noredirect=1'.($stundenzettel_id > 0 ? '&stundenzettel_id='.$stundenzettel_id : '');
|
|
$head[$h][1] = $langs->trans("ProductList");
|
|
$head[$h][2] = 'products';
|
|
$h++;
|
|
|
|
// Tab 3: Stundenzettel (aktueller/ausgewählter Stundenzettel - card.php)
|
|
// Wenn kein Stundenzettel ausgewählt, den letzten offenen für diesen Auftrag suchen
|
|
$activeStundenzettelId = $stundenzettel_id;
|
|
if ($activeStundenzettelId <= 0) {
|
|
$sqlActive = "SELECT rowid FROM ".MAIN_DB_PREFIX."stundenzettel";
|
|
$sqlActive .= " WHERE fk_commande = ".((int)$order->id);
|
|
$sqlActive .= " AND status = 0"; // Nur Entwürfe
|
|
$sqlActive .= " ORDER BY date_stundenzettel DESC, rowid DESC";
|
|
$sqlActive .= " LIMIT 1";
|
|
$resqlActive = $db->query($sqlActive);
|
|
if ($resqlActive && $db->num_rows($resqlActive) > 0) {
|
|
$objActive = $db->fetch_object($resqlActive);
|
|
$activeStundenzettelId = $objActive->rowid;
|
|
}
|
|
}
|
|
|
|
if ($activeStundenzettelId > 0) {
|
|
// Lade Stundenzettel für Badge-Berechnung
|
|
require_once dol_buildpath('/stundenzettel/class/stundenzettel.class.php', 0);
|
|
$tmpStz = new Stundenzettel($db);
|
|
$tmpStz->fetch($activeStundenzettelId);
|
|
$tmpStz->fetchLeistungen();
|
|
$tmpStz->fetchProducts();
|
|
|
|
$nbItems = count($tmpStz->leistungen) + count($tmpStz->products);
|
|
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/card.php', 1).'?id='.$activeStundenzettelId;
|
|
$head[$h][1] = $langs->trans("Stundenzettel");
|
|
if ($nbItems > 0) {
|
|
$head[$h][1] .= '<span class="badge marginleftonlyshort">'.$nbItems.'</span>';
|
|
}
|
|
$head[$h][2] = 'card';
|
|
$h++;
|
|
}
|
|
|
|
// Tab 4: Alle Stundenzettel (Liste aller Stundenzettel für diesen Auftrag)
|
|
$sql = "SELECT COUNT(*) as nb FROM ".MAIN_DB_PREFIX."stundenzettel WHERE fk_commande = ".((int)$order->id);
|
|
$resql = $db->query($sql);
|
|
$nbStundenzettel = 0;
|
|
if ($resql && ($obj = $db->fetch_object($resql))) {
|
|
$nbStundenzettel = $obj->nb;
|
|
}
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$order->id.'&tab=stundenzettel&noredirect=1'.($stundenzettel_id > 0 ? '&stundenzettel_id='.$stundenzettel_id : '');
|
|
$head[$h][1] = $langs->trans("StundenzettelList");
|
|
if ($nbStundenzettel > 0) {
|
|
$head[$h][1] .= '<span class="badge marginleftonlyshort">'.$nbStundenzettel.'</span>';
|
|
}
|
|
$head[$h][2] = 'stundenzettel';
|
|
$h++;
|
|
|
|
// Tab 4: Lieferauflistung (Tracking)
|
|
$head[$h][0] = dol_buildpath('/stundenzettel/stundenzettel_commande.php', 1).'?id='.$order->id.'&tab=tracking&noredirect=1'.($stundenzettel_id > 0 ? '&stundenzettel_id='.$stundenzettel_id : '');
|
|
$head[$h][1] = $langs->trans("DeliveryTracking");
|
|
$head[$h][2] = 'tracking';
|
|
$h++;
|
|
|
|
return $head;
|
|
}
|
|
|
|
/**
|
|
* Holt den kundenspezifischen Preis für ein Produkt
|
|
* Falls kein kundenspezifischer Preis existiert, wird der Standardpreis zurückgegeben
|
|
*
|
|
* @param DoliDB $db Datenbankverbindung
|
|
* @param int $fk_product Produkt-ID
|
|
* @param int $fk_soc Kunden-ID (Societe)
|
|
* @param Product|null $product Optional: bereits geladenes Produkt-Objekt
|
|
* @return array Array mit 'price' (HT), 'price_ttc', 'tva_tx', 'price_base_type', 'is_customer_price'
|
|
*/
|
|
function getCustomerPrice($db, $fk_product, $fk_soc, $product = null) {
|
|
global $conf;
|
|
|
|
$now = dol_now();
|
|
|
|
// Suche kundenspezifischen Preis in der Tabelle product_customer_price
|
|
$sql = "SELECT price, price_ttc, tva_tx, price_base_type";
|
|
$sql .= " FROM ".MAIN_DB_PREFIX."product_customer_price";
|
|
$sql .= " WHERE fk_product = ".((int)$fk_product);
|
|
$sql .= " AND fk_soc = ".((int)$fk_soc);
|
|
$sql .= " AND entity IN (".getEntity('productprice').")";
|
|
// Prüfe Gültigkeitszeitraum (date_begin <= now und (date_end IS NULL oder date_end >= now))
|
|
$sql .= " AND date_begin <= '".$db->idate($now)."'";
|
|
$sql .= " AND (date_end IS NULL OR date_end >= '".$db->idate($now)."')";
|
|
$sql .= " ORDER BY date_begin DESC";
|
|
$sql .= " LIMIT 1";
|
|
|
|
$resql = $db->query($sql);
|
|
if ($resql && $db->num_rows($resql) > 0) {
|
|
$obj = $db->fetch_object($resql);
|
|
return array(
|
|
'price' => (float)$obj->price,
|
|
'price_ttc' => (float)$obj->price_ttc,
|
|
'tva_tx' => (float)$obj->tva_tx,
|
|
'price_base_type' => $obj->price_base_type,
|
|
'is_customer_price' => true
|
|
);
|
|
}
|
|
|
|
// Kein kundenspezifischer Preis gefunden - lade Standardpreis vom Produkt
|
|
if ($product === null) {
|
|
require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php';
|
|
$product = new Product($db);
|
|
$product->fetch($fk_product);
|
|
}
|
|
|
|
return array(
|
|
'price' => (float)$product->price,
|
|
'price_ttc' => (float)$product->price_ttc,
|
|
'tva_tx' => (float)$product->tva_tx,
|
|
'price_base_type' => $product->price_base_type,
|
|
'is_customer_price' => false
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Holt den effektiven Stundenpreis für einen Stundenzettel
|
|
* Berücksichtigt: 1. Manuell gesetzter Preis im Stundenzettel
|
|
* 2. Kundenspezifischer Preis
|
|
* 3. Standard-Produktpreis
|
|
*
|
|
* @param DoliDB $db Datenbankverbindung
|
|
* @param Stundenzettel $stundenzettel Das Stundenzettel-Objekt
|
|
* @param int $defaultServiceId ID der Standard-Leistung
|
|
* @return array Array mit 'price', 'source' ('custom', 'customer', 'standard')
|
|
*/
|
|
function getEffectiveHourlyRate($db, $stundenzettel, $defaultServiceId) {
|
|
// 1. Prüfe ob manueller Preis im Stundenzettel gesetzt
|
|
if ($stundenzettel->hourly_rate_is_custom && $stundenzettel->hourly_rate !== null) {
|
|
return array(
|
|
'price' => (float)$stundenzettel->hourly_rate,
|
|
'source' => 'custom'
|
|
);
|
|
}
|
|
|
|
// 2. Hole kundenspezifischen oder Standard-Preis
|
|
if ($defaultServiceId > 0) {
|
|
$priceInfo = getCustomerPrice($db, $defaultServiceId, $stundenzettel->fk_soc);
|
|
return array(
|
|
'price' => $priceInfo['price'],
|
|
'source' => $priceInfo['is_customer_price'] ? 'customer' : 'standard'
|
|
);
|
|
}
|
|
|
|
// 3. Kein Preis verfügbar
|
|
return array(
|
|
'price' => 0,
|
|
'source' => 'none'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Berechnet den Netto-Wert aller Stundenzettel eines Auftrags
|
|
* und aktualisiert das Extrafield stundenzettel_netto
|
|
*
|
|
* @param DoliDB $db Datenbankverbindung
|
|
* @param int $fk_commande Auftrags-ID
|
|
* @return float Der berechnete Netto-Wert
|
|
*/
|
|
function updateOrderNettoSTZ($db, $fk_commande) {
|
|
global $conf;
|
|
|
|
if (empty($fk_commande)) {
|
|
dol_syslog("updateOrderNettoSTZ: fk_commande is empty", LOG_WARNING);
|
|
return 0;
|
|
}
|
|
|
|
$totalNetto = 0;
|
|
|
|
// Hole Kunden-ID und Standard-Leistung ZUERST (vor der Stundenzettel-Schleife)
|
|
$defaultServiceId = 0;
|
|
$socid = 0;
|
|
|
|
// Hole Kunden-ID vom Auftrag
|
|
$sqlOrder = "SELECT fk_soc FROM ".MAIN_DB_PREFIX."commande WHERE rowid = ".((int)$fk_commande);
|
|
$resqlOrder = $db->query($sqlOrder);
|
|
if ($resqlOrder && ($objOrder = $db->fetch_object($resqlOrder))) {
|
|
$socid = (int)$objOrder->fk_soc;
|
|
|
|
// Hole Standard-Leistung vom Kunden (Extrafield)
|
|
$sqlService = "SELECT stundenzettel_default_service FROM ".MAIN_DB_PREFIX."societe_extrafields";
|
|
$sqlService .= " WHERE fk_object = ".((int)$socid);
|
|
$resqlService = $db->query($sqlService);
|
|
if ($resqlService && ($objService = $db->fetch_object($resqlService))) {
|
|
$defaultServiceId = (int)$objService->stundenzettel_default_service;
|
|
}
|
|
}
|
|
|
|
dol_syslog("updateOrderNettoSTZ: commande=".$fk_commande.", socid=".$socid.", defaultServiceId=".$defaultServiceId, LOG_DEBUG);
|
|
|
|
// 1. Alle freigegebenen Stundenzettel des Auftrags laden (status >= 1 = validiert)
|
|
$sqlStz = "SELECT s.rowid, s.fk_soc, s.hourly_rate, s.hourly_rate_is_custom";
|
|
$sqlStz .= " FROM ".MAIN_DB_PREFIX."stundenzettel as s";
|
|
$sqlStz .= " WHERE s.fk_commande = ".((int)$fk_commande);
|
|
$sqlStz .= " AND s.status >= 1"; // Nur validierte/freigegebene Stundenzettel
|
|
|
|
$resqlStz = $db->query($sqlStz);
|
|
if (!$resqlStz) {
|
|
dol_syslog("updateOrderNettoSTZ: SQL error: ".$db->lasterror(), LOG_ERR);
|
|
return 0;
|
|
}
|
|
|
|
$numStz = $db->num_rows($resqlStz);
|
|
dol_syslog("updateOrderNettoSTZ: Found ".$numStz." validated Stundenzettel", LOG_DEBUG);
|
|
|
|
while ($stz = $db->fetch_object($resqlStz)) {
|
|
// 2. Produkte dieses Stundenzettels summieren
|
|
$sqlProd = "SELECT sp.fk_product, sp.fk_commandedet, sp.qty_done, sp.origin,";
|
|
$sqlProd .= " cd.subprice as order_price, cd.tva_tx";
|
|
$sqlProd .= " FROM ".MAIN_DB_PREFIX."stundenzettel_product as sp";
|
|
$sqlProd .= " LEFT JOIN ".MAIN_DB_PREFIX."commandedet as cd ON cd.rowid = sp.fk_commandedet";
|
|
$sqlProd .= " WHERE sp.fk_stundenzettel = ".((int)$stz->rowid);
|
|
$sqlProd .= " AND sp.origin IN ('order', 'added', 'extra')"; // Nicht 'omitted' (entfällt)
|
|
|
|
$resqlProd = $db->query($sqlProd);
|
|
if ($resqlProd) {
|
|
while ($prod = $db->fetch_object($resqlProd)) {
|
|
$price = 0;
|
|
$qty = (float)$prod->qty_done;
|
|
|
|
if ($prod->fk_commandedet > 0 && $prod->order_price > 0) {
|
|
// Preis aus Auftragszeile
|
|
$price = (float)$prod->order_price;
|
|
} elseif ($prod->fk_product > 0) {
|
|
// Kundenspezifischer oder Standard-Preis
|
|
$priceInfo = getCustomerPrice($db, $prod->fk_product, $socid);
|
|
$price = $priceInfo['price'];
|
|
}
|
|
|
|
$totalNetto += $price * $qty;
|
|
}
|
|
}
|
|
|
|
// 3. Leistungen (Arbeitsstunden) - JEDE Zeile einzeln mit ihrer gewählten Leistungsposition
|
|
$sqlHours = "SELECT l.duration, l.fk_product FROM ".MAIN_DB_PREFIX."stundenzettel_leistung l";
|
|
$sqlHours .= " WHERE l.fk_stundenzettel = ".((int)$stz->rowid);
|
|
|
|
$resqlHours = $db->query($sqlHours);
|
|
if ($resqlHours) {
|
|
while ($leistung = $db->fetch_object($resqlHours)) {
|
|
$minutes = (float)$leistung->duration;
|
|
$hoursWorked = $minutes / 60;
|
|
|
|
if ($hoursWorked > 0) {
|
|
$hourlyRate = 0;
|
|
|
|
// Priorität: 1. Leistungsposition der Zeile, 2. Stundenzettel-Rate, 3. Kunden-Standard
|
|
if ($leistung->fk_product > 0) {
|
|
// Preis der gewählten Leistungsposition (mit Kundenpreis falls vorhanden)
|
|
$priceInfo = getCustomerPrice($db, $leistung->fk_product, $socid);
|
|
$hourlyRate = $priceInfo['price'];
|
|
dol_syslog("updateOrderNettoSTZ: Leistung fk_product=".$leistung->fk_product." price=".$hourlyRate, LOG_DEBUG);
|
|
} elseif ($stz->hourly_rate > 0) {
|
|
// Fallback: Manueller Preis im Stundenzettel
|
|
$hourlyRate = (float)$stz->hourly_rate;
|
|
dol_syslog("updateOrderNettoSTZ: Using STZ hourly_rate=".$hourlyRate, LOG_DEBUG);
|
|
} elseif ($defaultServiceId > 0) {
|
|
// Fallback: Standard-Leistung des Kunden
|
|
$priceInfo = getCustomerPrice($db, $defaultServiceId, $socid);
|
|
$hourlyRate = $priceInfo['price'];
|
|
dol_syslog("updateOrderNettoSTZ: Using defaultService price=".$hourlyRate, LOG_DEBUG);
|
|
} else {
|
|
dol_syslog("updateOrderNettoSTZ: No price for leistung! fk_product=".$leistung->fk_product, LOG_WARNING);
|
|
}
|
|
|
|
$subtotal = $hourlyRate * $hoursWorked;
|
|
$totalNetto += $subtotal;
|
|
dol_syslog("updateOrderNettoSTZ: subtotal=".$subtotal." (rate=".$hourlyRate." x hours=".$hoursWorked.")", LOG_DEBUG);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dol_syslog("updateOrderNettoSTZ: TOTAL=".$totalNetto, LOG_DEBUG);
|
|
|
|
// 4. Extrafield aktualisieren
|
|
$sqlUpdate = "UPDATE ".MAIN_DB_PREFIX."commande_extrafields";
|
|
$sqlUpdate .= " SET stundenzettel_netto = ".((float)$totalNetto);
|
|
$sqlUpdate .= " WHERE fk_object = ".((int)$fk_commande);
|
|
|
|
$resqlUpdate = $db->query($sqlUpdate);
|
|
if (!$resqlUpdate || $db->affected_rows($resqlUpdate) == 0) {
|
|
// Zeile existiert noch nicht - INSERT
|
|
$sqlInsert = "INSERT INTO ".MAIN_DB_PREFIX."commande_extrafields (fk_object, stundenzettel_netto)";
|
|
$sqlInsert .= " VALUES (".((int)$fk_commande).", ".((float)$totalNetto).")";
|
|
$db->query($sqlInsert);
|
|
}
|
|
|
|
return $totalNetto;
|
|
}
|