diff --git a/GerbilManager.Tests/IngestResolvedServiceTests.cs b/GerbilManager.Tests/IngestResolvedServiceTests.cs
index 732ffb0..06d4606 100644
--- a/GerbilManager.Tests/IngestResolvedServiceTests.cs
+++ b/GerbilManager.Tests/IngestResolvedServiceTests.cs
@@ -87,7 +87,7 @@ namespace GerbilManager.Tests
CharacterNote = (string?)null,
IsDeaf = false,
IsResident = true,
- Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"]}"
+ Provenance = (string?)"{\"sourceFiles\":[\"Stammbaum von Papa.xlsx\",\"Wurfchronik-Detail.docx\"],\"mergedRecordCount\":2,\"fromWurfchronik\":true,\"notes\":[\"aus 2 Datensätzen zusammengeführt\"],\"history\":[\"In \\u201eStammbaum von Papa.xlsx\\u201c gefunden.\",\"Genotyp aus \\u201eWurfchronik-Detail.docx\\u201c.\"]}"
},
new
{
@@ -185,6 +185,10 @@ namespace GerbilManager.Tests
Assert.NotNull(father.Provenance);
Assert.Contains("Stammbaum von Papa.xlsx", father.Provenance);
Assert.Contains("mergedRecordCount", father.Provenance);
+ // The chronological, file-attributed history survives ingest verbatim
+ // (stored as opaque JSON on the text column — no schema for it).
+ Assert.Contains("history", father.Provenance);
+ Assert.Contains("Genotyp aus", father.Provenance);
Assert.Null(mother.Provenance);
// Contact + litter provenance also round-trips through the ingest.
diff --git a/gerbil-manager-web/e2e/mock-data.ts b/gerbil-manager-web/e2e/mock-data.ts
index 4e99b03..cfef01e 100644
--- a/gerbil-manager-web/e2e/mock-data.ts
+++ b/gerbil-manager-web/e2e/mock-data.ts
@@ -130,6 +130,14 @@ export function seedDb(): MockDb {
parentMethod: 'chart-position',
parentConfidence: 'medium',
notes: ['aus 2 Datensätzen zusammengeführt'],
+ history: [
+ 'In „Stammbaum von Krümel.xlsx“ gefunden.',
+ 'Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.',
+ 'Genotyp aus „Stammbaum von Krümel.xlsx“.',
+ 'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
+ 'Eltern über Position im Stammbaum erkannt (Quelle: „Stammbaum von Krümel.xlsx“).',
+ 'Angaben aus der Wurfchronik übernommen.',
+ ],
}),
},
{ ...gerbil('fridolin', 'Fridolin', 'male', '2023-05-01', 'w-fridolin', 'cv-schwarz', 'aa CC DD EE GG PP spsp rere'), enclosureId: 'enc-gross', originBreeder: 'Zoohandlung Meier', profilePhotoUrl: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==' },
@@ -203,6 +211,11 @@ export function seedDb(): MockDb {
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus Wurfchronik', 'aus 2 Datensätzen zusammengeführt', 'Geschwister-Würfe zusammengeführt'],
+ history: [
+ 'Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.',
+ 'Auch in „Wurfchronik Teil 1_page_0028.md“ gefunden → Datensätze zusammengeführt.',
+ 'Geschwister-Würfe zusammengeführt.',
+ ],
}),
},
{ id: 'w-fridolin', name: 'Wurf F', date: '2023-05-01', totalBorn: 4, expectedGoHomeDate: null, notes: null, fatherId: 'balu', motherId: 'maja' },
@@ -229,6 +242,11 @@ export function seedDb(): MockDb {
mergedRecordCount: 2,
fromWurfchronik: true,
notes: ['aus 2 Datensätzen zusammengeführt', 'als Züchter erkannt', 'als Abnehmer erkannt'],
+ history: [
+ 'In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.',
+ 'Auch in „Wurfchronik Teil 1_page_0001.md“ gefunden → Datensätze zusammengeführt.',
+ 'Sowohl als Züchter als auch als Abnehmer geführt.',
+ ],
}),
},
{ id: 'con-huber', name: 'Familie Huber', email: null, phone: '0151 2345678', address: null, notes: null, isBreeder: false, isReceiver: true },
diff --git a/gerbil-manager-web/e2e/provenance.spec.ts b/gerbil-manager-web/e2e/provenance.spec.ts
index a65da58..eeb518e 100644
--- a/gerbil-manager-web/e2e/provenance.spec.ts
+++ b/gerbil-manager-web/e2e/provenance.spec.ts
@@ -13,6 +13,14 @@ test('Tierakte: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', asyn
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
+ // VERLAUF: chronologischer, dateibezogener Verlauf als Primärinhalt.
+ await expect(dialog).toContainText(p.historyTitle)
+ await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ gefunden.')
+ await expect(dialog).toContainText('Geburtsdatum (12.03.2025) aus „Stammbaum von Krümel.xlsx“.')
+ await expect(dialog).toContainText(
+ 'Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt.',
+ )
+
// Quelldateien werden angezeigt.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Stammbaum von Krümel.xlsx')
@@ -53,6 +61,10 @@ test('Kontakt: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
+ // VERLAUF: dateibezogene Schritte des Kontakts.
+ await expect(dialog).toContainText(p.historyTitle)
+ await expect(dialog).toContainText('In „Stammbaum von Krümel.xlsx“ als Züchter erkannt.')
+
// Quelldateien + Zusammenführung + Kontakt-Rolle-Hinweise.
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0001.md')
@@ -73,6 +85,10 @@ test('Wurf: "Datenherkunft"-Button öffnet den Nachverfolgungs-Dialog', async ({
const dialog = page.getByRole('dialog', { name: p.dialogTitle })
await expect(dialog).toBeVisible()
+ // VERLAUF: dateibezogene Schritte des Wurfs.
+ await expect(dialog).toContainText(p.historyTitle)
+ await expect(dialog).toContainText('Wurf aus Wurfchronik „Wurfchronik Teil 1_page_0009.md“.')
+
await expect(dialog).toContainText(p.sourceFilesTitle)
await expect(dialog).toContainText('Wurfchronik Teil 1_page_0009.md')
await expect(dialog).toContainText(p.mergedCount(2))
diff --git a/gerbil-manager-web/src/api/types.ts b/gerbil-manager-web/src/api/types.ts
index 53e46bf..725cd89 100644
--- a/gerbil-manager-web/src/api/types.ts
+++ b/gerbil-manager-web/src/api/types.ts
@@ -88,6 +88,13 @@ export interface GerbilProvenance {
parentConfidence?: string
/** Menschlich lesbare Herkunftshinweise (deutsch). */
notes: string[]
+ /**
+ * Chronologischer, dateibezogener Verlauf (deutsch): liest sich wie ein
+ * Protokoll, WELCHE Quelldatei WELCHE Angabe beigetragen hat (z. B.
+ * „Geburtsdatum (27.03.2022) aus ‚Stammbaum von X.xlsx'."). Primärinhalt des
+ * Datenherkunft-Dialogs.
+ */
+ history: string[]
}
/**
diff --git a/gerbil-manager-web/src/components/ProvenanceDialog.tsx b/gerbil-manager-web/src/components/ProvenanceDialog.tsx
index 1e34ea6..8b72519 100644
--- a/gerbil-manager-web/src/components/ProvenanceDialog.tsx
+++ b/gerbil-manager-web/src/components/ProvenanceDialog.tsx
@@ -33,6 +33,7 @@ function parseProvenance(raw?: string | null): EntityProvenance | null {
parentMethod: p.parentMethod,
parentConfidence: p.parentConfidence,
notes: Array.isArray(p.notes) ? p.notes : [],
+ history: Array.isArray(p.history) ? p.history : [],
}
} catch {
return null
@@ -88,6 +89,19 @@ export default function ProvenanceDialog({ open, onClose, provenance, entityLabe
<>
{entityLabel ? t.introFor(entityLabel) : t.intro}
+ {p.history.length > 0 && (
+
+ {t.historyTitle}
+
+ {p.history.map((step, i) => (
+ -
+ {step}
+
+ ))}
+
+
+ )}
+
{t.mergedTitle}
diff --git a/gerbil-manager-web/src/components/provenanceDialog.css b/gerbil-manager-web/src/components/provenanceDialog.css
index a8318ba..dc1a045 100644
--- a/gerbil-manager-web/src/components/provenanceDialog.css
+++ b/gerbil-manager-web/src/components/provenanceDialog.css
@@ -149,6 +149,20 @@
font-size: 0.85rem;
}
+/* Chronologischer Verlauf: liest sich wie ein Protokoll der Datenherkunft. */
+.provenance__history {
+ margin: 0;
+ padding-left: 1.25rem;
+ display: grid;
+ gap: 0.35rem;
+}
+
+.provenance__history-step {
+ font-size: 0.88rem;
+ line-height: 1.35;
+ word-break: break-word;
+}
+
.provenance__actions {
display: flex;
justify-content: flex-end;
diff --git a/gerbil-manager-web/src/strings/de.ts b/gerbil-manager-web/src/strings/de.ts
index 5e8f10e..29ba8d8 100644
--- a/gerbil-manager-web/src/strings/de.ts
+++ b/gerbil-manager-web/src/strings/de.ts
@@ -894,6 +894,8 @@ export const de = {
contact: 'dieses Kontakts',
litter: 'dieses Wurfs',
},
+ /** Überschrift des chronologischen, dateibezogenen Verlaufs (Primärinhalt). */
+ historyTitle: 'Verlauf',
/** Überschriften / Feldbeschriftungen. */
sourceFilesTitle: 'Quelldateien',
sourceFilesCount: (n: number) => (n === 1 ? 'aus 1 Quelle' : `aus ${n} Quellen`),
diff --git a/tools/import/merge_and_resolve.py b/tools/import/merge_and_resolve.py
index 052f23f..18b8258 100644
--- a/tools/import/merge_and_resolve.py
+++ b/tools/import/merge_and_resolve.py
@@ -23,14 +23,17 @@ def generate_guid(key_str):
return str(uuid.uuid5(uuid.NAMESPACE_DNS, key_str))
def build_entity_provenance(source_files, merged_record_count, notes=None,
- from_wurfchronik=None, extra=None):
+ from_wurfchronik=None, extra=None, history=None):
"""Generic data-provenance JSON builder shared by gerbils, contacts and
litters. Mirrors the GerbilProvenance frontend contract:
- { sourceFiles, mergedRecordCount, fromWurfchronik, notes, ... }
+ { sourceFiles, mergedRecordCount, fromWurfchronik, notes, history, ... }
`source_files` is any iterable of filenames; `from_wurfchronik` is auto-
derived from the filenames when left as None. `extra` may carry entity-
- specific keys (e.g. parentMethod/parentConfidence for gerbils). Returns a
- JSON string (stored on the nullable Provenance text column)."""
+ specific keys (e.g. parentMethod/parentConfidence for gerbils). `history`
+ is an ordered list of human-readable German lines that read like a
+ chronological log of where each fact came from (the primary content shown
+ in the Datenherkunft dialog). Returns a JSON string (stored on the nullable
+ Provenance text column)."""
files = sorted({f for f in source_files if f})
if from_wurfchronik is None:
from_wurfchronik = any("wurfchronik" in f.lower() for f in files)
@@ -39,6 +42,7 @@ def build_entity_provenance(source_files, merged_record_count, notes=None,
"mergedRecordCount": merged_record_count,
"fromWurfchronik": bool(from_wurfchronik),
"notes": list(notes or []),
+ "history": list(history or []),
}
if extra:
for k, v in extra.items():
@@ -46,6 +50,58 @@ def build_entity_provenance(source_files, merged_record_count, notes=None,
prov[k] = v
return json.dumps(prov, ensure_ascii=False)
+
+def _quote_file(fname):
+ """German typographic quotes around a source filename for history lines."""
+ return f"„{fname}“"
+
+
+# Human-readable German labels for the significant fields we attribute to files.
+PROV_FIELD_LABELS = {
+ "DateOfBirth": "Geburtsdatum",
+ "DateOfDeath": "Sterbedatum",
+ "Gender": "Geschlecht",
+ "Genotype": "Genotyp",
+ "ColorVarietyId": "Farbschlag",
+ "Name": "Name",
+}
+
+
+def _primary_file_of(record):
+ """The single most representative source file of one raw record.
+
+ Prefers an explicit Stammbaum/Wurfchronik filename from ImportSource, else
+ the record's _filename. Used to attribute a field value to a concrete file
+ in the history log."""
+ fn = record.get("_filename")
+ if fn:
+ return fn
+ imp = record.get("ImportSource")
+ if imp:
+ first = str(imp).split(",")[0].strip()
+ if first:
+ return first
+ return None
+
+
+def _record_source_files(g):
+ """All distinct source files a single raw gerbil record drew from.
+
+ ImportSource is either a comma-joined Stammbaum file list, a single
+ Wurfchronik filename, or the per-litter filename; _filename is the primary
+ file. We union both so nothing is lost."""
+ files = set()
+ imp = g.get("ImportSource")
+ if imp:
+ for part in str(imp).split(","):
+ part = part.strip()
+ if part:
+ files.add(part)
+ fn = g.get("_filename")
+ if fn:
+ files.add(fn)
+ return files
+
def to_valid_guid(val):
if not val:
return None
@@ -657,6 +713,114 @@ def pick_parent_ref(parent_refs, role, child_dob, avoid_name=None, gender_of=Non
return role_refs[order[0]]
+def _build_gerbil_history(records, best_g, field_source, parent_method=None,
+ any_decision=False, any_conflict=False, conflict_notes=None):
+ """Build an ordered, file-attributed German history for a resolved gerbil.
+
+ Reads like a chronological log:
+ • „In ‚X.xlsx' gefunden."
+ • „Geburtsdatum (27.03.2022) aus ‚X.xlsx'."
+ • „Auch in ‚Y.xlsx' gefunden → Datensätze zusammengeführt."
+ • „Genotyp aus ‚Z.xlsx'."
+ • „Eltern über Position im Stammbaum erkannt (Quelle: ‚X.xlsx')."
+ • „Aus Wurfchronik übernommen."
+
+ `field_source` maps a field name to the raw record that supplied its final
+ value; when present we name that record's file, otherwise we fall back to
+ the primary record. The records are visited in a stable order (primary
+ first, then the rest sorted by file) so the log is deterministic."""
+ conflict_notes = conflict_notes or []
+ history = []
+
+ # Order records: best_g first, then others by primary file name (stable).
+ others = [r for r in records if r is not best_g]
+ others.sort(key=lambda r: (_primary_file_of(r) or ""))
+ ordered = [best_g] + others
+
+ best_file = _primary_file_of(best_g)
+ if best_file:
+ history.append(f"In {_quote_file(best_file)} gefunden.")
+ else:
+ history.append("Im Import gefunden.")
+
+ # Field-by-field attribution: name the file that supplied each fact.
+ def attr_line(field, formatter):
+ rec = field_source.get(field) or best_g
+ val = best_g.get(field)
+ if not val or val == "unknown":
+ return
+ fname = _primary_file_of(rec)
+ label = PROV_FIELD_LABELS.get(field, field)
+ text = formatter(label, val)
+ if fname:
+ history.append(f"{text} aus {_quote_file(fname)}.")
+ else:
+ history.append(f"{text} (Quelle unbekannt).")
+
+ attr_line("DateOfBirth", lambda label, v: f"{label} ({_de_date(v)})")
+ attr_line("Gender", lambda label, v: f"{label} ({_de_gender(v)})")
+ attr_line("Genotype", lambda label, v: f"{label}")
+ attr_line("ColorVarietyId", lambda label, v: f"{label}")
+ attr_line("DateOfDeath", lambda label, v: f"{label} ({_de_date(v)})")
+
+ # Merge step: every additional record that contributed.
+ for r in others:
+ fname = _primary_file_of(r)
+ if fname:
+ history.append(
+ f"Auch in {_quote_file(fname)} gefunden → Datensätze zusammengeführt."
+ )
+ else:
+ history.append("In weiterem Datensatz gefunden → Datensätze zusammengeführt.")
+
+ # Parent derivation.
+ if parent_method:
+ method_label = {
+ "chart-position": "Position im Stammbaum",
+ "geburtsdatum+eltern": "Geburtsdatum und Elternnamen",
+ "nur-geburtsdatum": "Geburtsdatum",
+ "decision": "manuelle Entscheidung",
+ }.get(parent_method, parent_method)
+ # The parent evidence comes from a Stammbaum chart — attribute to the
+ # primary record's file when it is a Stammbaum.
+ parent_file = best_file if best_file and "stammbaum" in best_file.lower() else None
+ if parent_file:
+ history.append(
+ f"Eltern über {method_label} erkannt (Quelle: {_quote_file(parent_file)})."
+ )
+ else:
+ history.append(f"Eltern über {method_label} erkannt.")
+
+ # Manual decisions / conflicts.
+ if any_decision:
+ history.append("Zuordnung per manueller Entscheidung getroffen.")
+ if any_conflict:
+ history.append("Konflikt per Entscheidung gelöst.")
+ for cn in conflict_notes:
+ if cn and cn not in history:
+ history.append(cn + ".")
+
+ # Wurfchronik provenance line.
+ if any("wurfchronik" in f.lower() for r in records for f in _record_source_files(r)):
+ history.append("Angaben aus der Wurfchronik übernommen.")
+
+ return history
+
+
+def _de_date(iso):
+ """YYYY-MM-DD → DD.MM.YYYY for display; pass through anything else."""
+ if not iso:
+ return iso
+ m = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", str(iso))
+ if m:
+ return f"{m.group(3)}.{m.group(2)}.{m.group(1)}"
+ return iso
+
+
+def _de_gender(g):
+ return {"male": "männlich", "female": "weiblich"}.get(g, g)
+
+
def main():
print("Loading color variety seeds...")
variety_map = {}
@@ -1721,30 +1885,14 @@ def main():
return True
- def _record_source_files(g):
- """All distinct source files a single raw record drew from.
-
- ImportSource is either a comma-joined Stammbaum file list, a single
- Wurfchronik filename, or the per-litter filename; _filename is the
- primary file. We union both so nothing is lost."""
- files = set()
- imp = g.get("ImportSource")
- if imp:
- for part in str(imp).split(","):
- part = part.strip()
- if part:
- files.add(part)
- fn = g.get("_filename")
- if fn:
- files.add(fn)
- return files
-
- def build_provenance(records, best_g, extra_notes=None):
+ def build_provenance(records, best_g, extra_notes=None, field_source=None):
"""Aggregate data-provenance across every raw record merged into one
resolved gerbil. Returns a JSON string (stored on the Gerbil entity as a
nullable text column) so the Rennmausakte can show where the entry came
from. `records` is the list of raw records that became this gerbil;
- `best_g` is the chosen primary record."""
+ `best_g` is the chosen primary record. `field_source` maps a significant
+ field name (DateOfBirth/Genotype/…) to the raw record that supplied its
+ final value, so the history can name the exact file for each fact."""
source_files = set()
from_wurfchronik = False
any_conflict = False
@@ -1783,12 +1931,21 @@ def main():
if n and n not in notes:
notes.append(n)
+ history = _build_gerbil_history(
+ records, best_g, field_source or {},
+ parent_method=parent_method,
+ any_decision=any_decision,
+ any_conflict=any_conflict,
+ conflict_notes=extra_notes or [],
+ )
+
return build_entity_provenance(
source_files,
merged_count,
notes=notes,
from_wurfchronik=from_wurfchronik,
extra={"parentMethod": parent_method, "parentConfidence": parent_confidence},
+ history=history,
)
# Group gerbils by name to perform deduplication
@@ -1861,13 +2018,24 @@ def main():
merged_notes = []
if best_g["Notes"]:
merged_notes.append(best_g["Notes"])
-
+
# Merge photos
merged_photos = list(best_g.get("_photos", []))
# Track sources for debugging
sources = [best_g["_filename"]]
+ # Per-field file attribution: which raw record supplied each final
+ # field value. Seed with best_g for every field it already carries;
+ # the fill loop and voting loop update it as winners change.
+ ATTRIB_FIELDS = ["DateOfBirth", "DateOfDeath", "Gender", "Genotype",
+ "ColorVarietyId", "Name"]
+ field_source = {}
+ for fld in ATTRIB_FIELDS:
+ v = best_g.get(fld)
+ if v and v != "unknown":
+ field_source[fld] = best_g
+
for g in sub:
if g == best_g:
continue
@@ -1884,16 +2052,20 @@ def main():
best_g["LitterId"] = g["LitterId"]
if not best_g["DateOfBirth"] and g["DateOfBirth"]:
best_g["DateOfBirth"] = g["DateOfBirth"]
+ field_source["DateOfBirth"] = g
if not best_g["DateOfDeath"] and g["DateOfDeath"]:
best_g["DateOfDeath"] = g["DateOfDeath"]
+ field_source["DateOfDeath"] = g
if not best_g["CauseOfDeath"] and g["CauseOfDeath"]:
best_g["CauseOfDeath"] = g["CauseOfDeath"]
if not best_g["GoHomeDate"] and g["GoHomeDate"]:
best_g["GoHomeDate"] = g["GoHomeDate"]
if not best_g["Genotype"] and g["Genotype"]:
best_g["Genotype"] = g["Genotype"]
+ field_source["Genotype"] = g
if not best_g["ColorVarietyId"] and g["ColorVarietyId"]:
best_g["ColorVarietyId"] = g["ColorVarietyId"]
+ field_source["ColorVarietyId"] = g
if not best_g["OriginContactId"] and g["OriginContactId"]:
best_g["OriginContactId"] = g["OriginContactId"]
if not best_g["ReceiverContactId"] and g["ReceiverContactId"]:
@@ -1904,10 +2076,12 @@ def main():
# Reconcile Gender: prefer a known gender over unknown, and prefer stammbaum over other sources
if best_g["Gender"] == "unknown" and g["Gender"] != "unknown":
best_g["Gender"] = g["Gender"]
+ field_source["Gender"] = g
elif best_g["Gender"] != "unknown" and g["Gender"] != "unknown" and best_g["Gender"] != g["Gender"]:
if g["ImportSource"] and "stammbaum" in g["ImportSource"].lower():
if not best_g["ImportSource"] or "stammbaum" not in best_g["ImportSource"].lower():
best_g["Gender"] = g["Gender"]
+ field_source["Gender"] = g
# Status precedence: Deceased > GivenAway > Breeding/Pet
if g["Status"] == "Deceased":
@@ -1935,17 +2109,15 @@ def main():
# If the records disagreed on a field, the merge had to pick a
# winner — record that as a provenance note.
if len(votes) > 1:
- FIELD_LABEL = {
- "DateOfBirth": "Geburtsdatum",
- "DateOfDeath": "Sterbedatum",
- "Gender": "Geschlecht",
- "Genotype": "Genotyp",
- "ColorVarietyId": "Farbschlag",
- }
conflict_notes.append(
- f"Konflikt bei {FIELD_LABEL[field]} per Mehrheitsentscheidung gelöst"
+ f"Konflikt bei {PROV_FIELD_LABELS[field]} per Mehrheitsentscheidung gelöst"
)
best_g[field] = best_val
+ # Attribute the winning value to a record that actually holds
+ # it, so the history names the right file.
+ winner = next((g for g in sub if g.get(field) == best_val), None)
+ if winner is not None:
+ field_source[field] = winner
# Keep helper fields in sync if we changed DateOfBirth
if field == "DateOfBirth":
best_g["_birth_date"] = best_val
@@ -1955,7 +2127,9 @@ def main():
best_g["Notes"] = " | ".join(merged_notes)
best_g["_photos"] = merged_photos
- best_g["Provenance"] = build_provenance(sub, best_g, extra_notes=conflict_notes)
+ best_g["Provenance"] = build_provenance(
+ sub, best_g, extra_notes=conflict_notes, field_source=field_source
+ )
# Print merge trace
print(f"Deduplicated same-animal name '{best_g['Name']}': merged {len(sub)} entries across files: {', '.join(sources)}")
@@ -2246,9 +2420,31 @@ def main():
if l_merged_count > 1:
l_notes.append(f"aus {l_merged_count} Datensätzen zusammengeführt")
l_notes.append("Geschwister-Würfe zusammengeführt")
+
+ # Chronological, file-attributed history for the litter.
+ l_files_sorted = sorted({f for f in l_source_files if f})
+ l_history = []
+ first_file = l_files_sorted[0] if l_files_sorted else None
+ if is_virtual and not l_from_wurfchronik:
+ if first_file:
+ l_history.append(
+ f"Aus Stammbaum-Diagramm rekonstruiert ({_quote_file(first_file)})."
+ )
+ else:
+ l_history.append("Aus Stammbaum-Diagramm rekonstruiert.")
+ elif first_file:
+ l_history.append(f"Wurf aus Wurfchronik {_quote_file(first_file)}.")
+ else:
+ l_history.append("Wurf im Import gefunden.")
+ for f in l_files_sorted[1:]:
+ l_history.append(
+ f"Auch in {_quote_file(f)} gefunden → Datensätze zusammengeführt."
+ )
+ if l_merged_count > 1:
+ l_history.append("Geschwister-Würfe zusammengeführt.")
l["Provenance"] = build_entity_provenance(
l_source_files, l_merged_count, notes=l_notes,
- from_wurfchronik=l_from_wurfchronik,
+ from_wurfchronik=l_from_wurfchronik, history=l_history,
)
# Set IsBreeder and IsReceiver flags on contacts
@@ -2275,7 +2471,24 @@ def main():
c_notes.append("als Züchter erkannt")
if is_receiver:
c_notes.append("als Abnehmer erkannt")
- c["Provenance"] = build_entity_provenance(c_source_files, c_merged_count, notes=c_notes)
+
+ # Chronological, file-attributed history for the contact.
+ c_files_sorted = sorted({f for f in c_source_files if f})
+ c_history = []
+ role_word = "Züchter" if is_breeder else "Abnehmer"
+ if c_files_sorted:
+ c_history.append(f"In {_quote_file(c_files_sorted[0])} als {role_word} erkannt.")
+ for f in c_files_sorted[1:]:
+ c_history.append(
+ f"Auch in {_quote_file(f)} gefunden → Datensätze zusammengeführt."
+ )
+ else:
+ c_history.append(f"Im Import als {role_word} erkannt.")
+ if is_breeder and is_receiver:
+ c_history.append("Sowohl als Züchter als auch als Abnehmer geführt.")
+ c["Provenance"] = build_entity_provenance(
+ c_source_files, c_merged_count, notes=c_notes, history=c_history
+ )
# Set and map gerbilPhotos
resolved_photos = []
diff --git a/tools/import/test_merge_resolve.py b/tools/import/test_merge_resolve.py
index 668bd54..3fbce82 100644
--- a/tools/import/test_merge_resolve.py
+++ b/tools/import/test_merge_resolve.py
@@ -213,6 +213,70 @@ mr = m.pick_parent_ref(molly_refs, "mother", "13.09.2021", avoid_name=fr["name"]
check("pick(gender): mother = Arya (female)", mr and mr["name"].startswith("Arya"))
+# ── Provenance history: chronological, file-attributed German log ──
+import json as _json
+
+
+def _hist(prov_json):
+ return _json.loads(prov_json)["history"]
+
+
+# build_entity_provenance carries history through verbatim.
+_prov = _json.loads(
+ m.build_entity_provenance(["A.xlsx"], 1, notes=["x"], history=["line one"])
+)
+check("build_entity_provenance includes history key", _prov.get("history") == ["line one"])
+check("build_entity_provenance defaults history to []",
+ _json.loads(m.build_entity_provenance(["A.xlsx"], 1)).get("history") == [])
+
+# Single-record gerbil history: names the file and the per-field facts.
+g_single = {
+ "Name": "Picus", "DateOfBirth": "2022-03-27", "Gender": "male",
+ "Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
+ "ImportSource": "Stammbaum von Picus Son.xlsx",
+ "_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
+}
+h = m._build_gerbil_history([g_single], g_single, {})
+check("history: first line names the source file",
+ h[0] == "In „Stammbaum von Picus Son.xlsx“ gefunden.")
+check("history: dob line names file + formatted date",
+ "Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h)
+check("history: gender line is German + file-attributed",
+ "Geschlecht (männlich) aus „Stammbaum von Picus Son.xlsx“." in h)
+check("history: genotype line file-attributed",
+ "Genotyp aus „Stammbaum von Picus Son.xlsx“." in h)
+
+# Merged gerbil: a field sourced from a DIFFERENT file is attributed to THAT file.
+g_best = {
+ "Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
+ "Genotype": None, "ColorVarietyId": None, "DateOfDeath": None,
+ "ImportSource": "Stammbaum von Picus Son.xlsx",
+ "_filename": "Stammbaum von Picus Son.xlsx", "parentRefs": [],
+}
+g_other = {
+ "Name": "Solice", "DateOfBirth": "2022-03-27", "Gender": "male",
+ "Genotype": "aa", "ColorVarietyId": None, "DateOfDeath": None,
+ "ImportSource": "Wurfchronik-Detail.docx",
+ "_filename": "Wurfchronik-Detail.docx", "parentRefs": [],
+}
+# Genotype was filled from g_other → its line must name the docx file.
+g_best["Genotype"] = "aa"
+fs = {"DateOfBirth": g_best, "Gender": g_best, "Genotype": g_other}
+h2 = m._build_gerbil_history([g_best, g_other], g_best, fs)
+check("history(merge): genotype attributed to the file that supplied it",
+ "Genotyp aus „Wurfchronik-Detail.docx“." in h2)
+check("history(merge): dob attributed to primary file",
+ "Geburtsdatum (27.03.2022) aus „Stammbaum von Picus Son.xlsx“." in h2)
+check("history(merge): merge line names the absorbed file",
+ "Auch in „Wurfchronik-Detail.docx“ gefunden → Datensätze zusammengeführt." in h2)
+check("history(merge): Wurfchronik line present",
+ "Angaben aus der Wurfchronik übernommen." in h2)
+
+# Date formatting helper.
+check("_de_date: ISO → DD.MM.YYYY", m._de_date("2022-03-27") == "27.03.2022")
+check("_de_date: passes through non-ISO", m._de_date("unbekannt") == "unbekannt")
+
+
if check.failed:
print(f"\n{check.failed} test(s) FAILED")
sys.exit(1)