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

@@ -10,6 +10,10 @@ namespace GerbilManager.Tests
{
private readonly string _dir;
private readonly string _resolvedJsonPath;
private Guid ContactId { get; set; }
private Guid FatherId { get; set; }
private Guid ContractId { get; set; }
private Guid UnknownGerbilId { get; set; }
public IngestResolvedServiceTests()
{
@@ -22,6 +26,10 @@ namespace GerbilManager.Tests
var motherId = Guid.NewGuid();
var litterId = Guid.NewGuid();
var photoId = Guid.NewGuid();
ContactId = contactId;
FatherId = fatherId;
ContractId = Guid.NewGuid();
UnknownGerbilId = Guid.NewGuid();
var data = new
{
@@ -127,6 +135,20 @@ namespace GerbilManager.Tests
FileName = "photo1.jpg",
SortOrder = 0
}
},
SaleContracts = new[]
{
new
{
Id = ContractId,
ContactId = contactId,
Price = 35.0m,
HandoverDate = "2023-09-29",
ContractDate = "2023-09-29",
FileName = "Zucht der kleinen Chaoten _ (Papa) - Test Breeder_.docx",
// One known animal + one unknown id (must be skipped defensively).
Animals = new[] { fatherId, UnknownGerbilId }
}
}
};
@@ -199,6 +221,41 @@ namespace GerbilManager.Tests
Assert.NotNull(litter.Provenance);
Assert.Contains("aus Wurfchronik", litter.Provenance);
Assert.Contains("fromWurfchronik", litter.Provenance);
// SaleContracts are created from the payload, and animal links to
// unknown gerbils are skipped defensively (only the known father links).
Assert.Equal(1, await db.SaleContracts.CountAsync());
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
Assert.Equal(ContractId, contract.Id);
Assert.Equal(ContactId, contract.ContactId);
Assert.Equal(35.0m, contract.Price);
Assert.Equal(new DateOnly(2023, 9, 29), contract.HandoverDate);
var link = Assert.Single(contract.Animals);
Assert.Equal(FatherId, link.GerbilId);
}
[Fact]
public async Task IngestResolved_recreates_sale_contracts_on_every_ingest()
{
using var db = NewDb();
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
{ "Import:SourcePath", _dir }
})
.Build();
var service = new IngestResolvedService(db, config, null!);
await service.RunAsync();
Assert.Equal(1, await db.SaleContracts.CountAsync());
// Re-ingest wipes then recreates from the (deterministic) payload —
// the contract survives by being part of the payload, no duplicate PK.
await service.RunAsync();
Assert.Equal(1, await db.SaleContracts.CountAsync());
var contract = await db.SaleContracts.Include(c => c.Animals).SingleAsync();
Assert.Equal(ContractId, contract.Id);
Assert.Single(contract.Animals);
}
}
}

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

View File

@@ -363,6 +363,7 @@ export async function installMockApi(page: Page): Promise<MockDb> {
fileName: `vertrag-${id}.docx`,
createdAt: new Date().toISOString(),
url: `/contracts/${id}/file`,
hasFile: true,
}
;(db.contracts as unknown as Row[]).push(contract as unknown as Row)
// Tiere auf GivenAway setzen (wie das echte Backend)

View File

@@ -55,6 +55,8 @@ export interface MockContract {
createdAt: string
gerbilIds: string[]
url: string
/** Ob die Word-Datei vorliegt. Importierte Verträge: false (kein Word-Button). */
hasFile: boolean
}
export interface MockDb {
@@ -372,7 +374,21 @@ export function seedDb(): MockDb {
requests,
aiConfigured: true,
mailConfigured: true,
contracts: [],
// Ein importierter Vertrag (hasFile:false) prüft die Word-Button-Ausblendung.
contracts: [
{
id: 'contract-import-1',
contactId: 'con-meier',
price: 30,
handoverDate: '2023-09-29',
contractDate: '2023-09-29',
fileName: 'Zucht der kleinen Chaoten _ (Krümel) - Zoohandlung Meier_.docx',
createdAt: '2023-09-29T00:00:00Z',
gerbilIds: ['kruemel'],
url: '/contracts/contract-import-1/file',
hasFile: false,
},
],
saleAdConfigured: true,
namesConfigured: true,
feedback: [],

View File

@@ -0,0 +1,33 @@
/**
* FEAT-Contracts: Verträge-Liste (/vertraege) zeigt importierte Abgabeverträge.
*
* Der Mock-Seed enthält EINEN importierten Vertrag (hasFile:false). Wir prüfen,
* dass er gerendert wird (Abnehmer, Preis, Datum, Tieranzahl) und dass der
* Word-Download-Button für ihn ausgeblendet ist (kein .docx), während der
* PDF-Button (aus den Daten neu erzeugt) sichtbar bleibt.
*/
import { de, expect, gotoSection, skipUnlessMock, test } from './fixtures'
const t = de.pages.vertraege
test.describe('Verträge-Liste', () => {
test('importierter Vertrag wird gelistet; Word-Button ausgeblendet, PDF bleibt', async ({
page,
}) => {
skipUnlessMock()
await gotoSection(page, de.nav.contracts)
await expect(page.getByRole('heading', { name: t.title, exact: true })).toBeVisible()
const card = page.locator('.vertrag-card').filter({ hasText: 'Zoohandlung Meier' })
await expect(card).toBeVisible()
// Tieranzahl + Preis + Übergabedatum erscheinen in der Meta-Zeile.
await expect(card).toContainText(t.animalsCount(1))
await expect(card).toContainText('30,00 €')
await expect(card).toContainText('29.09.2023')
// PDF-Button vorhanden, Word-Button NICHT (importiert, keine Datei).
await expect(card.getByRole('link', { name: t.downloadPdf })).toBeVisible()
await expect(card.getByRole('link', { name: t.download, exact: true })).toHaveCount(0)
await expect(card.getByText(t.noWordFileShort)).toBeVisible()
})
})

View File

@@ -14,6 +14,9 @@ export interface SaleContract {
gerbilIds: string[]
/** API-relativer Download-Pfad ("/contracts/{id}/file"). */
url: string
/** Ob die Word-Datei im Vertrags-Dateiroot liegt. Importierte Verträge haben
* keine Datei (Word-Download 404) — das PDF wird trotzdem neu erzeugt. */
hasFile: boolean
}
export interface CreateSaleContract {

View File

@@ -84,9 +84,15 @@ export default function VertraegeListPage() {
<a className="btn btn--primary" href={contractPdfUrl(c)}>
{t.downloadPdf}
</a>
<a className="btn" href={contractFileUrl(c)}>
{t.download}
</a>
{c.hasFile ? (
<a className="btn" href={contractFileUrl(c)}>
{t.download}
</a>
) : (
<span className="muted" title={t.noWordFile}>
{t.noWordFileShort}
</span>
)}
<button
type="button"
className="btn btn--danger"

View File

@@ -472,6 +472,9 @@ export const de = {
countText: (n: number) => (n === 1 ? '1 Vertrag' : `${n} Verträge`),
download: 'Word',
downloadPdf: 'PDF',
noWordFileShort: 'Keine Word-Datei',
noWordFile:
'Für importierte Verträge liegt keine Word-Datei vor — das PDF wird aus den Daten erzeugt.',
delete: 'Löschen',
confirmDelete:
'Vertrag wirklich löschen? Die Word-Datei wird mit entfernt; der Status der Tiere bleibt unverändert.',

View File

@@ -957,9 +957,28 @@ def _append_history(prov_json, line):
return json.dumps(prov, ensure_ascii=False)
def _parse_price(raw):
"""Parse a contract price string ('27,50' / '30.00' / '') → float (0.0 if empty).
extract_contracts.py emits German-formatted numbers ('27,50'); accept both
comma and dot decimal separators. Unparseable/empty → 0.0 (a price-less
contract is still a valid contract record)."""
if raw is None:
return 0.0
s = str(raw).strip()
if not s:
return 0.0
s = s.replace(".", "").replace(",", ".") if ("," in s) else s
try:
return round(float(s), 2)
except ValueError:
return 0.0
def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
contact_id_map):
"""Conservatively fold Abgabevertrag data into the resolved gerbils.
"""Conservatively fold Abgabevertrag data into the resolved gerbils AND emit
one SaleContract record per contract with a resolvable buyer.
For every parsed contract we (a) ensure the buyer exists as a (receiver)
contact, reusing the existing contact dedup/normalisation, and (b) try to
@@ -969,18 +988,37 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
and add a provenance history line. Ambiguous or absent matches are logged,
never guessed.
Returns a stats dict. Mutates resolved_gerbils + contact_by_norm_name in
place. New buyer contacts are appended via contact_by_norm_name so the later
IsReceiver-flag pass picks them up automatically.
In addition we build a `sale_contracts` list (one record per contract whose
buyer resolves to a contact). Each record carries a deterministic Id (from
the source filename, so re-ingest is idempotent), the resolved buyer
ContactId, the parsed Price, the parsed dates and the gerbil ids that
matched for that contract. Contracts with NO date at all are skipped from
record creation (the SaleContract.HandoverDate/ContractDate columns are
non-nullable DateOnly) and counted in stats["dateless_skipped"]; contracts
whose buyer cannot be resolved are counted in stats["no_buyer_skipped"].
Returns (stats, sale_contracts). Mutates resolved_gerbils +
contact_by_norm_name in place. New buyer contacts are appended via
contact_by_norm_name so the later IsReceiver-flag pass picks them up
automatically.
"""
stats = {
"contracts": len(contracts), "buyers_created": 0, "buyers_existing": 0,
"matched": 0, "ambiguous_skipped": 0, "no_match_skipped": 0,
"receiver_set": 0, "gohome_set": 0, "status_givenaway": 0,
"conflicts": 0,
"records_created": 0, "no_buyer_skipped": 0, "dateless_skipped": 0,
"records_with_animal": 0, "records_with_date": 0,
}
sale_contracts = []
# The same contract filename can appear more than once in contracts.json
# (the .docx is filed in several subfolders of the share). The record Id is
# derived from the filename, so we must collapse those into ONE record per
# Id (a duplicate PK would break ingest). Keyed by Id; animal lists are
# merged and a missing date is back-filled from the duplicate.
records_by_id = {}
if not contracts:
return stats
return stats, sale_contracts
# Index breeder-owned gerbils by dedup name key. Contracts only ever sell
# animals the breeder bred, so restrict candidates to her own stock to avoid
@@ -1035,8 +1073,10 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
# --- match each animal call-name to a resolved gerbil ---
handover = parse_date(c.get("handoverDate"))
contract_date = parse_date(c.get("contractDate"))
c_year = year_of(parse_date(c.get("dob"))) if c.get("dob") else None
c_color = (c.get("color") or "").strip().lower()
matched_gerbil_ids = [] # gerbils this contract resolved to (for the record)
for call in (c.get("animals") or []):
key = get_dedup_name_key(get_call_name(call))
@@ -1071,6 +1111,8 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
continue
stats["matched"] += 1
if chosen.get("Id") and chosen["Id"] not in matched_gerbil_ids:
matched_gerbil_ids.append(chosen["Id"])
# --- set receiver, only if not already set differently ---
if buyer_global_id:
@@ -1103,7 +1145,48 @@ def enrich_from_contracts(contracts, resolved_gerbils, contact_by_norm_name,
chosen["Status"] = "GivenAway"
stats["status_givenaway"] += 1
return stats
# --- emit a SaleContract record for this contract ---------------------
# Only contracts with a resolvable buyer become records (the row needs a
# ContactId). A record with zero matched animals is still kept — better
# to show the contract than to drop it.
if not buyer_global_id:
stats["no_buyer_skipped"] += 1
continue
# HandoverDate/ContractDate are non-nullable DateOnly in the DB. Fall
# back from one to the other; if BOTH are missing, skip the record
# (we do not invent dates) and count it.
h = handover or contract_date
cd = contract_date or handover
if not h: # implies cd is also None
stats["dateless_skipped"] += 1
continue
rec_id = generate_guid(f"contract-{fname}")
existing = records_by_id.get(rec_id)
if existing is None:
records_by_id[rec_id] = {
"Id": rec_id,
"ContactId": buyer_global_id,
"Price": _parse_price(c.get("price")),
"HandoverDate": h,
"ContractDate": cd,
"FileName": fname,
"Animals": list(matched_gerbil_ids),
}
else:
# Same filename seen again — merge animal matches; back-fill price.
for gid in matched_gerbil_ids:
if gid not in existing["Animals"]:
existing["Animals"].append(gid)
if not existing["Price"]:
existing["Price"] = _parse_price(c.get("price"))
sale_contracts = list(records_by_id.values())
stats["records_created"] = len(sale_contracts)
stats["records_with_animal"] = sum(1 for r in sale_contracts if r["Animals"])
stats["records_with_date"] = sum(1 for r in sale_contracts if r["HandoverDate"])
return stats, sale_contracts
def main():
@@ -2863,7 +2946,7 @@ def main():
# Status=GivenAway setzen (nur falls noch nicht gesetzt). Provenance-
# Historie wird ergänzt. Neue Käuferkontakte landen in contact_by_norm_name
# und werden danach automatisch als IsReceiver markiert.
cstats = enrich_from_contracts(
cstats, sale_contracts = enrich_from_contracts(
contracts, resolved_gerbils, contact_by_norm_name, contact_id_map)
if contracts:
print("Abgabeverträge: "
@@ -2877,6 +2960,12 @@ def main():
f"GoHomeDate={cstats['gohome_set']}, "
f"Status=GivenAway={cstats['status_givenaway']}, "
f"Konflikte={cstats['conflicts']}.")
print(" Vertragszeilen: "
f"{cstats['records_created']} angelegt "
f"({cstats['records_with_animal']} mit Tier, "
f"{cstats['records_with_date']} mit Originaldatum), "
f"{cstats['no_buyer_skipped']} ohne Käufer übersprungen, "
f"{cstats['dateless_skipped']} ohne Datum übersprungen.")
# Re-materialise contacts so freshly created buyer contacts are exported.
resolved_contacts = list(contact_by_norm_name.values())
@@ -2989,17 +3078,19 @@ def main():
"contacts": resolved_contacts,
"litters": resolved_litters,
"gerbils": resolved_gerbils,
"gerbilPhotos": resolved_photos
"gerbilPhotos": resolved_photos,
"saleContracts": sale_contracts,
}
with open(OUTPUT_FILE, "w", encoding="utf-8") as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
print(f"Successfully wrote database-ready import file to: {OUTPUT_FILE}")
print(f" Contacts: {len(payload['contacts'])}")
print(f" Litters: {len(payload['litters'])}")
print(f" Gerbils: {len(payload['gerbils'])}")
print(f" Photos: {len(payload['gerbilPhotos'])}")
print(f" Contacts: {len(payload['contacts'])}")
print(f" Litters: {len(payload['litters'])}")
print(f" Gerbils: {len(payload['gerbils'])}")
print(f" Photos: {len(payload['gerbilPhotos'])}")
print(f" SaleContracts: {len(payload['saleContracts'])}")
if __name__ == "__main__":
main()

View File

@@ -345,6 +345,75 @@ check("history: discard lines carry the warning marker",
all(s.startswith(m.DISCARD_MARK) for s in h_disc if "verworfen" in s))
# ── enrich_from_contracts: SaleContract record emission ───────────────────────
# A contract whose buyer resolves to a contact and whose animal call-name matches
# a breeder-owned gerbil must yield a SaleContract record carrying the buyer
# ContactId, a deterministic Id, the parsed dates and the matched gerbil id.
def _balu():
# A breeder-owned ("Chaoten") gerbil whose call-name is "Balu".
return {
"Id": "11111111-1111-1111-1111-111111111111",
"Name": "Balu von den kleinen Chaoten",
"Gender": "male", "DateOfBirth": "2022-05-01",
"OriginBreeder": "Zucht der kleinen Chaoten",
"Status": "Active", "ColorVarietyId": None,
"Provenance": None,
}
_g = _balu()
_resolved = [_g]
_contacts_by_norm = {}
_contracts = [{
"sourceFile": "Zucht der kleinen Chaoten _ Schwarz (Balu) - Max Muster_.docx",
"buyer": "Max Muster", "animals": ["Balu"], "color": "schwarz",
"gender": "Male", "dob": "2022-05-01",
"handoverDate": "2022-07-01", "contractDate": "2022-07-01", "price": "30,00",
}]
_stats, _sale = m.enrich_from_contracts(_contracts, _resolved, _contacts_by_norm, {})
check("contracts: exactly one SaleContract record emitted", len(_sale) == 1)
_rec = _sale[0] if _sale else {}
check("contracts: record Id is deterministic from filename",
_rec.get("Id") == m.generate_guid(
"contract-Zucht der kleinen Chaoten _ Schwarz (Balu) - Max Muster_.docx"))
check("contracts: record ContactId is the resolved buyer contact",
_rec.get("ContactId") and
_rec["ContactId"] == _contacts_by_norm.get(m.normalize_name("Max Muster"), {}).get("Id"))
check("contracts: record lists the matched gerbil",
_rec.get("Animals") == [_g["Id"]])
check("contracts: price parsed as float", _rec.get("Price") == 30.0)
check("contracts: dates carried through",
_rec.get("HandoverDate") == "2022-07-01" and _rec.get("ContractDate") == "2022-07-01")
check("contracts: stats count the created record", _stats.get("records_created") == 1)
# A dateless contract is skipped from record creation (non-nullable DateOnly) but
# still counted, and the buyer contact is still created.
_c2 = [{
"sourceFile": "Zucht der kleinen Chaoten _ (Nala) - Erika Muster_.docx",
"buyer": "Erika Muster", "animals": ["Nala"], "color": "",
"gender": "", "dob": "", "handoverDate": "", "contractDate": "", "price": "",
}]
_stats2, _sale2 = m.enrich_from_contracts(_c2, [], {}, {})
check("contracts: dateless contract skipped from records", len(_sale2) == 0)
check("contracts: dateless contract counted", _stats2.get("dateless_skipped") == 1)
# Price-only / no-date fallback: contract with only a contractDate gets it copied
# into HandoverDate too (and vice versa), and an animal-less contract still
# becomes a record (better to show it than drop it).
_c3 = [{
"sourceFile": "Zucht der kleinen Chaoten _ (Unbekannt) - Tom Muster_.docx",
"buyer": "Tom Muster", "animals": ["Unbekannt"], "color": "",
"gender": "", "dob": "", "handoverDate": "", "contractDate": "2023-01-15",
"price": "",
}]
_stats3, _sale3 = m.enrich_from_contracts(_c3, [], {}, {})
check("contracts: animal-less contract still becomes a record", len(_sale3) == 1)
check("contracts: missing handover falls back to contract date",
_sale3 and _sale3[0]["HandoverDate"] == "2023-01-15"
and _sale3[0]["ContractDate"] == "2023-01-15")
check("contracts: animal-less record has empty Animals list",
_sale3 and _sale3[0]["Animals"] == [])
if check.failed:
print(f"\n{check.failed} test(s) FAILED")
sys.exit(1)