diff --git a/app/Enums/DokumentKategorie.php b/app/Enums/DokumentKategorie.php new file mode 100644 index 0000000..cdda7a3 --- /dev/null +++ b/app/Enums/DokumentKategorie.php @@ -0,0 +1,18 @@ + */ + use ErbtZuordnung, HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'land' => Land::class, + ]; + } + + protected function zuordnungsEltern(): ?Model + { + return $this->projekt()->first(); + } + + /** @return BelongsTo */ + public function projekt(): BelongsTo + { + return $this->belongsTo(Projekt::class); + } + + /** @return HasMany */ + public function vertraege(): HasMany + { + return $this->hasMany(Vertrag::class); + } +} diff --git a/app/Models/Auftragnehmer.php b/app/Models/Auftragnehmer.php new file mode 100644 index 0000000..fa0128c --- /dev/null +++ b/app/Models/Auftragnehmer.php @@ -0,0 +1,53 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'land' => Land::class, + ]; + } + + /** @return BelongsTo */ + public function mandant(): BelongsTo + { + return $this->belongsTo(Mandant::class); + } + + /** @return HasMany */ + public function vertraege(): HasMany + { + return $this->hasMany(Vertrag::class); + } +} diff --git a/app/Models/Concerns/ErbtZuordnung.php b/app/Models/Concerns/ErbtZuordnung.php new file mode 100644 index 0000000..f3ffd33 --- /dev/null +++ b/app/Models/Concerns/ErbtZuordnung.php @@ -0,0 +1,77 @@ +uebernimmZuordnung(); + }); + } + + protected function uebernimmZuordnung(): void + { + $eltern = $this->zuordnungsEltern(); + + if ($eltern === null) { + return; + } + + $this->gleicheZuordnungAn($eltern, [ + 'mandant_id' => $eltern->getAttribute('mandant_id'), + 'land' => $eltern->getAttribute('land'), + 'projekt_id' => $eltern instanceof Projekt ? $eltern->getKey() : $eltern->getAttribute('projekt_id'), + ]); + } + + /** + * Setzt fehlende Werte und weist abweichende ab. + * + * @param array $werte + */ + protected function gleicheZuordnungAn(Model $anderes, array $werte): void + { + foreach ($werte as $spalte => $wert) { + $eigen = $this->getAttribute($spalte); + + if ($eigen === null) { + $this->setAttribute($spalte, $wert); + } elseif (self::vergleichswert($eigen) !== self::vergleichswert($wert)) { + throw new LogicException(sprintf( + '%s: %s passt nicht zu %s.', + class_basename($this), + $spalte, + class_basename($anderes), + )); + } + } + } + + private static function vergleichswert(mixed $wert): ?string + { + if ($wert instanceof BackedEnum) { + return (string) $wert->value; + } + + return $wert === null ? null : (string) $wert; + } +} diff --git a/app/Models/Dokument.php b/app/Models/Dokument.php new file mode 100644 index 0000000..d1c10ae --- /dev/null +++ b/app/Models/Dokument.php @@ -0,0 +1,95 @@ + */ + use ErbtZuordnung, HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'land' => Land::class, + 'kategorie' => DokumentKategorie::class, + 'vertraulich' => 'boolean', + 'groesse' => 'integer', + ]; + } + + protected function zuordnungsEltern(): ?Model + { + if ($this->dokumentable_type !== null) { + return $this->dokumentable()->getResults(); + } + + return $this->projekt()->first(); + } + + /** @return BelongsTo */ + public function projekt(): BelongsTo + { + return $this->belongsTo(Projekt::class); + } + + /** @return MorphTo */ + public function dokumentable(): MorphTo + { + return $this->morphTo(); + } + + /** @return BelongsTo */ + public function hochgeladenVon(): BelongsTo + { + return $this->belongsTo(User::class, 'hochgeladen_von'); + } + + /** @return BelongsToMany */ + public function freigaben(): BelongsToMany + { + return $this->belongsToMany(User::class, 'dokument_freigaben')->withTimestamps(); + } +} diff --git a/app/Models/Leistungsverzeichnis.php b/app/Models/Leistungsverzeichnis.php new file mode 100644 index 0000000..eb2e46d --- /dev/null +++ b/app/Models/Leistungsverzeichnis.php @@ -0,0 +1,72 @@ + */ + use ErbtZuordnung, HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'land' => Land::class, + 'import_status' => VerarbeitungStatus::class, + ]; + } + + protected function zuordnungsEltern(): ?Model + { + return $this->vertrag()->first(); + } + + /** @return BelongsTo */ + public function projekt(): BelongsTo + { + return $this->belongsTo(Projekt::class); + } + + /** @return BelongsTo */ + public function vertrag(): BelongsTo + { + return $this->belongsTo(Vertrag::class); + } + + /** @return MorphMany */ + public function dokumente(): MorphMany + { + return $this->morphMany(Dokument::class, 'dokumentable'); + } +} diff --git a/app/Models/Mandant.php b/app/Models/Mandant.php new file mode 100644 index 0000000..885a8d2 --- /dev/null +++ b/app/Models/Mandant.php @@ -0,0 +1,45 @@ + */ + use HasFactory; + + /** @return HasMany */ + public function projekte(): HasMany + { + return $this->hasMany(Projekt::class); + } + + /** @return HasMany */ + public function auftragnehmer(): HasMany + { + return $this->hasMany(Auftragnehmer::class); + } + + /** @return HasMany */ + public function benutzer(): HasMany + { + return $this->hasMany(User::class); + } +} diff --git a/app/Models/Nachtrag.php b/app/Models/Nachtrag.php new file mode 100644 index 0000000..8f29a1b --- /dev/null +++ b/app/Models/Nachtrag.php @@ -0,0 +1,90 @@ +|null $mka_bezug + * @property NachtragStatus $status + * @property int|null $zugewiesen_an + * @property CarbonImmutable|null $created_at + * @property CarbonImmutable|null $updated_at + */ +#[Table('nachtraege')] +#[Fillable(['vertrag_id', 'nummer', 'eingang_am', 'betreff', 'mka_bezug', 'status', 'zugewiesen_an'])] +class Nachtrag extends Model +{ + /** @use HasFactory */ + use ErbtZuordnung, HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'land' => Land::class, + 'eingang_am' => 'date', + 'mka_bezug' => 'array', + 'status' => NachtragStatus::class, + ]; + } + + protected function zuordnungsEltern(): ?Model + { + return $this->vertrag()->first(); + } + + /** @return BelongsTo */ + public function projekt(): BelongsTo + { + return $this->belongsTo(Projekt::class); + } + + /** @return BelongsTo */ + public function vertrag(): BelongsTo + { + return $this->belongsTo(Vertrag::class); + } + + /** @return BelongsTo */ + public function zugewiesenAn(): BelongsTo + { + return $this->belongsTo(User::class, 'zugewiesen_an'); + } + + /** @return HasMany */ + public function positionen(): HasMany + { + return $this->hasMany(Nachtragsposition::class)->orderBy('sortierung')->orderBy('id'); + } + + /** @return MorphMany */ + public function dokumente(): MorphMany + { + return $this->morphMany(Dokument::class, 'dokumentable'); + } +} diff --git a/app/Models/Nachtragsposition.php b/app/Models/Nachtragsposition.php new file mode 100644 index 0000000..4287376 --- /dev/null +++ b/app/Models/Nachtragsposition.php @@ -0,0 +1,62 @@ + */ + use ErbtZuordnung, HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'land' => Land::class, + 'menge' => 'decimal:3', + 'einheitspreis' => 'decimal:2', + ]; + } + + protected function zuordnungsEltern(): ?Model + { + return $this->nachtrag()->first(); + } + + /** @return BelongsTo */ + public function nachtrag(): BelongsTo + { + return $this->belongsTo(Nachtrag::class); + } +} diff --git a/app/Models/Projekt.php b/app/Models/Projekt.php new file mode 100644 index 0000000..b35df4d --- /dev/null +++ b/app/Models/Projekt.php @@ -0,0 +1,116 @@ + */ + use HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'land' => Land::class, + 'vertragsgrundlage' => Vertragsgrundlage::class, + 'status' => ProjektStatus::class, + 'suche_andere_vertraege_im_abschnitt' => 'boolean', + 'suche_andere_abschnitte' => 'boolean', + 'suche_nur_gleicher_auftragnehmer' => 'boolean', + ]; + } + + /** @return BelongsTo */ + public function mandant(): BelongsTo + { + return $this->belongsTo(Mandant::class); + } + + /** @return HasMany */ + public function abschnitte(): HasMany + { + return $this->hasMany(Abschnitt::class)->orderBy('sortierung')->orderBy('id'); + } + + /** @return HasMany */ + public function vertraege(): HasMany + { + return $this->hasMany(Vertrag::class); + } + + /** @return HasMany */ + public function nachtraege(): HasMany + { + return $this->hasMany(Nachtrag::class); + } + + /** @return HasMany */ + public function dokumente(): HasMany + { + return $this->hasMany(Dokument::class); + } + + /** @return BelongsToMany */ + public function mitglieder(): BelongsToMany + { + return $this->belongsToMany(User::class, 'projekt_mitglieder') + ->withPivot('rolle') + ->withTimestamps(); + } + + /** + * Rolle des Benutzers in diesem Projekt oder null, wenn er kein Mitglied ist. + */ + public function rolleVon(User $user): ?Projektrolle + { + /** @var string|null $rolle */ + $rolle = $this->mitglieder()->whereKey($user->getKey())->value('projekt_mitglieder.rolle'); + + return $rolle === null ? null : Projektrolle::from($rolle); + } + + public function istArchiviert(): bool + { + return $this->status === ProjektStatus::Archiviert; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index ee71f9c..e739710 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -7,6 +7,8 @@ use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Hidden; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Illuminate\Support\Carbon; @@ -18,6 +20,7 @@ use Spatie\Permission\Traits\HasRoles; /** * @property int $id + * @property int|null $mandant_id * @property string $name * @property string $email * @property string|null $locale @@ -50,6 +53,24 @@ class User extends Authenticatable implements MustVerifyEmail, PasskeyUser ]; } + /** @return BelongsTo */ + public function mandant(): BelongsTo + { + return $this->belongsTo(Mandant::class); + } + + /** + * Projekte, in denen der Benutzer Mitglied ist; die Projektrolle steht im Pivot (rolle). + * + * @return BelongsToMany + */ + public function projekte(): BelongsToMany + { + return $this->belongsToMany(Projekt::class, 'projekt_mitglieder') + ->withPivot('rolle') + ->withTimestamps(); + } + /** * Get the user's initials */ diff --git a/app/Models/Vertrag.php b/app/Models/Vertrag.php new file mode 100644 index 0000000..48ce397 --- /dev/null +++ b/app/Models/Vertrag.php @@ -0,0 +1,105 @@ + */ + use ErbtZuordnung, HasFactory; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'land' => Land::class, + 'vertrag_vom' => 'date', + ]; + } + + protected static function booted(): void + { + static::saving(function (Vertrag $vertrag): void { + $auftragnehmer = $vertrag->auftragnehmer()->first(); + + if ($auftragnehmer !== null) { + $vertrag->gleicheZuordnungAn($auftragnehmer, [ + 'mandant_id' => $auftragnehmer->mandant_id, + 'land' => $auftragnehmer->land, + ]); + } + }); + } + + protected function zuordnungsEltern(): ?Model + { + return $this->abschnitt()->first(); + } + + /** @return BelongsTo */ + public function projekt(): BelongsTo + { + return $this->belongsTo(Projekt::class); + } + + /** @return BelongsTo */ + public function abschnitt(): BelongsTo + { + return $this->belongsTo(Abschnitt::class); + } + + /** @return BelongsTo */ + public function auftragnehmer(): BelongsTo + { + return $this->belongsTo(Auftragnehmer::class); + } + + /** @return HasMany */ + public function leistungsverzeichnisse(): HasMany + { + return $this->hasMany(Leistungsverzeichnis::class); + } + + /** @return HasMany */ + public function nachtraege(): HasMany + { + return $this->hasMany(Nachtrag::class); + } + + /** @return MorphMany */ + public function dokumente(): MorphMany + { + return $this->morphMany(Dokument::class, 'dokumentable'); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index f1525e9..325d87d 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,7 +2,13 @@ namespace App\Providers; +use App\Models\Leistungsverzeichnis; +use App\Models\Nachtrag; +use App\Models\Projekt; +use App\Models\User; +use App\Models\Vertrag; use Carbon\CarbonImmutable; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Support\Facades\Date; use Illuminate\Support\Facades\DB; use Illuminate\Support\ServiceProvider; @@ -33,6 +39,15 @@ class AppServiceProvider extends ServiceProvider { Date::use(CarbonImmutable::class); + // Feste Namen für polymorphe Beziehungen statt Klassennamen in der Datenbank. + Relation::enforceMorphMap([ + 'user' => User::class, + 'projekt' => Projekt::class, + 'vertrag' => Vertrag::class, + 'leistungsverzeichnis' => Leistungsverzeichnis::class, + 'nachtrag' => Nachtrag::class, + ]); + DB::prohibitDestructiveCommands( app()->isProduction(), ); diff --git a/database/factories/AbschnittFactory.php b/database/factories/AbschnittFactory.php new file mode 100644 index 0000000..7a53b82 --- /dev/null +++ b/database/factories/AbschnittFactory.php @@ -0,0 +1,25 @@ + + */ +class AbschnittFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'projekt_id' => Projekt::factory(), + 'bezeichnung' => 'PFA '.fake()->unique()->numberBetween(1, 999999), + 'sortierung' => 0, + ]; + } +} diff --git a/database/factories/AuftragnehmerFactory.php b/database/factories/AuftragnehmerFactory.php new file mode 100644 index 0000000..3c70c2b --- /dev/null +++ b/database/factories/AuftragnehmerFactory.php @@ -0,0 +1,26 @@ + + */ +class AuftragnehmerFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'mandant_id' => Mandant::factory(), + 'land' => Land::DE, + 'name' => fake()->unique()->company(), + ]; + } +} diff --git a/database/factories/DokumentFactory.php b/database/factories/DokumentFactory.php new file mode 100644 index 0000000..8feef92 --- /dev/null +++ b/database/factories/DokumentFactory.php @@ -0,0 +1,53 @@ + + */ +class DokumentFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + $uuid = (string) Str::uuid(); + + return [ + 'projekt_id' => Projekt::factory(), + 'kategorie' => DokumentKategorie::Sonstiges, + 'dateiname' => 'beispiel.pdf', + 'pfad' => 'dokumente/'.$uuid.'.pdf', + 'mime_type' => 'application/pdf', + 'groesse' => fake()->numberBetween(10_000, 5_000_000), + 'sha256' => hash('sha256', $uuid), + 'vertraulich' => false, + ]; + } + + /** + * Dokument zu einem LV, Nachtrag oder Vertrag; Projekt, Mandant und Land kommen von dort. + */ + public function zu(Model $dokumentable, DokumentKategorie $kategorie): static + { + return $this->state(fn (array $attributes) => [ + 'projekt_id' => null, + 'dokumentable_type' => $dokumentable->getMorphClass(), + 'dokumentable_id' => $dokumentable->getKey(), + 'kategorie' => $kategorie, + ]); + } + + public function vertraulich(): static + { + return $this->state(fn (array $attributes) => ['vertraulich' => true]); + } +} diff --git a/database/factories/LeistungsverzeichnisFactory.php b/database/factories/LeistungsverzeichnisFactory.php new file mode 100644 index 0000000..a3afab5 --- /dev/null +++ b/database/factories/LeistungsverzeichnisFactory.php @@ -0,0 +1,28 @@ + + */ +class LeistungsverzeichnisFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'vertrag_id' => Vertrag::factory(), + 'bezeichnung' => sprintf('LV %02d %s', fake()->numberBetween(1, 35), fake()->randomElement([ + 'Baustelleneinrichtung', 'Oberbau', 'Entwässerung', 'Kabeltiefbau', 'Erdbau', + ])), + 'import_status' => VerarbeitungStatus::Wartet, + ]; + } +} diff --git a/database/factories/MandantFactory.php b/database/factories/MandantFactory.php new file mode 100644 index 0000000..502fbe1 --- /dev/null +++ b/database/factories/MandantFactory.php @@ -0,0 +1,22 @@ + + */ +class MandantFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'name' => fake()->unique()->company(), + ]; + } +} diff --git a/database/factories/NachtragFactory.php b/database/factories/NachtragFactory.php new file mode 100644 index 0000000..cb0ca93 --- /dev/null +++ b/database/factories/NachtragFactory.php @@ -0,0 +1,29 @@ + + */ +class NachtragFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'vertrag_id' => Vertrag::factory(), + 'nummer' => 'N'.fake()->unique()->numberBetween(1, 999999), + 'eingang_am' => fake()->dateTimeBetween('-3 months'), + 'betreff' => fake()->sentence(4), + 'mka_bezug' => ['MKA '.fake()->numberBetween(1, 200)], + 'status' => NachtragStatus::Eingegangen, + ]; + } +} diff --git a/database/factories/NachtragspositionFactory.php b/database/factories/NachtragspositionFactory.php new file mode 100644 index 0000000..cea4151 --- /dev/null +++ b/database/factories/NachtragspositionFactory.php @@ -0,0 +1,30 @@ + + */ +class NachtragspositionFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'nachtrag_id' => Nachtrag::factory(), + 'oz' => sprintf('01.%07d', fake()->unique()->numberBetween(1, 9999999)), + 'kurztext' => fake()->sentence(5), + 'langtext' => fake()->paragraph(), + 'menge' => fake()->randomFloat(3, 1, 2000), + 'einheit' => fake()->randomElement(['m', 'm²', 'm³', 'St', 'psch']), + 'einheitspreis' => fake()->randomFloat(2, 1, 500), + 'sortierung' => 0, + ]; + } +} diff --git a/database/factories/ProjektFactory.php b/database/factories/ProjektFactory.php new file mode 100644 index 0000000..4d40f91 --- /dev/null +++ b/database/factories/ProjektFactory.php @@ -0,0 +1,38 @@ + + */ +class ProjektFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'mandant_id' => Mandant::factory(), + 'land' => Land::DE, + 'name' => 'Elektrifizierung '.fake()->city(), + 'nummer' => fake()->numerify('P-####'), + 'auftraggeber' => 'DB InfraGO AG', + 'vertragsgrundlage' => Vertragsgrundlage::VobB, + 'abschnitt_bezeichnung' => 'PFA', + 'status' => ProjektStatus::Aktiv, + ]; + } + + public function archiviert(): static + { + return $this->state(fn (array $attributes) => ['status' => ProjektStatus::Archiviert]); + } +} diff --git a/database/factories/VertragFactory.php b/database/factories/VertragFactory.php new file mode 100644 index 0000000..63680ac --- /dev/null +++ b/database/factories/VertragFactory.php @@ -0,0 +1,36 @@ + + */ +class VertragFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'abschnitt_id' => Abschnitt::factory(), + // Auftragnehmer im selben Mandanten und Land wie der Abschnitt + 'auftragnehmer_id' => function (array $attributes) { + $abschnitt = Abschnitt::query()->whereKey($attributes['abschnitt_id'])->firstOrFail(); + + return Auftragnehmer::factory()->state([ + 'mandant_id' => $abschnitt->mandant_id, + 'land' => $abschnitt->land, + ]); + }, + 'bezeichnung' => fake()->randomElement(['Gleisbau', 'Tunnelbau', 'Oberleitung', 'Kabeltiefbau']), + 'vertragsnummer' => fake()->numerify('V-#####'), + 'vertrag_vom' => fake()->dateTimeBetween('-2 years', '-1 month'), + ]; + } +} diff --git a/database/migrations/2026_10_05_080000_create_fachliche_grundtabellen.php b/database/migrations/2026_10_05_080000_create_fachliche_grundtabellen.php new file mode 100644 index 0000000..67147ab --- /dev/null +++ b/database/migrations/2026_10_05_080000_create_fachliche_grundtabellen.php @@ -0,0 +1,183 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + }); + + Schema::table('users', function (Blueprint $table) { + $table->foreignId('mandant_id')->nullable()->after('id')->constrained('mandanten')->restrictOnDelete(); + }); + + Schema::create('auftragnehmer', function (Blueprint $table) { + $table->id(); + $table->foreignId('mandant_id')->constrained('mandanten')->restrictOnDelete(); + $table->char('land', 2); + $table->string('name'); + $table->timestamps(); + + $table->unique(['mandant_id', 'land', 'name']); + }); + + Schema::create('projekte', function (Blueprint $table) { + $table->id(); + $table->foreignId('mandant_id')->constrained('mandanten')->restrictOnDelete(); + $table->char('land', 2); + $table->string('name'); + $table->string('nummer', 100)->nullable(); + $table->string('auftraggeber'); + $table->string('vertragsgrundlage', 20)->default('vob_b'); + $table->string('abschnitt_bezeichnung', 50)->default('PFA'); + $table->string('status', 20)->default('aktiv'); + $table->boolean('suche_andere_vertraege_im_abschnitt')->default(false); + $table->boolean('suche_andere_abschnitte')->default(true); + $table->boolean('suche_nur_gleicher_auftragnehmer')->default(false); + $table->timestamps(); + + $table->index(['mandant_id', 'land', 'status']); + }); + + Schema::create('projekt_mitglieder', function (Blueprint $table) { + $table->foreignId('projekt_id')->constrained('projekte')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->string('rolle', 20); + $table->timestamps(); + + $table->primary(['projekt_id', 'user_id']); + }); + + Schema::create('abschnitte', function (Blueprint $table) { + $table->id(); + $table->foreignId('mandant_id')->constrained('mandanten')->restrictOnDelete(); + $table->char('land', 2); + $table->foreignId('projekt_id')->constrained('projekte')->restrictOnDelete(); + $table->string('bezeichnung', 100); + $table->unsignedSmallInteger('sortierung')->default(0); + $table->timestamps(); + + $table->unique(['projekt_id', 'bezeichnung']); + }); + + Schema::create('vertraege', function (Blueprint $table) { + $table->id(); + $table->foreignId('mandant_id')->constrained('mandanten')->restrictOnDelete(); + $table->char('land', 2); + $table->foreignId('projekt_id')->constrained('projekte')->restrictOnDelete(); + $table->foreignId('abschnitt_id')->constrained('abschnitte')->restrictOnDelete(); + $table->foreignId('auftragnehmer_id')->constrained('auftragnehmer')->restrictOnDelete(); + $table->string('bezeichnung', 150); + $table->string('vertragsnummer', 100)->nullable(); + $table->date('vertrag_vom')->nullable(); + $table->timestamps(); + }); + + Schema::create('leistungsverzeichnisse', function (Blueprint $table) { + $table->id(); + $table->foreignId('mandant_id')->constrained('mandanten')->restrictOnDelete(); + $table->char('land', 2); + $table->foreignId('projekt_id')->constrained('projekte')->restrictOnDelete(); + $table->foreignId('vertrag_id')->constrained('vertraege')->restrictOnDelete(); + $table->string('bezeichnung'); + $table->string('import_status', 20)->default('wartet'); + $table->text('import_fehler')->nullable(); + $table->timestamps(); + }); + + Schema::create('nachtraege', function (Blueprint $table) { + $table->id(); + $table->foreignId('mandant_id')->constrained('mandanten')->restrictOnDelete(); + $table->char('land', 2); + $table->foreignId('projekt_id')->constrained('projekte')->restrictOnDelete(); + $table->foreignId('vertrag_id')->constrained('vertraege')->restrictOnDelete(); + $table->string('nummer', 50); + $table->date('eingang_am'); + $table->string('betreff'); + $table->json('mka_bezug')->nullable(); + $table->string('status', 20)->default('eingegangen'); + $table->foreignId('zugewiesen_an')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->unique(['vertrag_id', 'nummer']); + $table->index(['projekt_id', 'status']); + }); + + Schema::create('nachtragspositionen', function (Blueprint $table) { + $table->id(); + $table->foreignId('mandant_id')->constrained('mandanten')->restrictOnDelete(); + $table->char('land', 2); + $table->foreignId('projekt_id')->constrained('projekte')->restrictOnDelete(); + $table->foreignId('nachtrag_id')->constrained('nachtraege')->cascadeOnDelete(); + $table->string('oz', 50); + $table->string('kurztext', 500); + $table->text('langtext')->nullable(); + $table->decimal('menge', 15, 3)->nullable(); + $table->string('einheit', 20)->nullable(); + $table->decimal('einheitspreis', 15, 2)->nullable(); + $table->unsignedInteger('sortierung')->default(0); + $table->timestamps(); + + $table->unique(['nachtrag_id', 'oz']); + }); + + Schema::create('dokumente', function (Blueprint $table) { + $table->id(); + $table->foreignId('mandant_id')->constrained('mandanten')->restrictOnDelete(); + $table->char('land', 2); + $table->foreignId('projekt_id')->nullable()->constrained('projekte')->restrictOnDelete(); + $table->nullableMorphs('dokumentable'); + $table->string('kategorie', 40); + $table->string('dateiname'); + $table->string('pfad', 500); + $table->string('mime_type', 150); + $table->unsignedBigInteger('groesse'); + $table->char('sha256', 64); + $table->boolean('vertraulich')->default(false); + $table->foreignId('hochgeladen_von')->nullable()->constrained('users')->nullOnDelete(); + $table->timestamps(); + + $table->index(['projekt_id', 'kategorie']); + $table->index('sha256'); + }); + + Schema::create('dokument_freigaben', function (Blueprint $table) { + $table->foreignId('dokument_id')->constrained('dokumente')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->timestamps(); + + $table->primary(['dokument_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('dokument_freigaben'); + Schema::dropIfExists('dokumente'); + Schema::dropIfExists('nachtragspositionen'); + Schema::dropIfExists('nachtraege'); + Schema::dropIfExists('leistungsverzeichnisse'); + Schema::dropIfExists('vertraege'); + Schema::dropIfExists('abschnitte'); + Schema::dropIfExists('projekt_mitglieder'); + Schema::dropIfExists('projekte'); + Schema::dropIfExists('auftragnehmer'); + + Schema::table('users', function (Blueprint $table) { + $table->dropConstrainedForeignId('mandant_id'); + }); + + Schema::dropIfExists('mandanten'); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 6b901f8..a0074fb 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -21,5 +21,9 @@ class DatabaseSeeder extends Seeder 'name' => 'Test User', 'email' => 'test@example.com', ]); + + if (app()->isLocal()) { + $this->call(DemoSeeder::class); + } } } diff --git a/database/seeders/DemoSeeder.php b/database/seeders/DemoSeeder.php new file mode 100644 index 0000000..3095b45 --- /dev/null +++ b/database/seeders/DemoSeeder.php @@ -0,0 +1,127 @@ +where('name', self::PROJEKT)->exists()) { + $this->command->info('Demodaten sind schon vorhanden.'); + + return; + } + + $mandant = Mandant::query()->firstOrCreate(['name' => 'Demo-Mandant']); + + $projekt = Projekt::query()->create([ + 'mandant_id' => $mandant->id, + 'land' => Land::DE, + 'name' => self::PROJEKT, + 'nummer' => 'P-2025-01', + 'auftraggeber' => 'DB InfraGO AG', + ]); + + $an = fn (string $name) => Auftragnehmer::query()->firstOrCreate( + ['mandant_id' => $mandant->id, 'land' => Land::DE, 'name' => $name], + ); + $arge = $an('ARGE Musterbau'); + $tunnel = $an('Tunnelbau Beispiel GmbH'); + $oberleitung = $an('Oberleitung Nord GmbH'); + + $abschnitte = []; + foreach ([1, 2, 3, 4] as $nr) { + $abschnitte[$nr] = Abschnitt::query()->create([ + 'projekt_id' => $projekt->id, + 'bezeichnung' => 'PFA '.$nr, + 'sortierung' => $nr, + ]); + } + + $vertrag = fn (Abschnitt $abschnitt, Auftragnehmer $auftragnehmer, string $bezeichnung) => Vertrag::query()->create([ + 'abschnitt_id' => $abschnitt->id, + 'auftragnehmer_id' => $auftragnehmer->id, + 'bezeichnung' => $bezeichnung, + 'vertrag_vom' => '2025-03-01', + ]); + + $vertrag($abschnitte[1], $oberleitung, 'Oberleitung'); + $vertrag($abschnitte[2], $oberleitung, 'Oberleitung'); + $gleisbauPfa3 = $vertrag($abschnitte[3], $arge, 'Gleisbau'); + $vertrag($abschnitte[3], $tunnel, 'Tunnelbau'); + $vertrag($abschnitte[3], $oberleitung, 'Oberleitung'); + $gleisbauPfa4 = $vertrag($abschnitte[4], $arge, 'Gleisbau'); + $vertrag($abschnitte[4], $tunnel, 'Tunnelbau'); + $vertrag($abschnitte[4], $oberleitung, 'Oberleitung'); + + foreach (['LV 01 Baustelleneinrichtung', 'LV 03 Oberbau', 'LV 05 Entwässerung', 'LV 07 Kabeltiefbau'] as $bezeichnung) { + $gleisbauPfa4->leistungsverzeichnisse()->create(['bezeichnung' => $bezeichnung, 'import_status' => VerarbeitungStatus::Wartet]); + $gleisbauPfa3->leistungsverzeichnisse()->create(['bezeichnung' => $bezeichnung, 'import_status' => VerarbeitungStatus::Wartet]); + } + + $this->nachtrag($gleisbauPfa4, 'N14', 'Lieferung besohlter Schwellen', '2026-09-29', ['MKA 112'], [ + ['N14.0010', 'Schwelle B70 mit Besohlung liefern', 420, 'St', 137.50], + ['N14.0020', 'Zulage Verlegen besohlter Schwellen', 420, 'St', 12.40], + ]); + $this->nachtrag($gleisbauPfa4, 'N16', 'Kamerabefahrung und Spülen der Entwässerungsleitungen', '2026-09-15', ['MKA 98'], [ + ['N16.0010', 'Kamerabefahrung Leitungen DN 160', 850, 'm', 3.20], + ['N16.0020', 'Leitungen DN 160 spülen', 850, 'm', 2.10], + ]); + $this->nachtrag($gleisbauPfa4, 'N21', 'Kabelschutzrohr DN 110 verlegen', '2026-10-01', ['MKA 131', 'MKA 133'], [ + ['N21.0010', 'Kabelschutzrohr DN 110 verlegen', 1200, 'm', 36.80], + ]); + + $testbenutzer = User::query()->where('email', 'test@example.com')->first(); + if ($testbenutzer !== null) { + $testbenutzer->mandant()->associate($mandant)->save(); + $projekt->mitglieder()->attach($testbenutzer, ['rolle' => Projektrolle::Projektleiter->value]); + } + } + + /** + * @param list $mka + * @param list $positionen + */ + private function nachtrag(Vertrag $vertrag, string $nummer, string $betreff, string $eingang, array $mka, array $positionen): Nachtrag + { + $nachtrag = $vertrag->nachtraege()->create([ + 'nummer' => $nummer, + 'betreff' => $betreff, + 'eingang_am' => $eingang, + 'mka_bezug' => $mka, + 'status' => NachtragStatus::Eingegangen, + ]); + + foreach ($positionen as $i => [$oz, $kurztext, $menge, $einheit, $ep]) { + $nachtrag->positionen()->create([ + 'oz' => $oz, + 'kurztext' => $kurztext, + 'menge' => $menge, + 'einheit' => $einheit, + 'einheitspreis' => $ep, + 'sortierung' => $i, + ]); + } + + return $nachtrag; + } +} diff --git a/docs/datenmodell.md b/docs/datenmodell.md new file mode 100644 index 0000000..cdff0f5 --- /dev/null +++ b/docs/datenmodell.md @@ -0,0 +1,62 @@ +# KI-BauIN – Datenmodell (Kern) + +Stand: 5. Oktober 2026. Grundlage für Phase 1. Später ergänzt werden LV-Positionen und +Vorbemerkungen (GAEB-Import, AP5), Prüfung, Feedback und Regeln (AP6–AP8) sowie die Einstellungen +der KI-Schnittstelle (AP3). + +## Übersicht + +```mermaid +erDiagram + MANDANT ||--o{ PROJEKT : hat + MANDANT ||--o{ AUFTRAGNEHMER : hat + MANDANT ||--o{ USER : hat + PROJEKT ||--o{ ABSCHNITT : "gliedert sich in (PFA)" + ABSCHNITT ||--o{ VERTRAG : enthält + AUFTRAGNEHMER ||--o{ VERTRAG : "ist Partner von" + VERTRAG ||--o{ LEISTUNGSVERZEICHNIS : "hat (oft 20–35)" + VERTRAG ||--o{ NACHTRAG : "erhält" + NACHTRAG ||--o{ NACHTRAGSPOSITION : hat + PROJEKT ||--o{ DOKUMENT : "enthält" + LEISTUNGSVERZEICHNIS ||--o{ DOKUMENT : "X86, D86, PDF" + NACHTRAG ||--o{ DOKUMENT : "Nachtrags-LV, Anschreiben, Anlagen" + PROJEKT }o--o{ USER : "Mitglieder mit Projektrolle" + DOKUMENT }o--o{ USER : "Freigaben (vertraulich)" +``` + +| Modell | Tabelle | Inhalt | +|---|---|---| +| `Mandant` | `mandanten` | Kunde der Anwendung (im Pilot nur BauIn) | +| `Projekt` | `projekte` | z. B. „Elektrifizierung Musterbahn“; Auftraggeber, Vertragsgrundlage, Status, Suchreihenfolge | +| `Abschnitt` | `abschnitte` | PFA bzw. Abschnitt; die Bezeichnung „PFA“ steht am Projekt (`abschnitt_bezeichnung`) | +| `Auftragnehmer` | `auftragnehmer` | Firma oder ARGE; kann in mehreren PFAs Verträge haben | +| `Vertrag` | `vertraege` | Auftrag an einen Auftragnehmer in einem Abschnitt, z. B. „Gleisbau“ | +| `Leistungsverzeichnis` | `leistungsverzeichnisse` | ein LV des Vertrags; Dateien als Dokumente, Importstatus | +| `Nachtrag` | `nachtraege` | Nr., Eingang, Betreff, MKA-Bezug (Liste), Status, zugewiesen an | +| `Nachtragsposition` | `nachtragspositionen` | OZ, Kurztext, Langtext, Menge, Einheit, EP (nur Information) | +| `Dokument` | `dokumente` | hochgeladene Datei; gehört zu Projekt und optional zu LV, Nachtrag usw.; vertraulich ja/nein | + +Pivot-Tabellen: `projekt_mitglieder` (Benutzer, Projekt, Projektrolle) und `dokument_freigaben` +(vertrauliche Dokumente für benannte Personen). + +## Festlegungen + +- **Mandant, Land und Projekt an jedem Fachdatensatz.** `mandant_id` und `land` stehen an allen + Fachtabellen; `projekt_id` an allem unterhalb des Projekts. Die Werte werden beim Speichern vom + übergeordneten Datensatz übernommen (`App\Models\Concerns\ErbtZuordnung`). Ein Datensatz, der + auf einen Datensatz eines anderen Mandanten, Landes oder Projekts zeigt, wird abgewiesen. Das + gilt auch für Vertrag und Auftragnehmer. +- **Rollen:** Globale Rollen (Administrator, Regelverantwortlicher) über Spatie + laravel-permission. Projektrollen (Projektleiter, Bearbeiter, Leser) in `projekt_mitglieder`, + weil sie je Projekt verschieden sind. Der Teams-Modus von Spatie bleibt aus. +- **MKA nur als Bezug:** `nachtraege.mka_bezug` ist eine Liste von Nummern (Fragenkatalog A8). +- **Dokumente polymorph:** `dokumentable` zeigt auf LV, Nachtrag oder Vertrag; `projekt_id` + bleibt für die Rechteprüfung immer gesetzt. Ohne Projekt ist es ein allgemeines Dokument + (Einstellungen). Die Morph-Namen sind fest (`projekt`, `vertrag`, `leistungsverzeichnis`, + `nachtrag`, `user`), damit Klassennamen nicht in der Datenbank stehen. +- **Löschen:** Fachdaten werden nicht kaskadierend gelöscht; ein Projekt mit Abschnitten lässt + sich nicht löschen, sondern wird archiviert. Ausnahmen: Nachtragspositionen hängen am Nachtrag, + Pivot-Einträge an ihren beiden Seiten. +- **Suchreihenfolge je Projekt (vorläufig, Fragenkatalog A10):** andere Verträge im selben + Abschnitt einbeziehen = nein, andere Abschnitte = ja, nur gleicher Auftragnehmer = nein. +- **Beträge** als Dezimalzahlen (Menge 15,3, EP 15,2); Preise sind in Phase 1 nur Information. diff --git a/docs/projektplan.md b/docs/projektplan.md index 8ff32c6..b58cd53 100644 --- a/docs/projektplan.md +++ b/docs/projektplan.md @@ -209,8 +209,8 @@ PDF-Anzeige mit Sprung zur Position, Massen-Upload, Suchreihenfolge über PFAs u - [ ] API-Schlüssel für die Entwicklung bei ITM anfordern - [ ] **Meilisearch-Test Teil 1** (erzeugte 4.096-dim. Vektoren, z. B. 50.000 Stück): Indexierungszeit, Antwortzeit mit Filtern, RAM und Platte; jeweils ohne und mit binärer Quantisierung -- [ ] Datenmodell Kern: Mandant, Land, Projekt, PFA, Vertrag, LV, Dokument (Kategorie, - Vertraulichkeit), Nachtrag, Nachtragsposition, Mitgliedschaften +- [x] Datenmodell Kern: Mandant, Land, Projekt, PFA, Vertrag, LV, Dokument (Kategorie, + Vertraulichkeit), Nachtrag, Nachtragsposition, Mitgliedschaften (`docs/datenmodell.md`, Demodaten) - [ ] Benutzerverwaltung, Rollen und Rechte (Spatie, ggf. Teams je Projekt) - [ ] GAEB-X86-Test mit öffentlichen Beispieldateien, Bibliothek auswählen - [ ] CI in Gitea Actions: Tests, Pint, PHPStan diff --git a/tests/Feature/Datenmodell/BeziehungenTest.php b/tests/Feature/Datenmodell/BeziehungenTest.php new file mode 100644 index 0000000..bb61784 --- /dev/null +++ b/tests/Feature/Datenmodell/BeziehungenTest.php @@ -0,0 +1,98 @@ +create(); + $auftragnehmer = Auftragnehmer::factory()->create(['mandant_id' => $projekt->mandant_id]); + [$pfa3, $pfa4] = Abschnitt::factory()->count(2)->create(['projekt_id' => $projekt->id]); + + Vertrag::factory()->create(['abschnitt_id' => $pfa3->id, 'auftragnehmer_id' => $auftragnehmer->id]); + Vertrag::factory()->create(['abschnitt_id' => $pfa4->id, 'auftragnehmer_id' => $auftragnehmer->id]); + + $this->assertEqualsCanonicalizing( + [$pfa3->id, $pfa4->id], + $auftragnehmer->vertraege()->pluck('abschnitt_id')->all(), + ); + $this->assertSame(2, $projekt->vertraege()->count()); + } + + public function test_nachtrag_hat_mka_bezug_status_und_positionen_in_reihenfolge(): void + { + $nachtrag = Nachtrag::factory()->create(['mka_bezug' => ['MKA 131', 'MKA 133']]); + Nachtragsposition::factory()->create(['nachtrag_id' => $nachtrag->id, 'oz' => 'N1.0020', 'sortierung' => 2]); + Nachtragsposition::factory()->create(['nachtrag_id' => $nachtrag->id, 'oz' => 'N1.0010', 'sortierung' => 1]); + + $nachtrag = $nachtrag->fresh(); + + $this->assertNotNull($nachtrag); + $this->assertSame(['MKA 131', 'MKA 133'], $nachtrag->mka_bezug); + $this->assertSame(NachtragStatus::Eingegangen, $nachtrag->status); + $this->assertSame(['N1.0010', 'N1.0020'], $nachtrag->positionen->pluck('oz')->all()); + } + + public function test_nachtragsnummer_ist_je_vertrag_eindeutig(): void + { + $vertrag = Vertrag::factory()->create(); + Nachtrag::factory()->create(['vertrag_id' => $vertrag->id, 'nummer' => 'N14']); + + $this->expectException(QueryException::class); + + Nachtrag::factory()->create(['vertrag_id' => $vertrag->id, 'nummer' => 'N14']); + } + + public function test_projektmitglieder_haben_eine_rolle(): void + { + $projekt = Projekt::factory()->create(); + $leiterin = User::factory()->create(); + $leser = User::factory()->create(); + $aussenstehend = User::factory()->create(); + + $projekt->mitglieder()->attach($leiterin, ['rolle' => Projektrolle::Projektleiter->value]); + $projekt->mitglieder()->attach($leser, ['rolle' => Projektrolle::Leser->value]); + + $this->assertSame(Projektrolle::Projektleiter, $projekt->rolleVon($leiterin)); + $this->assertSame(Projektrolle::Leser, $projekt->rolleVon($leser)); + $this->assertNull($projekt->rolleVon($aussenstehend)); + $this->assertTrue($leiterin->projekte()->whereKey($projekt->id)->exists()); + } + + public function test_vertrauliches_dokument_hat_benannte_freigaben(): void + { + $nachtrag = Nachtrag::factory()->create(); + $dokument = Dokument::factory()->zu($nachtrag, DokumentKategorie::Anlage)->vertraulich()->create(); + $berechtigt = User::factory()->create(); + + $dokument->freigaben()->attach($berechtigt); + + $this->assertTrue($dokument->vertraulich); + $this->assertSame([$berechtigt->id], $dokument->freigaben()->pluck('users.id')->all()); + $this->assertSame(1, $nachtrag->dokumente()->count()); + } + + public function test_archiviertes_projekt_wird_erkannt(): void + { + $this->assertTrue(Projekt::factory()->archiviert()->create()->istArchiviert()); + $this->assertFalse(Projekt::factory()->create()->istArchiviert()); + } +} diff --git a/tests/Feature/Datenmodell/DemoSeederTest.php b/tests/Feature/Datenmodell/DemoSeederTest.php new file mode 100644 index 0000000..835f019 --- /dev/null +++ b/tests/Feature/Datenmodell/DemoSeederTest.php @@ -0,0 +1,32 @@ +create(['email' => 'test@example.com']); + + $this->seed(DemoSeeder::class); + $this->seed(DemoSeeder::class); + + $projekt = Projekt::query()->where('name', DemoSeeder::PROJEKT)->sole(); + $this->assertSame(['PFA 1', 'PFA 2', 'PFA 3', 'PFA 4'], $projekt->abschnitte->pluck('bezeichnung')->all()); + $this->assertSame(8, $projekt->vertraege()->count()); + $this->assertSame(['N14', 'N16', 'N21'], $projekt->nachtraege()->orderBy('nummer')->pluck('nummer')->all()); + $this->assertSame(['MKA 131', 'MKA 133'], Nachtrag::query()->where('nummer', 'N21')->sole()->mka_bezug); + $this->assertSame(Projektrolle::Projektleiter, $projekt->rolleVon($testbenutzer)); + $this->assertSame($projekt->mandant_id, $testbenutzer->fresh()?->mandant_id); + } +} diff --git a/tests/Feature/Datenmodell/LoeschenTest.php b/tests/Feature/Datenmodell/LoeschenTest.php new file mode 100644 index 0000000..ff7e406 --- /dev/null +++ b/tests/Feature/Datenmodell/LoeschenTest.php @@ -0,0 +1,49 @@ +create(); + + $this->expectException(QueryException::class); + + $abschnitt->projekt->delete(); + } + + public function test_nachtragspositionen_werden_mit_dem_nachtrag_geloescht(): void + { + $nachtrag = Nachtrag::factory()->create(); + Nachtragsposition::factory()->count(3)->create(['nachtrag_id' => $nachtrag->id]); + + $nachtrag->delete(); + + $this->assertSame(0, Nachtragsposition::query()->count()); + } + + public function test_geloeschter_benutzer_verschwindet_aus_projekt_und_zuweisung(): void + { + $projekt = Projekt::factory()->create(); + $benutzer = User::factory()->create(); + $projekt->mitglieder()->attach($benutzer, ['rolle' => 'bearbeiter']); + $nachtrag = Nachtrag::factory()->create(['zugewiesen_an' => $benutzer->id]); + + $benutzer->delete(); + + $this->assertSame(0, $projekt->mitglieder()->count()); + $this->assertNull($nachtrag->fresh()?->zugewiesen_an); + } +} diff --git a/tests/Feature/Datenmodell/ZuordnungTest.php b/tests/Feature/Datenmodell/ZuordnungTest.php new file mode 100644 index 0000000..e5aab9e --- /dev/null +++ b/tests/Feature/Datenmodell/ZuordnungTest.php @@ -0,0 +1,113 @@ +create(['land' => Land::AT]); + $abschnitt = Abschnitt::factory()->create(['projekt_id' => $projekt->id]); + $vertrag = Vertrag::factory()->create(['abschnitt_id' => $abschnitt->id]); + $lv = Leistungsverzeichnis::factory()->create(['vertrag_id' => $vertrag->id]); + $nachtrag = Nachtrag::factory()->create(['vertrag_id' => $vertrag->id]); + $position = Nachtragsposition::factory()->create(['nachtrag_id' => $nachtrag->id]); + + foreach ([$abschnitt, $vertrag, $lv, $nachtrag, $position] as $datensatz) { + $this->assertSame($projekt->mandant_id, $datensatz->mandant_id, $datensatz::class); + $this->assertSame(Land::AT, $datensatz->land, $datensatz::class); + $this->assertSame($projekt->id, $datensatz->projekt_id, $datensatz::class); + } + } + + public function test_dokument_uebernimmt_die_zuordnung_vom_lv(): void + { + $lv = Leistungsverzeichnis::factory()->create(); + + $dokument = Dokument::factory()->zu($lv, DokumentKategorie::LvX86)->create(); + + $this->assertSame($lv->projekt_id, $dokument->projekt_id); + $this->assertSame($lv->mandant_id, $dokument->mandant_id); + $this->assertTrue($dokument->dokumentable?->is($lv)); + $this->assertSame('leistungsverzeichnis', $dokument->dokumentable_type); + } + + public function test_allgemeines_dokument_ohne_projekt_braucht_mandant_und_land(): void + { + $mandant = Mandant::factory()->create(); + + $dokument = Dokument::factory()->create([ + 'projekt_id' => null, + 'mandant_id' => $mandant->id, + 'land' => Land::DE, + 'kategorie' => DokumentKategorie::Vorlage, + ]); + + $this->assertNull($dokument->projekt_id); + $this->assertSame($mandant->id, $dokument->mandant_id); + } + + public function test_vertrag_mit_auftragnehmer_eines_anderen_mandanten_wird_abgewiesen(): void + { + $abschnitt = Abschnitt::factory()->create(); + $fremderAuftragnehmer = Auftragnehmer::factory()->create(); + + $this->expectException(LogicException::class); + + Vertrag::factory()->create([ + 'abschnitt_id' => $abschnitt->id, + 'auftragnehmer_id' => $fremderAuftragnehmer->id, + ]); + } + + public function test_abweichender_mandant_beim_anlegen_wird_abgewiesen(): void + { + $projekt = Projekt::factory()->create(); + $andererMandant = Mandant::factory()->create(); + + $this->expectException(LogicException::class); + + Abschnitt::factory()->create([ + 'projekt_id' => $projekt->id, + 'mandant_id' => $andererMandant->id, + ]); + } + + public function test_nachtrag_laesst_sich_nicht_in_ein_anderes_projekt_verschieben(): void + { + $nachtrag = Nachtrag::factory()->create(); + $vertragInAnderemProjekt = Vertrag::factory()->create(); + + $this->expectException(LogicException::class); + + $nachtrag->update(['vertrag_id' => $vertragInAnderemProjekt->id]); + } + + public function test_nachtrag_darf_innerhalb_des_projekts_den_vertrag_wechseln(): void + { + $nachtrag = Nachtrag::factory()->create(); + $abschnitt = $nachtrag->vertrag->abschnitt; + $andererVertrag = Vertrag::factory()->create(['abschnitt_id' => $abschnitt->id]); + + $nachtrag->update(['vertrag_id' => $andererVertrag->id]); + + $this->assertSame($andererVertrag->id, $nachtrag->fresh()?->vertrag_id); + } +}