buchhaltungswidget/core/boxes/box_rentabilitaet.php
data db82df2234 Stabile 1.0 Version Firmen Statistik Widgets und
Beurteilung der Rechnungsfristen auf der Kundenkarte
2026-01-30 11:41:02 +01:00

409 lines
16 KiB
PHP

<?php
/* Copyright (C) 2026 Eduard Wisch <data@data-it-solution.de>
*
* 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 htdocs/custom/buchaltungswidget/core/boxes/box_rentabilitaet.php
* \ingroup buchaltungswidget
* \brief Widget showing profitability analysis - materials purchased vs invoiced
*/
include_once DOL_DOCUMENT_ROOT.'/core/boxes/modules_boxes.php';
/**
* Class to manage the profitability analysis widget
*/
class box_rentabilitaet extends ModeleBoxes
{
public $boxcode = "rentabilitaet";
public $boximg = "accountancy";
public $boxlabel = "Rentabilitaet";
public $depends = array("facture", "fournisseur");
/**
* Constructor
*/
public function __construct($db, $param = '')
{
global $user;
$this->db = $db;
$this->hidden = !($user->hasRight('facture', 'lire') || $user->hasRight('fournisseur', 'facture', 'lire'));
}
/**
* Load data into info_box_contents array to show a widget
*/
public function loadBox($max = 5)
{
global $conf, $langs, $user;
$langs->loadLangs(array("buchaltungswidget@buchaltungswidget", "bills", "compta"));
$this->info_box_head = array(
'text' => $langs->trans("Rentabilitaet"),
'sublink' => dol_buildpath('/buchaltungswidget/rentabilitaet_detail.php', 1),
'subpicto' => 'chart',
'subtext' => $langs->trans("ShowDetails"),
'limit' => 0,
'graph' => false,
'nbcol' => 4,
);
if (!$user->hasRight('facture', 'lire') && !$user->hasRight('fournisseur', 'facture', 'lire')) {
$this->info_box_contents[0][0] = array(
'td' => 'class="center"',
'text' => $langs->trans("ReadPermissionNotAllowed"),
);
return;
}
$currentYear = date('Y');
$lastYear = $currentYear - 1;
$nextYear = $currentYear + 1;
$currentMonth = date('n');
// Get profitability data for all years
$dataCurrentYear = $this->getProfitabilityByMonth($currentYear);
$dataLastYear = $this->getProfitabilityByMonth($lastYear);
// Calculate projection
$projectionNextYear = $this->calculateProjection($dataCurrentYear, $dataLastYear);
// Build the output
$this->info_box_contents = array();
$line = 0;
// Mini chart area - dual axis bar/line chart
$chartId = 'rentabilitaet_chart_'.uniqid();
$chartData = $this->prepareChartData($dataCurrentYear, $dataLastYear, $currentYear, $lastYear);
$this->info_box_contents[$line][] = array(
'td' => 'colspan="4" class="buchaltung-chart-container"',
'text' => '<canvas id="'.$chartId.'" height="140"></canvas>
<script>
document.addEventListener("DOMContentLoaded", function() {
if (typeof Chart !== "undefined") {
var ctx = document.getElementById("'.$chartId.'").getContext("2d");
new Chart(ctx, {
type: "bar",
data: {
labels: '.json_encode($chartData['labels']).',
datasets: [{
type: "line",
label: "'.$langs->trans("ProfitMargin").' %",
data: '.json_encode($chartData['marginPercent']).',
borderColor: "rgba(40, 167, 69, 1)",
backgroundColor: "transparent",
yAxisID: "y1",
tension: 0.3,
pointRadius: 4,
pointBackgroundColor: '.json_encode($chartData['marginColors']).'
}, {
label: "'.$langs->trans("MaterialsPurchased").'",
data: '.json_encode($chartData['purchased']).',
backgroundColor: "rgba(220, 53, 69, 0.6)",
yAxisID: "y"
}, {
label: "'.$langs->trans("MaterialsServicesInvoiced").'",
data: '.json_encode($chartData['invoiced']).',
backgroundColor: "rgba(0, 123, 255, 0.6)",
yAxisID: "y"
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: "bottom", labels: { boxWidth: 10, font: { size: 9 }, color: "#444" } },
tooltip: {
callbacks: {
label: function(context) {
if (context.dataset.yAxisID === "y1") {
return context.dataset.label + ": " + context.raw + "%";
}
return context.dataset.label + ": " + new Intl.NumberFormat("de-DE", {style: "currency", currency: "EUR"}).format(context.raw);
}
}
}
},
scales: {
y: {
type: "linear",
position: "left",
beginAtZero: true,
ticks: { font: { size: 8 }, color: "#555" },
grid: { color: "rgba(255, 255, 255, 0.8)", lineWidth: 1 },
border: { color: "rgba(255, 255, 255, 1)" }
},
y1: {
type: "linear",
position: "right",
beginAtZero: true,
grid: { drawOnChartArea: false },
ticks: { font: { size: 8 }, color: "#555", callback: function(value) { return value + "%"; } },
border: { color: "rgba(255, 255, 255, 1)" }
},
x: {
ticks: { font: { size: 9 }, color: "#555" },
grid: { color: "rgba(255, 255, 255, 0.6)" },
border: { color: "rgba(255, 255, 255, 1)" }
}
}
}
});
}
});
</script>',
'asis' => 1,
);
$line++;
// Summary table header
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-header"', 'text' => '');
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-header right"', 'text' => $lastYear);
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-header right buchaltung-current-quarter"', 'text' => $currentYear);
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-header right buchaltung-future"', 'text' => $nextYear.' *');
$line++;
// Materials purchased (only for customers!)
$purchasedLast = array_sum($dataLastYear['purchased']);
$purchasedCurrent = array_sum(array_slice($dataCurrentYear['purchased'], 0, $currentMonth, true));
$purchasedProjection = $projectionNextYear['purchased'];
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-label"', 'text' => $langs->trans("MaterialsPurchasedForCustomers"));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-lastyear"', 'text' => price($purchasedLast, 0, $langs, 1, 0, 0, $conf->currency));
$this->info_box_contents[$line][] = array('td' => 'class="right"', 'text' => price($purchasedCurrent, 0, $langs, 1, 0, 0, $conf->currency));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-future"', 'text' => price($purchasedProjection, 0, $langs, 1, 0, 0, $conf->currency));
$line++;
// Materials & Services invoiced
$invoicedLast = array_sum($dataLastYear['invoiced']);
$invoicedCurrent = array_sum(array_slice($dataCurrentYear['invoiced'], 0, $currentMonth, true));
$invoicedProjection = $projectionNextYear['invoiced'];
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-label"', 'text' => $langs->trans("MaterialsServicesInvoiced"));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-lastyear"', 'text' => price($invoicedLast, 0, $langs, 1, 0, 0, $conf->currency));
$this->info_box_contents[$line][] = array('td' => 'class="right"', 'text' => price($invoicedCurrent, 0, $langs, 1, 0, 0, $conf->currency));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-future"', 'text' => price($invoicedProjection, 0, $langs, 1, 0, 0, $conf->currency));
$line++;
// Gross profit
$grossProfitLast = $invoicedLast - $purchasedLast;
$grossProfitCurrent = $invoicedCurrent - $purchasedCurrent;
$grossProfitProjection = $invoicedProjection - $purchasedProjection;
$colorLast = $grossProfitLast >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$colorCurrent = $grossProfitCurrent >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$colorProjection = $grossProfitProjection >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-label"', 'text' => $langs->trans("GrossProfit"));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-lastyear '.$colorLast.'"', 'text' => price($grossProfitLast, 0, $langs, 1, 0, 0, $conf->currency));
$this->info_box_contents[$line][] = array('td' => 'class="right '.$colorCurrent.'"', 'text' => '<strong>'.price($grossProfitCurrent, 0, $langs, 1, 0, 0, $conf->currency).'</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-future '.$colorProjection.'"', 'text' => price($grossProfitProjection, 0, $langs, 1, 0, 0, $conf->currency));
$line++;
// Profit margin percentage
$marginLast = ($purchasedLast > 0) ? (($invoicedLast - $purchasedLast) / $purchasedLast) * 100 : 0;
$marginCurrent = ($purchasedCurrent > 0) ? (($invoicedCurrent - $purchasedCurrent) / $purchasedCurrent) * 100 : 0;
$marginProjection = ($purchasedProjection > 0) ? (($invoicedProjection - $purchasedProjection) / $purchasedProjection) * 100 : 0;
$colorLast = $marginLast >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$colorCurrent = $marginCurrent >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$colorProjection = $marginProjection >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-label buchaltung-profit-row"', 'text' => '<strong>'.$langs->trans("ProfitMargin").'</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-profit-row buchaltung-lastyear '.$colorLast.'"', 'text' => '<strong>'.number_format($marginLast, 1, ',', '.').' %</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-profit-row '.$colorCurrent.'"', 'text' => '<strong>'.number_format($marginCurrent, 1, ',', '.').' %</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-profit-row buchaltung-future '.$colorProjection.'"', 'text' => '<strong>'.number_format($marginProjection, 1, ',', '.').' %</strong>', 'asis' => 1);
$line++;
// Productivity rating
$rating = $this->getProductivityRating($marginCurrent);
$this->info_box_contents[$line][] = array(
'td' => 'colspan="4" class="buchaltung-rating"',
'text' => '<div class="buchaltung-rating-box '.$rating['class'].'">
<span class="rating-label">'.$langs->trans("ProductivityRating").':</span>
<span class="rating-value">'.$rating['text'].'</span>
<span class="rating-description">'.$rating['description'].'</span>
</div>',
'asis' => 1,
);
$line++;
// Footer note
$this->info_box_contents[$line][] = array(
'td' => 'colspan="4" class="buchaltung-footnote"',
'text' => '<small>* '.$langs->trans("StatisticalProjection").' | '.$langs->trans("OnlyCustomerMaterials").'</small>',
'asis' => 1,
);
}
/**
* Prepare chart data for monthly display
*/
private function prepareChartData($currentData, $lastData, $currentYear, $lastYear)
{
$labels = array('Jan', 'Feb', 'Mar', 'Apr', 'Mai', 'Jun', 'Jul', 'Aug', 'Sep', 'Okt', 'Nov', 'Dez');
$currentMonth = date('n');
$purchased = array();
$invoiced = array();
$marginPercent = array();
$marginColors = array();
for ($m = 1; $m <= 12; $m++) {
if ($m <= $currentMonth) {
$p = isset($currentData['purchased'][$m]) ? $currentData['purchased'][$m] : 0;
$i = isset($currentData['invoiced'][$m]) ? $currentData['invoiced'][$m] : 0;
$purchased[] = round($p, 2);
$invoiced[] = round($i, 2);
$margin = ($p > 0) ? round((($i - $p) / $p) * 100, 1) : 0;
$marginPercent[] = $margin;
$marginColors[] = $margin >= 0 ? 'rgba(40, 167, 69, 1)' : 'rgba(220, 53, 69, 1)';
} else {
$purchased[] = 0;
$invoiced[] = 0;
$marginPercent[] = null;
$marginColors[] = 'rgba(200, 200, 200, 0.5)';
}
}
return array(
'labels' => $labels,
'purchased' => $purchased,
'invoiced' => $invoiced,
'marginPercent' => $marginPercent,
'marginColors' => $marginColors,
);
}
/**
* Get profitability data by month
* IMPORTANT: Only materials purchased FOR CUSTOMERS, not general company expenses
*/
private function getProfitabilityByMonth($year)
{
global $conf;
$result = array(
'purchased' => array_fill(1, 12, 0),
'invoiced' => array_fill(1, 12, 0),
);
// Materials purchased FOR CUSTOMERS only (products, not services)
// This should be materials that are resold or used in customer projects
$sql = "SELECT MONTH(f.datef) as month, SUM(fd.total_ht) as total";
$sql .= " FROM ".MAIN_DB_PREFIX."facture_fourn as f";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."facture_fourn_det as fd ON fd.fk_facture_fourn = f.rowid";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."product as p ON p.rowid = fd.fk_product";
$sql .= " WHERE f.fk_statut > 0 AND f.entity = ".((int) $conf->entity);
$sql .= " AND YEAR(f.datef) = ".((int) $year);
// Only products (type 0), not services (type 1)
// And only products that are meant for resale or customer projects
$sql .= " AND p.fk_product_type = 0"; // Products only
$sql .= " AND (p.tobuy = 1 OR p.tosell = 1)"; // Products that are bought/sold
$sql .= " GROUP BY MONTH(f.datef)";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$result['purchased'][$obj->month] = (float) $obj->total;
}
$this->db->free($resql);
}
// All materials and services invoiced to customers
$sql = "SELECT MONTH(f.datef) as month, SUM(fd.total_ht) as total";
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
$sql .= " INNER JOIN ".MAIN_DB_PREFIX."facturedet as fd ON fd.fk_facture = f.rowid";
$sql .= " WHERE f.fk_statut > 0 AND f.entity = ".((int) $conf->entity);
$sql .= " AND YEAR(f.datef) = ".((int) $year);
$sql .= " GROUP BY MONTH(f.datef)";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$result['invoiced'][$obj->month] = (float) $obj->total;
}
$this->db->free($resql);
}
return $result;
}
/**
* Calculate statistical projection for next year
*/
private function calculateProjection($currentData, $lastData)
{
$currentMonth = date('n');
$avgPurchased = array_sum(array_slice($currentData['purchased'], 0, $currentMonth, true)) / max(1, $currentMonth);
$avgInvoiced = array_sum(array_slice($currentData['invoiced'], 0, $currentMonth, true)) / max(1, $currentMonth);
// Calculate trend
$lastYearTotal = array_sum($lastData['invoiced']);
$currentYearProjected = $avgInvoiced * 12;
$growthRate = ($lastYearTotal > 0) ? (($currentYearProjected - $lastYearTotal) / $lastYearTotal) : 0;
$growthRate = max(-0.3, min(0.3, $growthRate));
return array(
'purchased' => round($avgPurchased * 12 * (1 + $growthRate * 0.5), 2),
'invoiced' => round($avgInvoiced * 12 * (1 + $growthRate * 0.7), 2),
);
}
/**
* Get productivity rating based on margin percentage
*/
private function getProductivityRating($marginPercent)
{
global $langs;
if ($marginPercent >= 100) {
return array(
'class' => 'rating-excellent',
'text' => $langs->trans("Excellent"),
'description' => $langs->trans("RatingExcellentDesc"),
);
} elseif ($marginPercent >= 50) {
return array(
'class' => 'rating-good',
'text' => $langs->trans("Good"),
'description' => $langs->trans("RatingGoodDesc"),
);
} elseif ($marginPercent >= 20) {
return array(
'class' => 'rating-average',
'text' => $langs->trans("Average"),
'description' => $langs->trans("RatingAverageDesc"),
);
} elseif ($marginPercent >= 0) {
return array(
'class' => 'rating-low',
'text' => $langs->trans("Low"),
'description' => $langs->trans("RatingLowDesc"),
);
} else {
return array(
'class' => 'rating-critical',
'text' => $langs->trans("Critical"),
'description' => $langs->trans("RatingCriticalDesc"),
);
}
}
/**
* Method to show the widget
*/
public function showBox($head = null, $contents = null, $nooutput = 0)
{
return parent::showBox($this->info_box_head, $this->info_box_contents, $nooutput);
}
}