Abrechnungsgründe nun immer erforderlich
This commit is contained in:
+224
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
|
||||||
|
|
||||||
|
use App\Enumerations\ParticipationType;
|
||||||
|
use App\Models\CostUnit;
|
||||||
|
use App\Models\Event;
|
||||||
|
use App\Providers\PdfGenerateAndDownloadProvider;
|
||||||
|
use App\Repositories\CostUnitRepository;
|
||||||
|
use App\ValueObjects\Amount;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt die Einnahmen-Überschuss-Rechnung einer Veranstaltung als PDF.
|
||||||
|
*
|
||||||
|
* Gezeigt wird ausschließlich Geld, das geflossen ist: gezahlte Beiträge, weitere Einnahmen, was von
|
||||||
|
* Abmeldungen einbehalten wurde, Fördermittel -- und auf der anderen Seite die erfassten Belege. Was nur
|
||||||
|
* erwartet (offene Beiträge) oder geplant (Budgetwerte) ist, gehört in eine Einnahmen-Überschuss-Rechnung
|
||||||
|
* nicht hinein.
|
||||||
|
*
|
||||||
|
* Es wird nichts gespeichert: Alle Zahlen leiten sich aus dem aktuellen Stand ab, ein erneuter Abruf
|
||||||
|
* liefert den dann gültigen Stand.
|
||||||
|
*/
|
||||||
|
class CreateIncomeSurplusStatementCommand
|
||||||
|
{
|
||||||
|
private Event $event;
|
||||||
|
|
||||||
|
private CostUnitRepository $costUnits;
|
||||||
|
|
||||||
|
public function __construct(private readonly CreateIncomeSurplusStatementRequest $request)
|
||||||
|
{
|
||||||
|
$this->event = $request->event;
|
||||||
|
$this->costUnits = new CostUnitRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function execute(): CreateIncomeSurplusStatementResponse
|
||||||
|
{
|
||||||
|
$response = new CreateIncomeSurplusStatementResponse();
|
||||||
|
|
||||||
|
$costUnit = $this->event->costUnit()->first();
|
||||||
|
|
||||||
|
if (!$costUnit instanceof CostUnit) {
|
||||||
|
$response->message = 'Der Veranstaltung ist keine Kostenstelle zugeordnet.';
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Pauschalbetrag wird vor dem Resource-Aufruf gelesen: `EventResource::calculateSupportPerPerson()`
|
||||||
|
// multipliziert das Amount-Objekt von `support_per_person` in place. Auf `support_flat` wirkt das
|
||||||
|
// zwar nicht, aber der gesamte Zugriff auf Beträge des Models ist danach nicht mehr vertrauenswürdig.
|
||||||
|
$otherIncome = $this->event->support_flat->getAmount();
|
||||||
|
|
||||||
|
$eventData = $this->event->toResource()->toArray(new Request());
|
||||||
|
|
||||||
|
$income = $this->buildIncome($eventData, $otherIncome);
|
||||||
|
$expenses = $this->buildExpenses($costUnit);
|
||||||
|
|
||||||
|
$result = new Amount($income['total']->getAmount() - $expenses['total']->getAmount(), 'Euro');
|
||||||
|
|
||||||
|
$html = view('pdfs.income-surplus-statement', [
|
||||||
|
'event' => $this->event,
|
||||||
|
'createdAt' => new \DateTime()->format('d.m.Y'),
|
||||||
|
'income' => $income,
|
||||||
|
'expenses' => $expenses,
|
||||||
|
'result' => $result,
|
||||||
|
'money' => self::money(...),
|
||||||
|
])->render();
|
||||||
|
|
||||||
|
$response->success = true;
|
||||||
|
$response->filename = 'EUER-' . $this->event->identifier . '.pdf';
|
||||||
|
$response->income = $income;
|
||||||
|
$response->expenses = $expenses;
|
||||||
|
$response->result = $result;
|
||||||
|
$response->pdfContent = PdfGenerateAndDownloadProvider::fromHtml($html, 'portrait');
|
||||||
|
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Einnahmenseite in zwei Ober-Kategorien.
|
||||||
|
*
|
||||||
|
* Alle Zahlen stammen aus {@see \App\Resources\EventResource} -- derselben Quelle wie die
|
||||||
|
* Veranstaltungsübersicht am Bildschirm. Eine eigene Rechnung daneben würde über kurz oder lang von
|
||||||
|
* der Übersicht abweichen, und dann glaubt niemand mehr einer der beiden Zahlen.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $eventData
|
||||||
|
*
|
||||||
|
* @return array{categories: array<int, array{name: string, total: Amount, entries: array<int, array{name: string, amount: Amount}>}>, total: Amount}
|
||||||
|
*/
|
||||||
|
private function buildIncome(array $eventData, float $otherIncome): array
|
||||||
|
{
|
||||||
|
// Beiträge aller Teilnahmearten in einer Zeile: Für die Mittelverwendung zählt, was an Beiträgen
|
||||||
|
// hereingekommen ist, nicht von wem.
|
||||||
|
$participationFees = new Amount(0, 'Euro');
|
||||||
|
foreach ([
|
||||||
|
ParticipationType::PARTICIPATION_TYPE_PARTICIPANT,
|
||||||
|
ParticipationType::PARTICIPATION_TYPE_TEAM,
|
||||||
|
ParticipationType::PARTICIPATION_TYPE_VOLUNTEER,
|
||||||
|
ParticipationType::PARTICIPATION_TYPE_OTHER,
|
||||||
|
] as $participationType) {
|
||||||
|
$participationFees->addAmount(
|
||||||
|
new Amount((float) $eventData['participants'][$participationType]['amount']['paid']['value'], 'Euro')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$ownFunds = [
|
||||||
|
['name' => 'Teilnahmebeiträge', 'amount' => $participationFees],
|
||||||
|
['name' => 'Weitere Einnahmen', 'amount' => new Amount($otherIncome, 'Euro')],
|
||||||
|
[
|
||||||
|
'name' => 'Einbehaltene Einnahmen aus Abmeldungen',
|
||||||
|
'amount' => new Amount((float) $eventData['retainedFromUnregistered']['value'], 'Euro'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$supportRate = new Amount((float) $eventData['supportPersonValue'], 'Euro');
|
||||||
|
$funding = [
|
||||||
|
[
|
||||||
|
'name' => 'Fördermittel (' . self::money($supportRate) . ' € p.P./Tag)',
|
||||||
|
'amount' => new Amount($eventData['supportPerson']['amount']->getAmount(), 'Euro'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$categories = [
|
||||||
|
['name' => 'Eigenmittel', 'entries' => $ownFunds, 'total' => self::sum($ownFunds)],
|
||||||
|
['name' => 'Förderungen', 'entries' => $funding, 'total' => self::sum($funding)],
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'categories' => $categories,
|
||||||
|
'total' => self::sum($categories),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Ausgabenseite: eine Zeile je Ausgabentyp, dazu die Belege für die Anlage.
|
||||||
|
*
|
||||||
|
* @return array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array{number: string, date: string, purpose: string, amount: Amount}>}>, total: Amount}
|
||||||
|
*/
|
||||||
|
private function buildExpenses(CostUnit $costUnit): array
|
||||||
|
{
|
||||||
|
$groups = [];
|
||||||
|
$total = new Amount(0, 'Euro');
|
||||||
|
|
||||||
|
foreach ($this->costUnits->groupExpensesByType($costUnit) as $group) {
|
||||||
|
$rows = [];
|
||||||
|
|
||||||
|
foreach ($group['invoices'] as $invoice) {
|
||||||
|
$rows[] = [
|
||||||
|
'number' => (string) $invoice->invoice_number,
|
||||||
|
'date' => $invoice->created_at?->format('d.m.Y') ?? '',
|
||||||
|
'purpose' => $this->purpose($invoice->type_other, $invoice->comment),
|
||||||
|
'amount' => Amount::fromString($invoice->amount),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$groups[] = [
|
||||||
|
'name' => $group['type']->name,
|
||||||
|
'sum' => $group['sum'],
|
||||||
|
'rows' => $rows,
|
||||||
|
];
|
||||||
|
|
||||||
|
$total->addAmount($group['sum']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['groups' => $groups, 'total' => $total];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wofür der Beleg steht.
|
||||||
|
*
|
||||||
|
* `type_other` trägt seit der Pflichtangabe "Was wurde eingekauft" zu jeder Abrechnung den Zweck,
|
||||||
|
* nicht mehr nur bei "Sonstige Kosten". Ältere Belege haben das Feld leer -- dann bleibt die
|
||||||
|
* Anmerkung, und fehlt auch die, bleibt die Zelle leer. Ein Platzhalter wie "--" würde in der
|
||||||
|
* Belegliste nur Platz kosten.
|
||||||
|
*/
|
||||||
|
private function purpose(?string $typeOther, ?string $comment): string
|
||||||
|
{
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
foreach ([$typeOther, $comment] as $part) {
|
||||||
|
if (trim((string) $part) !== '') {
|
||||||
|
$parts[] = trim((string) $part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(' — ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Betrag in deutscher Schreibweise: Punkt als Tausender-, Komma als Dezimaltrennzeichen.
|
||||||
|
*
|
||||||
|
* Bewusst nicht {@see Amount::getFormattedAmount()}: Die Methode ersetzt nach `number_format` jeden
|
||||||
|
* Punkt durch ein Komma und macht aus 1.487,50 damit "1,487,50". Auf einer Aufstellung, in der
|
||||||
|
* vierstellige Beträge die Regel sind, wäre das nicht lesbar. Der Fehler steckt im Value Object und
|
||||||
|
* wirkt überall, wo Beträge angezeigt werden -- ihn dort zu beheben ist eine eigene Änderung.
|
||||||
|
*
|
||||||
|
* Öffentlich, weil die Vorlage sie als Callable bekommt und weil sie für sich prüfbar sein soll.
|
||||||
|
*/
|
||||||
|
public static function money(Amount $amount): string
|
||||||
|
{
|
||||||
|
return number_format(round($amount->getAmount(), 2), 2, ',', '.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summiert Zeilen, die je ein `amount` oder `total` tragen.
|
||||||
|
*
|
||||||
|
* Über ein frisches Amount-Objekt, weil `Amount::addAmount()` den Empfänger verändert -- die
|
||||||
|
* Einzelbeträge sollen unangetastet bleiben, sie werden anschließend gedruckt.
|
||||||
|
*
|
||||||
|
* @param array<int, array<string, mixed>> $rows
|
||||||
|
*/
|
||||||
|
private static function sum(array $rows): Amount
|
||||||
|
{
|
||||||
|
$sum = new Amount(0, 'Euro');
|
||||||
|
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
/** @var Amount $amount */
|
||||||
|
$amount = $row['amount'] ?? $row['total'];
|
||||||
|
$sum->addAmount($amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $sum;
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
|
||||||
|
|
||||||
|
use App\Models\Event;
|
||||||
|
|
||||||
|
class CreateIncomeSurplusStatementRequest
|
||||||
|
{
|
||||||
|
public function __construct(public readonly Event $event)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
+33
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Actions\CreateIncomeSurplusStatement;
|
||||||
|
|
||||||
|
use App\ValueObjects\Amount;
|
||||||
|
|
||||||
|
class CreateIncomeSurplusStatementResponse
|
||||||
|
{
|
||||||
|
public bool $success = false;
|
||||||
|
|
||||||
|
public string $filename = '';
|
||||||
|
|
||||||
|
public string $pdfContent = '';
|
||||||
|
|
||||||
|
public ?string $message = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Die Zahlen, aus denen das PDF entsteht -- Einnahmen-Kategorien, Ausgaben-Gruppen und das Ergebnis.
|
||||||
|
*
|
||||||
|
* Sie stehen hier, weil sie das eigentliche Ergebnis der Action sind; das PDF ist nur ihre Darstellung.
|
||||||
|
* So lässt sich die Rechnung prüfen, ohne ein PDF zerlegen zu müssen.
|
||||||
|
*
|
||||||
|
* @var array{categories: array<int, array{name: string, total: Amount, entries: array<int, array{name: string, amount: Amount}>}>, total: Amount}|array{}
|
||||||
|
*/
|
||||||
|
public array $income = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array{groups: array<int, array{name: string, sum: Amount, rows: array<int, array<string, mixed>>}>, total: Amount}|array{}
|
||||||
|
*/
|
||||||
|
public array $expenses = [];
|
||||||
|
|
||||||
|
public ?Amount $result = null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementCommand;
|
||||||
|
use App\Domains\Event\Actions\CreateIncomeSurplusStatement\CreateIncomeSurplusStatementRequest;
|
||||||
|
use App\Scopes\CommonController;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class IncomeSurplusStatementController extends CommonController
|
||||||
|
{
|
||||||
|
public function __invoke(string $eventId): Response
|
||||||
|
{
|
||||||
|
$event = $this->events->getByIdentifier($eventId);
|
||||||
|
|
||||||
|
if ($event === null) {
|
||||||
|
abort(403, 'Zugriff verweigert.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$statementRequest = new CreateIncomeSurplusStatementRequest($event);
|
||||||
|
$statementCommand = new CreateIncomeSurplusStatementCommand($statementRequest);
|
||||||
|
$statementResponse = $statementCommand->execute();
|
||||||
|
|
||||||
|
if (!$statementResponse->success) {
|
||||||
|
abort(422, $statementResponse->message ?? 'Die EÜR konnte nicht erstellt werden.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response($statementResponse->pdfContent, 200, [
|
||||||
|
'Content-Type' => 'application/pdf',
|
||||||
|
'Content-Disposition' => 'attachment; filename="' . $statementResponse->filename . '"',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ use App\Domains\Event\Controllers\ArchivedEventsController;
|
|||||||
use App\Domains\Event\Controllers\AvailableEventsController;
|
use App\Domains\Event\Controllers\AvailableEventsController;
|
||||||
use App\Domains\Event\Controllers\CreateController;
|
use App\Domains\Event\Controllers\CreateController;
|
||||||
use App\Domains\Event\Controllers\DetailsController;
|
use App\Domains\Event\Controllers\DetailsController;
|
||||||
|
use App\Domains\Event\Controllers\IncomeSurplusStatementController;
|
||||||
use App\Domains\Event\Controllers\SignupController;
|
use App\Domains\Event\Controllers\SignupController;
|
||||||
use App\Middleware\IdentifyTenant;
|
use App\Middleware\IdentifyTenant;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -18,6 +19,10 @@ Route::middleware(IdentifyTenant::class)->group(function () {
|
|||||||
|
|
||||||
Route::middleware(['auth'])->group(function () {
|
Route::middleware(['auth'])->group(function () {
|
||||||
Route::get('/details/{eventId}', DetailsController::class);
|
Route::get('/details/{eventId}', DetailsController::class);
|
||||||
|
|
||||||
|
// Vor der Wildcard darunter: Sonst greift `downloadPdfList()` und sucht ein Blade namens
|
||||||
|
// `income-surplus-statement` mit Teilnehmendendaten, die die EÜR gar nicht braucht.
|
||||||
|
Route::get('/details/{eventId}/pdf/income-surplus-statement', IncomeSurplusStatementController::class);
|
||||||
Route::get('/details/{eventId}/pdf/{listType}', [DetailsController::class, 'downloadPdfList']);
|
Route::get('/details/{eventId}/pdf/{listType}', [DetailsController::class, 'downloadPdfList']);
|
||||||
Route::get('/details/{eventId}/csv/{listType}', [DetailsController::class, 'downloadCsvList']);
|
Route::get('/details/{eventId}/csv/{listType}', [DetailsController::class, 'downloadCsvList']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ async function showEventAddons() {
|
|||||||
<input type="button" value="Beitragsliste (PDF)" />
|
<input type="button" value="Beitragsliste (PDF)" />
|
||||||
</a><br/>
|
</a><br/>
|
||||||
|
|
||||||
|
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/income-surplus-statement'">
|
||||||
|
<input type="button" value="EüR (PDF)" />
|
||||||
|
</a><br/>
|
||||||
|
|
||||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
|
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
|
||||||
<input type="button" value="Getränkeliste (PDF)" />
|
<input type="button" value="Getränkeliste (PDF)" />
|
||||||
</a><br/>
|
</a><br/>
|
||||||
@@ -143,6 +147,10 @@ async function showEventAddons() {
|
|||||||
<input type="button" value="Beitragsliste (PDF)" />
|
<input type="button" value="Beitragsliste (PDF)" />
|
||||||
</a><br/>
|
</a><br/>
|
||||||
|
|
||||||
|
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/income-surplus-statement'">
|
||||||
|
<input type="button" value="EüR (PDF)" />
|
||||||
|
</a><br/>
|
||||||
|
|
||||||
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
|
<a :href="'/event/details/' + props.data.event.identifier + '/pdf/drinking-list'">
|
||||||
<input type="button" value="Getränkeliste (PDF)" />
|
<input type="button" value="Getränkeliste (PDF)" />
|
||||||
</a><br/>
|
</a><br/>
|
||||||
|
|||||||
Reference in New Issue
Block a user