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:
@@ -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,
|
||||
|
||||
@@ -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))));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user