Quelltext des aktuellen Standes
<?php
declare(strict_types=1);
use OpenNIT\Audit\AuditLogger;
use OpenNIT\Content\BudgetManager;
use OpenNIT\Core\Database;
/**
* Deckt Netto und Brutto im Budget ab.
*
* Planwerte werden netto erfasst, der Ist-Wert entsteht brutto aus den Belegen.
* Beide Größen müssen deshalb ineinander umrechenbar sein, und jeder Vergleich
* (verfügbar, Abweichung, Auslastung) muss auf gleicher Grundlage stehen.
*/
return static function (): void {
// ----------------------------------------------------------------
// Umrechnung
// ----------------------------------------------------------------
test('Netto und Brutto rechnen ineinander um', function () {
assertSame(119.00, BudgetManager::grossFromNet(100.00, 19.0));
assertSame(107.00, BudgetManager::grossFromNet(100.00, 7.0));
assertSame(100.00, BudgetManager::grossFromNet(100.00, 0.0));
assertSame(100.00, BudgetManager::netFromGross(119.00, 19.0));
assertSame(100.00, BudgetManager::netFromGross(107.00, 7.0));
});
test('Der Rundlauf über beide Richtungen erhält den Betrag', function () {
foreach ([0.0, 7.0, 19.0] as $satz) {
foreach ([0.0, 1.0, 1234.56, 999999.99] as $netto) {
$zurueck = BudgetManager::netFromGross(BudgetManager::grossFromNet($netto, $satz), $satz);
assertTrue(abs($zurueck - $netto) < 0.01, "Satz $satz, Betrag $netto → $zurueck");
}
}
});
test('Ein Satz ohne Kehrwert wird abgewiesen', function () {
// Bei -100 % wäre der Umrechnungsfaktor null und die Division ungültig.
assertSame(null, BudgetManager::sanitizeTaxRate(-100.0));
assertSame(null, BudgetManager::sanitizeTaxRate(-150.0));
assertSame(null, BudgetManager::sanitizeTaxRate(100.01));
assertSame(19.0, BudgetManager::sanitizeTaxRate(19.0));
assertSame(0.0, BudgetManager::sanitizeTaxRate(0.0));
});
test('Eine leere Eingabe bedeutet globale Vorgabe', function () {
// NULL in der Spalte lässt eine spätere Änderung der Vorgabe durchschlagen.
assertSame(null, BudgetManager::taxRateFromInput(''));
assertSame(null, BudgetManager::taxRateFromInput(null));
assertSame(null, BudgetManager::taxRateFromInput('keine Zahl'));
assertSame(7.0, BudgetManager::taxRateFromInput('7'));
assertSame(19.5, BudgetManager::taxRateFromInput('19.5'));
});
// ----------------------------------------------------------------
// Wirksamer Satz
// ----------------------------------------------------------------
/** Manager mit einer Attrappe, die eine gepflegte Vorgabe liefert. */
$mitVorgabe = static function (?string $vorgabe): BudgetManager {
$db = new class($vorgabe) extends Database {
public function __construct(private ?string $vorgabe)
{
parent::__construct([]);
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
return $this->vorgabe ?? false;
}
public function fetchOne(string $sql, array $params = []): ?array
{
return $this->vorgabe !== null ? ['value' => $this->vorgabe] : null;
}
};
$audit = (new ReflectionClass(AuditLogger::class))->newInstanceWithoutConstructor();
return new BudgetManager($db, $audit);
};
test('Der eigene Satz eines Budgets geht der Vorgabe vor', function () use ($mitVorgabe) {
$mgr = $mitVorgabe('19.00');
assertSame(7.0, $mgr->taxRateFor(['tax_rate' => 7.0]));
// Null Prozent ist eine gültige Angabe (steuerfreies Budget) und wird
// nicht als „nicht gesetzt" ausgelegt.
assertSame(0.0, $mgr->taxRateFor(['tax_rate' => 0.0]));
});
test('Ohne eigenen Satz greift die Vorgabe', function () use ($mitVorgabe) {
assertSame(19.0, $mitVorgabe('19.00')->taxRateFor(['tax_rate' => null]));
assertSame(19.0, $mitVorgabe('19.00')->taxRateFor(null));
assertSame(7.0, $mitVorgabe('7.00')->taxRateFor([]));
});
test('Ohne gepflegte Vorgabe greift der eingebaute Satz', function () use ($mitVorgabe) {
assertSame(BudgetManager::FALLBACK_TAX_RATE, $mitVorgabe(null)->taxRateFor(null));
});
test('Eine unbrauchbare Vorgabe macht die Umrechnung nicht ungültig', function () use ($mitVorgabe) {
// Ein von Hand verstellter Wert darf keine Division durch null erzeugen.
assertSame(BudgetManager::FALLBACK_TAX_RATE, $mitVorgabe('-100')->taxRateFor(null));
assertSame(BudgetManager::FALLBACK_TAX_RATE, $mitVorgabe('unsinn')->taxRateFor(null));
});
// ----------------------------------------------------------------
// Zusammenfassung
// ----------------------------------------------------------------
/**
* Liefert die Zusammenfassung zu vorgegebenen Roh-Summen.
*
* @param array<string,mixed> $budget Zeile aus budgets
*/
$summary = static function (array $budget, float $plannedNet, float $actualGross, float $committedNet, float $actualNet): array {
$db = new class($budget, $plannedNet, $actualGross, $committedNet, $actualNet) extends Database {
public function __construct(
private array $budget,
private float $plannedNet,
private float $actualGross,
private float $committedNet,
private float $actualNet
) {
parent::__construct([]);
}
public function fetchOne(string $sql, array $params = []): ?array
{
if (str_contains($sql, 'FROM budget_positions')) {
return [
'total_planned' => $this->plannedNet,
'total_actual' => $this->actualGross,
'total_committed' => $this->committedNet,
'position_count' => 2,
'positions_over_budget' => 0,
];
}
if (str_contains($sql, 'FROM budgets')) {
return $this->budget;
}
return null;
}
public function fetchColumn(string $sql, array $params = [], int $column = 0): mixed
{
// Netto-Ist aus den Belegen
if (str_contains($sql, 'FROM budget_receipts')) {
return $this->actualNet;
}
return false;
}
};
$audit = (new ReflectionClass(AuditLogger::class))->newInstanceWithoutConstructor();
return (new BudgetManager($db, $audit))->getBudgetSummary(1);
};
test('Jede Kennzahl liegt in beiden Größen vor', function () use ($summary) {
$s = $summary(['budget_mode' => 'structured', 'total_amount' => null, 'tax_rate' => 19.0], 1000.0, 595.0, 100.0, 500.0);
foreach ([
'total_planned_net', 'total_planned_gross',
'total_actual_net', 'total_actual_gross',
'total_committed_net', 'total_committed_gross',
'available_net', 'available_gross',
'variance_net', 'variance_gross',
'tax_rate',
] as $schluessel) {
assertTrue(array_key_exists($schluessel, $s), 'Fehlt: ' . $schluessel);
}
});
test('Plan und Ist werden auf gleicher Grundlage verglichen', function () use ($summary) {
// 1000 netto Plan, 595 brutto Ist (= 500 netto), 100 netto verplant.
$s = $summary(['budget_mode' => 'structured', 'total_amount' => null, 'tax_rate' => 19.0], 1000.0, 595.0, 100.0, 500.0);
assertSame(1000.00, $s['total_planned_net']);
assertSame(1190.00, $s['total_planned_gross']);
assertSame(500.00, $s['total_actual_net']);
assertSame(595.00, $s['total_actual_gross']);
// Netto: 1000 - 500 - 100; Brutto: 1190 - 595 - 119
assertSame(400.00, round($s['available_net'], 2));
assertSame(476.00, round($s['available_gross'], 2));
});
test('Die Auslastung steht auf Netto-Grundlage', function () use ($summary) {
// Brutto-Ist gegen Netto-Plan hätte 59,5 % statt 50 % ergeben.
$s = $summary(['budget_mode' => 'structured', 'total_amount' => null, 'tax_rate' => 19.0], 1000.0, 595.0, 0.0, 500.0);
assertSame(50.0, $s['utilization_pct']);
});
test('Ein Pauschalbudget führt seinen Planwert am Budget', function () use ($summary) {
// Die Sammel-Position trägt 0; maßgeblich ist total_amount.
$s = $summary(['budget_mode' => 'lump_sum', 'total_amount' => 2000.0, 'tax_rate' => 19.0], 0.0, 238.0, 0.0, 200.0);
assertSame(2000.00, $s['total_planned_net']);
assertSame(2380.00, $s['total_planned_gross']);
assertSame(1800.00, round($s['available_net'], 2));
});
test('Ein Budget ohne Plan liefert keine Division durch null', function () use ($summary) {
$s = $summary(['budget_mode' => 'structured', 'total_amount' => null, 'tax_rate' => 19.0], 0.0, 0.0, 0.0, 0.0);
assertSame(0, $s['utilization_pct']);
assertSame(0.00, $s['total_planned_gross']);
});
};