feat(import): Abgabeverträge als SaleContract-Datensätze importieren

Die ~1095 geparsten Verträge werden jetzt als echte SaleContract-Records erzeugt,
sodass die Verträge-Seite gefüllt ist (statt 0). enrich_from_contracts() liefert
zusätzlich eine saleContracts-Liste (deterministische Id aus Dateiname, Käufer-
ContactId, Preis, Abgabe-/Vertragsdatum, FileName, gematchte Tier-Ids); main()
schreibt sie als Top-Level-Key in resolved_import.json. IngestResolvedService legt
SaleContract + SaleContractAnimal an (FK-sicher nach Kontakten/Tieren, unbekannte
Links übersprungen) — Tabelle wird wie gehabt gewischt und aus dem Payload neu
befüllt (idempotent).

Ergebnis: 1033 Verträge (611 mit ≥1 Tier, alle datiert; 54 Dateinamen-Dubletten
zusammengefasst, 7 datumlose + 1 ohne Käufer übersprungen). Kein Schema-Change
(datumlose übersprungen statt Spalten nullable → keine Migration).

DOCX-Download: importierte Verträge haben keine Word-Datei im contract-storage →
DTO.HasFile=false, Frontend blendet den Word-Button aus (Hinweis „Keine Word-
Datei"), PDF wird weiterhin generiert. Download-Endpoint liefert sauber 404 statt
500 bei fehlender Datei.

Tests: dotnet 213, vitest 129, playwright 16 (neuer vertraege.spec.ts), Python grün.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 17:33:58 +02:00
parent 5e14124322
commit 5175c3229a
12 changed files with 382 additions and 25 deletions

View File

@@ -30,18 +30,24 @@ namespace GerbilManagerWebAPI.Endpoints
var group = app.MapGroup("/contracts").WithTags("Contracts");
// GET /contracts (Gridify; GerbilIds via Include mitgeladen)
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db) =>
TypedResults.Ok(await db.SaleContracts.AsNoTracking()
// HasFile pro Zeile per File.Exists (nur die Seite, i. d. R. 20 Zeilen).
group.MapGet("/", async ([AsParameters] GridifyParams query, ApplicationContext db,
IConfiguration config, IWebHostEnvironment env) =>
{
var root = ContractRoot(config, env);
return TypedResults.Ok(await db.SaleContracts.AsNoTracking()
.Include(c => c.Animals)
.ToPagedResultAsync(query, ToDto)));
.ToPagedResultAsync(query, c => ToDto(c, root)));
});
// GET /contracts/{id}
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (Guid id, ApplicationContext db) =>
group.MapGet("/{id:guid}", async Task<Results<Ok<SaleContractDto>, NotFound>> (
Guid id, ApplicationContext db, IConfiguration config, IWebHostEnvironment env) =>
{
var c = await db.SaleContracts.AsNoTracking()
.Include(x => x.Animals)
.FirstOrDefaultAsync(x => x.Id == id);
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c));
return c is null ? TypedResults.NotFound() : TypedResults.Ok(ToDto(c, ContractRoot(config, env)));
});
// POST /contracts — der Abgabe-Abschluss.
@@ -272,9 +278,15 @@ namespace GerbilManagerWebAPI.Endpoints
return cleaned.Trim('-');
}
internal static SaleContractDto ToDto(SaleContract c) => new(
internal static SaleContractDto ToDto(SaleContract c, string? contractRoot = null) => new(
c.Id, c.ContactId, c.Price, c.HandoverDate, c.ContractDate, c.FileName, c.CreatedAt,
c.Animals.Select(a => a.GerbilId).ToList(),
$"/contracts/{c.Id}/file");
$"/contracts/{c.Id}/file",
// Imported contracts have no staged .docx -> HasFile=false (Word
// download 404s gracefully). When no root is given (e.g. POST, where
// the file was just written) we assume the file exists.
HasFile: contractRoot is null
|| (!string.IsNullOrEmpty(c.FileName)
&& File.Exists(Path.Combine(contractRoot, c.FileName))));
}
}