Teilnahmerechnungen erstellen

This commit is contained in:
2026-09-03 00:08:46 +02:00
parent a61344395a
commit 9c4c28e566
79 changed files with 4303 additions and 75 deletions
@@ -1,20 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Setzt den Standard-Befreiungsgrund neuer Tenants auf Jugendhilfe (§ 4 Nr. 25 Buchst. a). Der Wert
* existiert in tax_exemption_reasons, der FK bleibt gültig. Bestehende Zeilen bleiben unverändert.
*
* Über `change()` statt rohem `ALTER TABLE ... SET DEFAULT`, weil sqlite das nicht kennt und die
* Testsuite auf sqlite läuft.
*/
return new class extends Migration {
public function up(): void
{
DB::statement("ALTER TABLE tenants ALTER COLUMN tax_exemption_reason SET DEFAULT 'ustg_4_25a'");
Schema::table('tenants', function (Blueprint $table) {
$table->string('tax_exemption_reason')->nullable()->default('ustg_4_25a')->change();
});
}
public function down(): void
{
DB::statement('ALTER TABLE tenants ALTER COLUMN tax_exemption_reason DROP DEFAULT');
Schema::table('tenants', function (Blueprint $table) {
$table->string('tax_exemption_reason')->nullable()->default(null)->change();
});
}
};
@@ -0,0 +1,55 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Ergänzt die für eine Rechnung nach § 14 Abs. 4 UStG fehlenden Angaben des Rechnungsstellers
* (vollständige Anschrift, Steuernummer bzw. USt-IdNr.) sowie das Kürzel für den Nummernkreis.
*
* Die Adressfelder heißen wie die des Teilnehmers (`event_participants.address_1/2`), damit
* Absender- und Empfängerblock der Rechnungsvorlage gleich aufgebaut sind.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->string('address_1')->nullable()->after('name');
$table->string('address_2')->nullable()->after('address_1');
$table->string('address_3')->nullable()->after('address_2');
$table->string('phone')->nullable()->after('email_finance');
$table->string('tax_number')->nullable()->after('vat_pricing_mode');
$table->string('vat_id')->nullable()->after('tax_number');
$table->string('invoice_prefix')->nullable()->after('vat_id');
});
// Bestands-Tenants bekommen den großgeschriebenen Slug als Default-Präfix (wm -> WM).
foreach (DB::table('tenants')->select('id', 'slug')->get() as $tenant) {
DB::table('tenants')
->where('id', $tenant->id)
->update(['invoice_prefix' => strtoupper($tenant->slug)]);
}
Schema::table('tenants', function (Blueprint $table) {
$table->unique('invoice_prefix');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropUnique(['invoice_prefix']);
$table->dropColumn([
'address_1',
'address_2',
'address_3',
'phone',
'tax_number',
'vat_id',
'invoice_prefix',
]);
});
}
};
@@ -0,0 +1,108 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Friert bei Event-Anlage den Rechnungssteller und den Event-Teil der Rechnungsnummer ein -- analog zur
* bereits bestehenden USt-Grundlage (`2026_08_06_160010_add_tax_snapshot_to_events`).
*
* ACHTUNG: Das hier erzeugte Format ist überholt -- `2026_09_02_140030_compact_invoice_key_format`
* entfernt die Trenner zwischen Jahr, Monat und Veranstaltungsnummer (`WM-V-20260701`). Diese
* Migration bleibt unverändert, weil sie den damaligen Stand abbildet.
*
* `invoice_key` hat die Form `WM-V-2026-07-01` (Tenant-Präfix, Dokumentart, Jahr, Monat, laufende Nummer
* der Veranstaltung dieses Tenants im Monat). Er muss eingefroren werden, weil Tenant-Präfix und
* `start_date` später änderbar sind -- eine bereits herausgegebene Rechnung würde sonst zu einer anderen
* Nummer gehören.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->string('invoice_key')->nullable()->unique();
$table->string('invoice_sender_name')->nullable();
$table->string('invoice_sender_address_1')->nullable();
$table->string('invoice_sender_address_2')->nullable();
$table->string('invoice_sender_address_3')->nullable();
$table->string('invoice_sender_postcode')->nullable();
$table->string('invoice_sender_city')->nullable();
$table->string('invoice_sender_email')->nullable();
$table->string('invoice_sender_phone')->nullable();
$table->string('invoice_sender_tax_number')->nullable();
$table->string('invoice_sender_vat_id')->nullable();
});
$this->backfill();
}
/**
* Bestandsevents nachträglich bestücken: Absenderdaten aus dem heutigen Tenant, `invoice_key` je
* Tenant und Monat nach `start_date`, dann `id` durchnummeriert.
*/
private function backfill(): void
{
$tenants = DB::table('tenants')->get()->keyBy('slug');
$counters = [];
$events = DB::table('events')
->select('id', 'tenant', 'start_date')
->orderBy('start_date')
->orderBy('id')
->get();
foreach ($events as $event) {
$tenant = $tenants->get($event->tenant);
if ($tenant === null || $event->start_date === null) {
continue;
}
$month = date('Y-m', strtotime($event->start_date));
$bucket = $event->tenant . '|' . $month;
$counters[$bucket] = ($counters[$bucket] ?? 0) + 1;
DB::table('events')->where('id', $event->id)->update([
// $month ist bereits "2026-07" -> ergibt "WM-V-2026-07-01"
'invoice_key' => sprintf(
'%s-V-%s-%s',
$tenant->invoice_prefix,
$month,
str_pad((string) $counters[$bucket], 2, '0', STR_PAD_LEFT)
),
'invoice_sender_name' => $tenant->name,
'invoice_sender_address_1' => $tenant->address_1,
'invoice_sender_address_2' => $tenant->address_2,
'invoice_sender_address_3' => $tenant->address_3,
'invoice_sender_postcode' => $tenant->postcode,
'invoice_sender_city' => $tenant->city,
'invoice_sender_email' => $tenant->email,
'invoice_sender_phone' => $tenant->phone,
'invoice_sender_tax_number' => $tenant->tax_number,
'invoice_sender_vat_id' => $tenant->vat_id,
]);
}
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropUnique(['invoice_key']);
$table->dropColumn([
'invoice_key',
'invoice_sender_name',
'invoice_sender_address_1',
'invoice_sender_address_2',
'invoice_sender_address_3',
'invoice_sender_postcode',
'invoice_sender_city',
'invoice_sender_email',
'invoice_sender_phone',
'invoice_sender_tax_number',
'invoice_sender_vat_id',
]);
});
}
};
@@ -0,0 +1,61 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Drei Angaben, die für die Anmelde-Rechnung gebraucht werden:
*
* - `invoice_sequence` -- die Position des Teilis in der Veranstaltung, bildet den letzten Block der
* Rechnungsnummer. Wird persistiert und nicht zur Laufzeit gezählt, damit die Nummer stabil bleibt,
* wenn sich jemand abmeldet.
* - `fee_type` / `sibling_reduction` -- flossen bisher in den Preis ein, ohne gespeichert zu werden.
* Ohne sie ließe sich der ausgewiesene Tagessatz nicht korrekt darstellen (siehe
* CreateParticipantInvoiceCommand).
*/
return new class extends Migration {
public function up(): void
{
Schema::table('event_participants', function (Blueprint $table) {
$table->unsignedInteger('invoice_sequence')->nullable()->after('identifier');
$table->string('fee_type')->nullable()->after('participation_type');
$table->boolean('sibling_reduction')->default(false)->after('fee_type');
});
$this->backfillSequences();
Schema::table('event_participants', function (Blueprint $table) {
$table->unique(['event_id', 'invoice_sequence']);
});
}
/** Bestandsanmeldungen je Event nach `id` durchnummerieren. */
private function backfillSequences(): void
{
$counters = [];
$participants = DB::table('event_participants')
->select('id', 'event_id')
->orderBy('event_id')
->orderBy('id')
->get();
foreach ($participants as $participant) {
$counters[$participant->event_id] = ($counters[$participant->event_id] ?? 0) + 1;
DB::table('event_participants')
->where('id', $participant->id)
->update(['invoice_sequence' => $counters[$participant->event_id]]);
}
}
public function down(): void
{
Schema::table('event_participants', function (Blueprint $table) {
$table->dropUnique(['event_id', 'invoice_sequence']);
$table->dropColumn(['invoice_sequence', 'fee_type', 'sibling_reduction']);
});
}
};
@@ -0,0 +1,36 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Vorlage für generierte Dokumente (aktuell: Anmelde-Rechnung), zerlegt in einzeln pflegbare Blöcke.
*
* Bewusst app-weit und ohne Tenant-Spalte (Model erbt `CommonModel`, also kein `SiteScope`) -- es gibt
* eine Vorlage für alle Tenants, tenant-spezifisch sind nur die eingesetzten Werte. Vorbild ist
* `page_texts`.
*
* Der Inhalt liegt in der Datenbank statt im Blade, damit ein Release ihn nicht überschreibt.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('document_templates', function (Blueprint $table) {
$table->id();
$table->string('document_type');
$table->string('block');
$table->longText('content')->nullable();
$table->integer('sort_order')->default(1);
$table->boolean('editable')->default(true);
$table->timestamps();
$table->unique(['document_type', 'block']);
});
}
public function down(): void
{
Schema::dropIfExists('document_templates');
}
};
@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Bilder für die Dokumentvorlagen (Logo, Emblem, ...), referenziert als `{asset:name}`.
*
* Die Bilder liegen in der Datenbank und nicht im Dateisystem: ein Release ist ein `git checkout`,
* alles unter `public/` und `resources/` wird dabei überschrieben. Die Datenbank fasst ein Release
* nicht an, und die Bilder wandern automatisch mit jedem Dump mit. Für das PDF werden sie ohnehin als
* Data-URI eingebettet, `data` wird also direkt so verwendet, wie es gespeichert ist.
*
* Die Namen vergibt die verwaltende Person selbst -- es gibt keine fachlich vorbelegten Slots.
*/
return new class extends Migration {
public function up(): void
{
Schema::create('document_assets', function (Blueprint $table) {
$table->id();
$table->string('name')->unique();
$table->string('label')->nullable();
$table->string('mime');
$table->longText('data');
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('document_assets');
}
};
@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Eigener Absendername für Rechnungen.
*
* `tenants.name` ist ein internes Kürzel ("Wilde Möhre", "Landesunmittelbare Mitglieder"). Auf einer
* Rechnung muss dagegen die vollständige Bezeichnung des Rechnungsstellers stehen, üblicherweise
* inklusive Rechtsform. Bleibt das Feld leer, wird weiterhin `name` verwendet.
*/
return new class extends Migration {
public function up(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->string('invoice_sender_name')->nullable()->after('name');
});
}
public function down(): void
{
Schema::table('tenants', function (Blueprint $table) {
$table->dropColumn('invoice_sender_name');
});
}
};
@@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Nimmt den Absender-Snapshot vom Event zurück.
*
* Der Absender einer Rechnung ist der Mandant, nicht die Veranstaltung. Eingefroren gehörten diese
* Angaben nie: eine Korrektur am Absendernamen oder an der Anschrift hätte sonst auf keiner
* bestehenden Veranstaltung gewirkt -- und damit auf keiner Rechnung, die zu ihr erzeugt wird.
*
* Eingefroren bleibt, was tatsächlich historisch ist: `events.invoice_key` (der Nummernkreis) und
* die Steuergrundlage (`tax_liable`, `vat_rate`, `vat_pricing_mode`, `tax_exemption_*`) -- sie
* beschreibt, wie der Preis zustande kam, und darf sich rückwirkend nicht ändern.
*
* Angelegt wurden die Spalten in `2026_08_27_140020_add_invoice_snapshot_to_events`; die bleibt
* unangetastet, weil sie zusätzlich `invoice_key` anlegt.
*/
return new class extends Migration {
/** @var array<int, string> */
private const array COLUMNS = [
'invoice_sender_name',
'invoice_sender_address_1',
'invoice_sender_address_2',
'invoice_sender_address_3',
'invoice_sender_postcode',
'invoice_sender_city',
'invoice_sender_email',
'invoice_sender_phone',
'invoice_sender_tax_number',
'invoice_sender_vat_id',
];
public function up(): void
{
Schema::table('events', function (Blueprint $table) {
$table->dropColumn(self::COLUMNS);
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table) {
foreach (self::COLUMNS as $column) {
$table->string($column)->nullable();
}
});
}
};
@@ -0,0 +1,42 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Entfernt die Trenner zwischen Jahr, Monat und Veranstaltungsnummer im Rechnungsschlüssel.
*
* vorher WM-V-2026-07-01 -> Rechnungsnummer WM-V-2026-07-01-0005
* nachher WM-V-20260701 -> Rechnungsnummer WM-V-20260701-0005
*
* Die Rechnungsnummer selbst wird nirgends gespeichert, sondern bei jedem Abruf aus `invoice_key`
* und `event_participants.invoice_sequence` zusammengesetzt. Die Umstellung hier wirkt daher
* unmittelbar auf alle Rechnungen; alte und neue Veranstaltungen bleiben im selben Format.
*/
return new class extends Migration {
public function up(): void
{
$this->rewrite('/^(.*-V-)(\d{4})-(\d{2})-(\d{2})$/', '$1$2$3$4');
}
public function down(): void
{
$this->rewrite('/^(.*-V-)(\d{4})(\d{2})(\d{2})$/', '$1$2-$3-$4');
}
private function rewrite(string $pattern, string $replacement): void
{
$events = DB::table('events')
->select('id', 'invoice_key')
->whereNotNull('invoice_key')
->get();
foreach ($events as $event) {
$rewritten = preg_replace($pattern, $replacement, (string) $event->invoice_key);
if ($rewritten !== null && $rewritten !== $event->invoice_key) {
DB::table('events')->where('id', $event->id)->update(['invoice_key' => $rewritten]);
}
}
}
};
File diff suppressed because one or more lines are too long