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

@@ -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()