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

@@ -12,7 +12,11 @@ namespace GerbilManagerWebAPI.Dtos
DateTimeOffset CreatedAt,
IReadOnlyList<Guid> GerbilIds,
// Download-URL der .docx ("/contracts/{id}/file").
string Url);
string Url,
// True, wenn die .docx im Vertrags-Dateiroot existiert. Importierte
// Verträge haben KEINE Datei (Word-Download 404) -> Frontend blendet den
// Word-Button aus; das PDF wird ohnehin aus den Daten neu erzeugt.
bool HasFile);
public record SaleContractInput(
Guid ContactId,

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))));
}
}

View File

@@ -238,7 +238,53 @@ namespace GerbilManagerWebAPI.Import
}
await _db.SaveChangesAsync();
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, and {data.GerbilPhotos.Count} photos.";
// 7. Insert imported Abgabeverträge (SaleContract + join rows).
// The table was wiped above, so these are recreated on every ingest
// (they survive only by being part of the payload). FK-safe: contacts
// and gerbils are already inserted. We skip rows whose buyer contact
// is unknown and skip individual animal links to unknown gerbils
// (defensive — the resolver should never emit those).
// No .docx is copied: the source files live on the network share and
// are not staged into Contracts:RootPath, so the Word download 404s
// gracefully (the endpoint already returns NotFound) while the PDF is
// regenerated from the data. The DTO exposes HasFile=false for these
// so the frontend hides the Word button.
int contractsAdded = 0;
int contractAnimalsSkipped = 0;
if (data.SaleContracts.Count > 0)
{
var contactIds = await _db.Contacts.Select(c => c.Id).ToHashSetAsync();
var gerbilIds = await _db.Gerbils.Select(g => g.Id).ToHashSetAsync();
var seenContractIds = new HashSet<Guid>();
foreach (var sc in data.SaleContracts)
{
if (!seenContractIds.Add(sc.Id)) continue; // dedupe within payload
if (!contactIds.Contains(sc.ContactId)) continue; // unknown buyer
var animals = new List<SaleContractAnimal>();
foreach (var gid in (sc.Animals ?? new List<Guid>()).Distinct())
{
if (!gerbilIds.Contains(gid)) { contractAnimalsSkipped++; continue; }
animals.Add(new SaleContractAnimal { SaleContractId = sc.Id, GerbilId = gid });
}
_db.SaleContracts.Add(new SaleContract
{
Id = sc.Id,
ContactId = sc.ContactId,
Price = sc.Price,
HandoverDate = sc.HandoverDate,
ContractDate = sc.ContractDate,
FileName = sc.FileName,
CreatedAt = DateTimeOffset.UtcNow,
Animals = animals,
});
contractsAdded++;
}
await _db.SaveChangesAsync();
}
return $"Ingestion successful! Imported {contactsAdded} contacts ({contactsUpdated} updated), {data.Litters.Count} litters, {data.Gerbils.Count} gerbils, {data.GerbilPhotos.Count} photos, and {contractsAdded} sale contracts ({contractAnimalsSkipped} animal links to unknown gerbils skipped).";
}
}
@@ -248,6 +294,22 @@ namespace GerbilManagerWebAPI.Import
public List<Litter> Litters { get; set; } = new();
public List<Gerbil> Gerbils { get; set; } = new();
public List<ResolvedPhoto> GerbilPhotos { get; set; } = new();
public List<ResolvedSaleContract> SaleContracts { get; set; } = new();
}
/// <summary>Eine importierte Abgabevertrag-Zeile aus resolved_import.json.
/// <c>Animals</c> sind die zugeordneten Gerbil-Ids (Join-Zeilen werden beim
/// Ingest gebaut). Die .docx liegt NICHT im Vertrags-Dateiroot — der
/// Word-Download liefert 404, das PDF wird aus den Daten neu erzeugt.</summary>
public class ResolvedSaleContract
{
public Guid Id { get; set; }
public Guid ContactId { get; set; }
public decimal Price { get; set; }
public DateOnly HandoverDate { get; set; }
public DateOnly ContractDate { get; set; }
public required string FileName { get; set; }
public List<Guid> Animals { get; set; } = new();
}
public class ResolvedPhoto