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

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)