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