buchhaltungswidget/core/boxes/box_gewinn_verlust.php

386 lines
15 KiB
PHP
Executable file

<?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_gewinn_verlust.php
* \ingroup buchaltungswidget
* \brief Widget showing Profit/Loss overview - only customer-related costs, no company overhead
*/
include_once DOL_DOCUMENT_ROOT.'/core/boxes/modules_boxes.php';
/**
* Class to manage the Profit/Loss overview widget
*/
class box_gewinn_verlust extends ModeleBoxes
{
public $boxcode = "gewinn_verlust";
public $boximg = "accountancy";
public $boxlabel = "GewinnVerlust";
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("GewinnVerlust"),
'sublink' => dol_buildpath('/buchaltungswidget/gewinn_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 data for all three years
$dataCurrentYear = $this->getIncomeExpenseByMonth($currentYear);
$dataLastYear = $this->getIncomeExpenseByMonth($lastYear);
// Calculate statistical projection for next year
$projectionNextYear = $this->calculateProjection($dataCurrentYear, $dataLastYear);
// Build the output
$this->info_box_contents = array();
$line = 0;
// Mini chart area
$chartId = 'gewinn_chart_'.uniqid();
$chartData = $this->prepareChartData($dataCurrentYear, $dataLastYear, $currentYear, $lastYear);
// Determine line color based on current profit status
$lastValue = $chartData['lastCurrentValue'];
$lineColor = $lastValue >= 0 ? 'rgba(40, 167, 69, 1)' : 'rgba(220, 53, 69, 1)';
$fillColor = $lastValue >= 0 ? 'rgba(40, 167, 69, 0.2)' : 'rgba(220, 53, 69, 0.2)';
$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");
var profitData = '.json_encode($chartData['currentProfit']).';
new Chart(ctx, {
type: "line",
data: {
labels: '.json_encode($chartData['labels']).',
datasets: [{
label: "'.$currentYear.' '.$langs->trans("ProfitLoss").'",
data: profitData,
borderColor: "'.$lineColor.'",
backgroundColor: "'.$fillColor.'",
fill: true,
tension: 0.3,
borderWidth: 3,
pointBackgroundColor: "'.$lineColor.'",
pointBorderColor: "'.$lineColor.'",
pointRadius: 3
}, {
label: "'.$lastYear.' '.$langs->trans("ProfitLoss").'",
data: '.json_encode($chartData['lastProfit']).',
borderColor: "rgba(108, 117, 125, 0.7)",
backgroundColor: "transparent",
borderDash: [5, 5],
fill: false,
tension: 0.3,
pointRadius: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: true, position: "bottom", labels: { boxWidth: 12, font: { size: 10 }, color: "#444" } },
tooltip: {
callbacks: {
label: function(context) {
return context.dataset.label + ": " + new Intl.NumberFormat("de-DE", {style: "currency", currency: "EUR"}).format(context.raw);
}
}
}
},
scales: {
y: {
beginAtZero: false,
ticks: { font: { size: 9 }, color: "#555" },
grid: { color: function(context) { return context.tick.value === 0 ? "rgba(0, 0, 0, 0.3)" : "rgba(255, 255, 255, 0.8)"; }, lineWidth: function(context) { return context.tick.value === 0 ? 2 : 1; } },
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++;
// Income row
$incomeLast = array_sum($dataLastYear['income']);
$incomeCurrent = array_sum(array_slice($dataCurrentYear['income'], 0, $currentMonth, true));
$incomeProjection = $projectionNextYear['income'];
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-label"', 'text' => $langs->trans("Income"));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-lastyear"', 'text' => price($incomeLast, 0, $langs, 1, 0, 0, $conf->currency));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-positive"', 'text' => '<strong>'.price($incomeCurrent, 0, $langs, 1, 0, 0, $conf->currency).'</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-future"', 'text' => price($incomeProjection, 0, $langs, 1, 0, 0, $conf->currency));
$line++;
// Customer-related expenses only
$expensesLast = array_sum($dataLastYear['customer_expenses']);
$expensesCurrent = array_sum(array_slice($dataCurrentYear['customer_expenses'], 0, $currentMonth, true));
$expensesProjection = $projectionNextYear['expenses'];
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-label"', 'text' => $langs->trans("CustomerRelatedCosts"));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-lastyear"', 'text' => price($expensesLast, 0, $langs, 1, 0, 0, $conf->currency));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-negative"', 'text' => '<strong>'.price($expensesCurrent, 0, $langs, 1, 0, 0, $conf->currency).'</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-future"', 'text' => price($expensesProjection, 0, $langs, 1, 0, 0, $conf->currency));
$line++;
// Profit/Loss row
$profitLast = $incomeLast - $expensesLast;
$profitCurrent = $incomeCurrent - $expensesCurrent;
$profitProjection = $incomeProjection - $expensesProjection;
$colorLast = $profitLast >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$colorCurrent = $profitCurrent >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$colorProjection = $profitProjection >= 0 ? 'buchaltung-positive' : 'buchaltung-negative';
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-label buchaltung-profit-row"', 'text' => '<strong>'.$langs->trans("ProfitLoss").'</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-profit-row '.$colorLast.'"', 'text' => '<strong>'.price($profitLast, 0, $langs, 1, 0, 0, $conf->currency).'</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-profit-row '.$colorCurrent.'"', 'text' => '<strong>'.price($profitCurrent, 0, $langs, 1, 0, 0, $conf->currency).'</strong>', 'asis' => 1);
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-profit-row buchaltung-future '.$colorProjection.'"', 'text' => '<strong>'.price($profitProjection, 0, $langs, 1, 0, 0, $conf->currency).'</strong>', 'asis' => 1);
$line++;
// Estimated income tax if profit
if ($profitCurrent > 0) {
$estimatedTax = $this->calculateIncomeTax($profitCurrent);
$this->info_box_contents[$line][] = array('td' => 'class="buchaltung-label" colspan="2"', 'text' => $langs->trans("EstimatedIncomeTax"));
$this->info_box_contents[$line][] = array('td' => 'class="right buchaltung-negative" colspan="2"', 'text' => '<strong>~'.price($estimatedTax, 0, $langs, 1, 0, 0, $conf->currency).'</strong>', 'asis' => 1);
$line++;
}
// Footer note
$this->info_box_contents[$line][] = array(
'td' => 'colspan="4" class="buchaltung-footnote"',
'text' => '<small>* '.$langs->trans("StatisticalProjection").'</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');
$currentProfit = array();
$lastProfit = array();
$currentColors = array();
$currentBgColors = array();
$cumulativeCurrent = 0;
$cumulativeLast = 0;
for ($m = 1; $m <= 12; $m++) {
// Last year cumulative
$incomeLast = isset($lastData['income'][$m]) ? $lastData['income'][$m] : 0;
$expensesLast = isset($lastData['customer_expenses'][$m]) ? $lastData['customer_expenses'][$m] : 0;
$cumulativeLast += ($incomeLast - $expensesLast);
$lastProfit[] = round($cumulativeLast, 2);
// Current year cumulative (only up to current month)
if ($m <= $currentMonth) {
$incomeCurrent = isset($currentData['income'][$m]) ? $currentData['income'][$m] : 0;
$expensesCurrent = isset($currentData['customer_expenses'][$m]) ? $currentData['customer_expenses'][$m] : 0;
$cumulativeCurrent += ($incomeCurrent - $expensesCurrent);
$currentProfit[] = round($cumulativeCurrent, 2);
// Color based on profit/loss
if ($cumulativeCurrent >= 0) {
$currentColors[] = 'rgba(40, 167, 69, 1)'; // Green for profit
$currentBgColors[] = 'rgba(40, 167, 69, 0.15)';
} else {
$currentColors[] = 'rgba(220, 53, 69, 1)'; // Red for loss
$currentBgColors[] = 'rgba(220, 53, 69, 0.15)';
}
} else {
$currentProfit[] = null; // No data for future months
$currentColors[] = 'rgba(200, 200, 200, 0.5)';
$currentBgColors[] = 'rgba(200, 200, 200, 0.1)';
}
}
return array(
'labels' => $labels,
'currentProfit' => $currentProfit,
'lastProfit' => $lastProfit,
'currentColors' => $currentColors,
'currentBgColors' => $currentBgColors,
'lastCurrentValue' => $cumulativeCurrent,
);
}
/**
* Get income and customer-related expenses by month
* IMPORTANT: Only includes costs related to customer projects (materials for customers),
* NOT company overhead costs
*/
private function getIncomeExpenseByMonth($year)
{
global $conf;
$result = array(
'income' => array_fill(1, 12, 0),
'customer_expenses' => array_fill(1, 12, 0),
);
// Income from customer invoices
$sql = "SELECT MONTH(f.datef) as month, SUM(f.total_ht) as total";
$sql .= " FROM ".MAIN_DB_PREFIX."facture as f";
$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['income'][$obj->month] = (float) $obj->total;
}
$this->db->free($resql);
}
// Customer-related expenses only:
// - Products (materials) for customers (product_type = 0)
// - Services directly for customers
// Exclude: Company overhead, rent, utilities, etc.
$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 .= " LEFT 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 include products (materials) - product_type 0 = product, 1 = service
// Also include invoice lines with product linked
$sql .= " AND (fd.fk_product IS NOT NULL AND fd.fk_product > 0)";
$sql .= " AND (p.fk_product_type = 0 OR fd.product_type = 0)"; // Materials only
$sql .= " GROUP BY MONTH(f.datef)";
$resql = $this->db->query($sql);
if ($resql) {
while ($obj = $this->db->fetch_object($resql)) {
$result['customer_expenses'][$obj->month] = (float) $obj->total;
}
$this->db->free($resql);
}
return $result;
}
/**
* Calculate statistical projection for next year based on trends
*/
private function calculateProjection($currentData, $lastData)
{
$currentMonth = date('n');
// Calculate average monthly values from current year (up to now)
$avgIncome = array_sum(array_slice($currentData['income'], 0, $currentMonth, true)) / max(1, $currentMonth);
$avgExpenses = array_sum(array_slice($currentData['customer_expenses'], 0, $currentMonth, true)) / max(1, $currentMonth);
// Calculate year-over-year growth rate
$lastYearIncome = array_sum($lastData['income']);
$currentYearIncome = array_sum(array_slice($currentData['income'], 0, $currentMonth, true));
$projectedCurrentYear = ($currentMonth > 0) ? ($currentYearIncome / $currentMonth) * 12 : 0;
$growthRate = ($lastYearIncome > 0) ? (($projectedCurrentYear - $lastYearIncome) / $lastYearIncome) : 0;
$growthRate = max(-0.5, min(0.5, $growthRate)); // Cap growth rate at +/- 50%
return array(
'income' => round($projectedCurrentYear * (1 + $growthRate * 0.5), 2), // Conservative projection
'expenses' => round(($avgExpenses * 12) * (1 + $growthRate * 0.3), 2),
);
}
/**
* Calculate estimated income tax (simplified German model)
*/
private function calculateIncomeTax($profit)
{
// Simplified German income tax calculation
// Grundfreibetrag 2024: ~11,604 EUR
$taxableIncome = max(0, $profit - 11604);
if ($taxableIncome <= 0) {
return 0;
} elseif ($taxableIncome <= 17005) {
// Zone 2: ~14-24%
return $taxableIncome * 0.18;
} elseif ($taxableIncome <= 66760) {
// Zone 3: ~24-42%
return $taxableIncome * 0.30;
} else {
// Zone 4: 42%+
return $taxableIncome * 0.42;
}
}
/**
* 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);
}
}