OpenNIT

Quelltext des aktuellen Standes

Fassung 1.1.1

BudgetVatTest.php

9 KB
<?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']);
    });
};