* * This program is free software; you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation; either version 3 of the License, or * (at your option) any later version. */ /** * \file produktverwaltung/produktverwaltungindex.php * \ingroup produktverwaltung * \brief Kategorie-Baumansicht mit Produkten und Inline-Editing */ // Load Dolibarr environment $res = 0; if (!$res && !empty($_SERVER["CONTEXT_DOCUMENT_ROOT"])) { $res = @include $_SERVER["CONTEXT_DOCUMENT_ROOT"]."/main.inc.php"; } $tmp = empty($_SERVER['SCRIPT_FILENAME']) ? '' : $_SERVER['SCRIPT_FILENAME']; $tmp2 = realpath(__FILE__); $i = strlen($tmp) - 1; $j = strlen($tmp2) - 1; while ($i > 0 && $j > 0 && isset($tmp[$i]) && isset($tmp2[$j]) && $tmp[$i] == $tmp2[$j]) { $i--; $j--; } if (!$res && $i > 0 && file_exists(substr($tmp, 0, ($i + 1))."/main.inc.php")) { $res = @include substr($tmp, 0, ($i + 1))."/main.inc.php"; } if (!$res && $i > 0 && file_exists(dirname(substr($tmp, 0, ($i + 1)))."/main.inc.php")) { $res = @include dirname(substr($tmp, 0, ($i + 1)))."/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.'/categories/class/categorie.class.php'; require_once DOL_DOCUMENT_ROOT.'/product/class/product.class.php'; require_once DOL_DOCUMENT_ROOT.'/core/lib/functions.lib.php'; $langs->loadLangs(array("produktverwaltung@produktverwaltung", "products")); // Security check if (!$user->hasRight('produktverwaltung', 'read')) { accessforbidden(); } $canEdit = $user->hasRight('produktverwaltung', 'write'); $canDelete = $user->hasRight('produktverwaltung', 'delete'); $canExport = $user->hasRight('produktverwaltung', 'export'); $defaultExpanded = getDolGlobalString('PRODUKTVERWALTUNG_DEFAULT_EXPANDED', '0'); // Check if preisbot_margin extrafield exists $hasMarginField = false; $sql = "SELECT name FROM ".MAIN_DB_PREFIX."extrafields WHERE elementtype = 'product' AND name = 'preisbot_margin'"; $resql = $db->query($sql); if ($resql && $db->num_rows($resql) > 0) { $hasMarginField = true; } // Load category tree $categorie = new Categorie($db); $fullTree = $categorie->get_full_arbo(Categorie::TYPE_PRODUCT); // Build category hierarchy $catChildren = array(); // parent_id => array of cat data $catData = array(); // cat_id => cat data if (is_array($fullTree)) { foreach ($fullTree as $cat) { $catData[$cat['id']] = $cat; $parentId = isset($cat['fk_parent']) ? (int) $cat['fk_parent'] : 0; if (!isset($catChildren[$parentId])) { $catChildren[$parentId] = array(); } $catChildren[$parentId][] = $cat; } } // Load all products with their category assignments $productsPerCat = array(); // cat_id => array of products $allProductIds = array(); $archivedProducts = array(); // tosell=0 AND tobuy=0 $sql = "SELECT DISTINCT p.rowid, p.ref, p.label, p.description, p.price, p.price_ttc,"; $sql .= " p.tobuy, p.tosell, p.fk_product_type"; $sql .= ", (SELECT pfp.unitprice FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp WHERE pfp.fk_product = p.rowid ORDER BY pfp.unitprice ASC LIMIT 1) as best_buy_price"; $sql .= ", (SELECT LEFT(COALESCE(NULLIF(s2.name_alias,''), s2.nom), 3) FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp2 LEFT JOIN ".MAIN_DB_PREFIX."societe as s2 ON s2.rowid = pfp2.fk_soc WHERE pfp2.fk_product = p.rowid ORDER BY pfp2.unitprice ASC LIMIT 1) as best_buy_supplier"; $sql .= ", (SELECT pfe3.kaufmenge FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp3 LEFT JOIN ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields as pfe3 ON pfe3.fk_object = pfp3.rowid WHERE pfp3.fk_product = p.rowid ORDER BY pfp3.unitprice ASC LIMIT 1) as best_buy_kaufmenge"; if ($hasMarginField) { $sql .= ", pe.preisbot_margin"; } $sql .= ", cp.fk_categorie"; $sql .= " FROM ".MAIN_DB_PREFIX."product as p"; $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."categorie_product as cp ON cp.fk_product = p.rowid"; if ($hasMarginField) { $sql .= " LEFT JOIN ".MAIN_DB_PREFIX."product_extrafields as pe ON pe.fk_object = p.rowid"; } $sql .= " WHERE p.entity IN (".getEntity('product').")"; $sql .= " ORDER BY p.ref ASC"; $resql = $db->query($sql); if ($resql) { while ($obj = $db->fetch_object($resql)) { $productData = array( 'id' => $obj->rowid, 'ref' => $obj->ref, 'label' => $obj->label, 'description' => $obj->description, 'price' => $obj->price, 'price_ttc' => $obj->price_ttc, 'best_buy_price' => $obj->best_buy_price, 'best_buy_supplier' => $obj->best_buy_supplier, 'best_buy_kaufmenge' => $obj->best_buy_kaufmenge, 'tosell' => $obj->tosell, 'tobuy' => $obj->tobuy, 'fk_product_type' => $obj->fk_product_type, 'margin' => $hasMarginField ? $obj->preisbot_margin : null, 'fk_categorie' => $obj->fk_categorie, ); $allProductIds[$obj->rowid] = true; // Archiv: weder verkaufbar noch beziehbar → separat sammeln if (empty($obj->tosell) && empty($obj->tobuy)) { $archivedProducts[$obj->rowid] = $productData; continue; } if (!empty($obj->fk_categorie)) { $catId = (int) $obj->fk_categorie; if (!isset($productsPerCat[$catId])) { $productsPerCat[$catId] = array(); } // Avoid duplicates (product can be in result multiple times if in multiple categories) $productsPerCat[$catId][$obj->rowid] = $productData; } } $db->free($resql); } // Products without category $productsWithoutCat = array(); $sql2 = "SELECT p.rowid, p.ref, p.label, p.description, p.price, p.price_ttc, p.tobuy, p.tosell, p.fk_product_type"; $sql2 .= ", (SELECT pfp.unitprice FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp WHERE pfp.fk_product = p.rowid ORDER BY pfp.unitprice ASC LIMIT 1) as best_buy_price"; $sql2 .= ", (SELECT LEFT(COALESCE(NULLIF(s2.name_alias,''), s2.nom), 3) FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp2 LEFT JOIN ".MAIN_DB_PREFIX."societe as s2 ON s2.rowid = pfp2.fk_soc WHERE pfp2.fk_product = p.rowid ORDER BY pfp2.unitprice ASC LIMIT 1) as best_buy_supplier"; $sql2 .= ", (SELECT pfe3.kaufmenge FROM ".MAIN_DB_PREFIX."product_fournisseur_price as pfp3 LEFT JOIN ".MAIN_DB_PREFIX."product_fournisseur_price_extrafields as pfe3 ON pfe3.fk_object = pfp3.rowid WHERE pfp3.fk_product = p.rowid ORDER BY pfp3.unitprice ASC LIMIT 1) as best_buy_kaufmenge"; if ($hasMarginField) { $sql2 .= ", pe.preisbot_margin"; } $sql2 .= " FROM ".MAIN_DB_PREFIX."product as p"; if ($hasMarginField) { $sql2 .= " LEFT JOIN ".MAIN_DB_PREFIX."product_extrafields as pe ON pe.fk_object = p.rowid"; } $sql2 .= " WHERE p.entity IN (".getEntity('product').")"; $sql2 .= " AND p.rowid NOT IN (SELECT fk_product FROM ".MAIN_DB_PREFIX."categorie_product)"; $sql2 .= " ORDER BY p.ref ASC"; $resql2 = $db->query($sql2); if ($resql2) { while ($obj = $db->fetch_object($resql2)) { $productData2 = array( 'id' => $obj->rowid, 'ref' => $obj->ref, 'label' => $obj->label, 'description' => $obj->description, 'price' => $obj->price, 'price_ttc' => $obj->price_ttc, 'best_buy_price' => $obj->best_buy_price, 'best_buy_supplier' => $obj->best_buy_supplier, 'best_buy_kaufmenge' => $obj->best_buy_kaufmenge, 'tosell' => $obj->tosell, 'tobuy' => $obj->tobuy, 'fk_product_type' => $obj->fk_product_type, 'margin' => $hasMarginField ? $obj->preisbot_margin : null, ); // Archiv: weder verkaufbar noch beziehbar if (empty($obj->tosell) && empty($obj->tobuy)) { $archivedProducts[$obj->rowid] = $productData2; } else { $productsWithoutCat[$obj->rowid] = $productData2; } } $db->free($resql2); } /* * View */ // Pass config to JS $jsConfig = array( 'ajaxUrl' => dol_buildpath('/produktverwaltung/ajax/product_actions.php', 1), 'token' => newToken(), 'hasMargin' => $hasMarginField, ); $morejs = ''; $morejs .= ''; $moreCss = array('/produktverwaltung/css/produktverwaltung.css'); $moreJs = array('/produktverwaltung/js/produktverwaltung.js'); llxHeader($morejs, $langs->trans("ProduktVerwaltungArea"), '', '', 0, 0, $moreJs, $moreCss, '', 'mod-produktverwaltung page-index'); print load_fiche_titre($langs->trans("ProduktVerwaltungArea"), '', 'fa-sitemap'); // === Ref-Schema Infobox === $showSchema = getDolGlobalString('PRODUKTVERWALTUNG_SHOW_SCHEMA', '1'); if ($showSchema) { $refSchema = getDolGlobalString('PRODUKTVERWALTUNG_REF_SCHEMA', 'KAT-HER-[TYP-]SPEC[-SERIE]'); $refExample = getDolGlobalString('PRODUKTVERWALTUNG_REF_EXAMPLE', ''); print ''; } // === Toolbar === print '
'; print ''; print ''; // Add root category if ($canEdit) { print ''; } // Search print ''; // Export buttons if ($canExport) { print '
'; print ''; print ' '.$langs->trans("ExportCSV"); print ''; print ''; print ' '.$langs->trans("ExportPDF"); print ''; print '
'; } print '
'; // === Category Tree === print ''; // === Products without category === $countWithout = count($productsWithoutCat); if ($countWithout > 0) { print '
'; print '
'; print ''; print ' '.$langs->trans("ProductsWithoutCategory").' '.$countWithout.''; print '
'; $ncCollapsed = $defaultExpanded ? '' : ' collapsed'; print '
'; renderProductTable($productsWithoutCat, $hasMarginField, $canEdit, $canDelete, 0, $langs, $conf); print '
'; print '
'; } // === Archiv (tosell=0 AND tobuy=0) === $countArchived = count($archivedProducts); if ($countArchived > 0) { print '
'; print '
'; print ''; print ' '.$langs->trans("ArchivedProducts").' '.$countArchived.''; print '
'; print ''; print '
'; } llxFooter(); $db->close(); // ========== Helper Functions ========== /** * Render category tree recursively */ function renderCategoryTree($parentId, &$catChildren, &$catData, &$productsPerCat, $hasMarginField, $canEdit, $canDelete, $langs, $conf, $defaultExpanded = '0') { if (!isset($catChildren[$parentId])) { return; } foreach ($catChildren[$parentId] as $cat) { $catId = (int) $cat['id']; $hasChildren = isset($catChildren[$catId]); $products = isset($productsPerCat[$catId]) ? $productsPerCat[$catId] : array(); $productCount = count($products); // Count total products including subcategories $totalCount = countProductsRecursive($catId, $catChildren, $productsPerCat); $color = isset($cat['color']) ? $cat['color'] : ''; print '
  • '; // Category header print '
    '; if ($hasChildren || $productCount > 0) { $collapsedClass = $defaultExpanded ? '' : ' collapsed'; print ''; } else { print ''; } if (!empty($color)) { print ''; } print ''.dol_escape_htmltag($cat['label']).''; if ($totalCount > 0) { print '('.$totalCount.')'; } // Category action buttons if ($canEdit) { print ''; print ''; print ''; if ($canDelete) { print ''; } print ''; } print '
    '; // Children container if ($hasChildren || $productCount > 0) { $collapsedClass2 = $defaultExpanded ? '' : ' collapsed'; print '
    '; // Products in this category if ($productCount > 0) { renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $catId, $langs, $conf); } // Sub-categories if ($hasChildren) { print ''; } print '
    '; } print '
  • '; } } /** * Count products in category and all subcategories */ function countProductsRecursive($catId, &$catChildren, &$productsPerCat) { $count = isset($productsPerCat[$catId]) ? count($productsPerCat[$catId]) : 0; if (isset($catChildren[$catId])) { foreach ($catChildren[$catId] as $child) { $count += countProductsRecursive((int) $child['id'], $catChildren, $productsPerCat); } } return $count; } /** * Render product table for a category */ function renderProductTable($products, $hasMarginField, $canEdit, $canDelete, $categoryId, $langs, $conf) { if (empty($products)) { return; } print ''; print ''; print ''; print ''; print ''; print ''; print ''; print ''; if ($hasMarginField) { print ''; } print ''; print ''; print ''; print ''; foreach ($products as $prod) { $productId = (int) $prod['id']; $ref = dol_escape_htmltag($prod['ref']); $label = dol_escape_htmltag($prod['label']); $description = dol_escape_htmltag(dol_trunc($prod['description'], 80)); $bestBuyPrice = !empty($prod['best_buy_price']) ? $prod['best_buy_price'] : 0; $sellPrice = $prod['price']; $kaufmenge = !empty($prod['best_buy_kaufmenge']) ? (float) $prod['best_buy_kaufmenge'] : 0; // kaufmenge-Korrektur: Wenn kaufmenge > 1, wird EK * kaufmenge als korrigierter EK pro VK-Einheit genutzt $correctedBuyPrice = $bestBuyPrice; if ($kaufmenge > 1 && $bestBuyPrice > 0) { $correctedBuyPrice = $bestBuyPrice * $kaufmenge; } // Calculate actual margin: ((VK - EK) / EK * 100) $calcMargin = ''; if ($correctedBuyPrice > 0 && $sellPrice > 0) { $calcMargin = round(($sellPrice - $correctedBuyPrice) / $correctedBuyPrice * 100, 1); } print ''; // Status (tosell/tobuy) - nur aktive anzeigen print ''; // Ref (editable) print ''; // Label (editable) print ''; // Description (editable) print ''; // Best EK (günstigster Lieferantenpreis, bei kaufmenge > 1 korrigiert) $displayBuyPrice = $correctedBuyPrice; print ''; // VK netto print ''; // Stored Margin % (preisbot_margin) - editierbar per Doppelklick if ($hasMarginField) { print ''; } // Calculated Margin % (nur Anzeige, nicht editierbar) print ''; // Actions print ''; print ''; } print '
    '.$langs->trans("Ref").''.$langs->trans("Label").''.$langs->trans("Description").''.$langs->trans("BestBuyPrice").''.$langs->trans("SellingPriceNet").''.$langs->trans("MarginPercent").''.$langs->trans("CalcMargin").''.$langs->trans("Actions").'
    '; if (!empty($prod['tosell'])) { print ''; } if (!empty($prod['tobuy'])) { print ''; } print ''; if ($canEdit) { print ''.$ref.''; } else { print $ref; } print ''; if ($canEdit) { print ''.$label.''; } else { print $label; } print ''; if ($canEdit) { print ''.$description.''; } else { print $description; } print ''; if ($bestBuyPrice > 0) { print price($displayBuyPrice, 0, $langs, 1, -1, 2); if (!empty($prod['best_buy_supplier'])) { print ' '.dol_escape_htmltag($prod['best_buy_supplier']).''; } if ($kaufmenge > 1) { print ' ×'.(int)$kaufmenge.''; } } else { print '-'; } print ''; print $sellPrice > 0 ? price($sellPrice, 0, $langs, 1, -1, 2) : '-'; print ''; $marginVal = !empty($prod['margin']) ? price($prod['margin'], 0, $langs, 1, -1, 1).'%' : '-'; if ($canEdit) { print ''.$marginVal.''; } else { print $marginVal; } print ''; if ($calcMargin !== '') { $marginClass = ''; $warnIcon = ''; // Extreme Margen: < 0% oder > 500% → rot mit Warnsymbol if ((float) $calcMargin < 0 || (float) $calcMargin > 500) { $marginClass = ' pv-margin-extreme'; $warnIcon = ' '; } elseif ($hasMarginField && !empty($prod['margin'])) { $diff = abs((float) $calcMargin - (float) $prod['margin']); $marginClass = $diff > 2 ? ' pv-margin-warn' : ' pv-margin-ok'; } print ''.$warnIcon.price($calcMargin, 0, $langs, 1, -1, 1).'%'; } else { print '-'; } print ''; // Edit dialog if ($canEdit) { print ''; } // Open in Dolibarr (dedicated popup window) print ''; print ''; print ''; // Assign category if ($canEdit) { print ''; } // Remove from category if ($canEdit && $categoryId > 0) { print ''; } // Delete if ($canDelete) { print ''; } print '
    '; }