diff --git a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md index e4fd5fd..1e3cd54 100644 --- a/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md +++ b/.claude/docs/technical/ACTIVITY_PRODUCTION_ARCHITECTURE_AND_PHASES.md @@ -155,6 +155,18 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel **Abhängigkeit:** Phase A + B (sonst Lücken beim Lesen). +**Analyse (2026-04-16, nur Ist-Review):** Es gibt **keinen aktiven** Schreibpfad mehr, der `activity_log`-Spalten für `source_field`-Parameter **dauerhaft nach EAV spiegelt**. + +| Prüfpunkt | Ergebnis | +|-----------|----------| +| `sync_column_backed_session_metrics` | Nur noch **Definition** in `activity_session_metrics.py`, als veraltet markiert; **keine Aufrufer** im Repo (grep). Laufzeit-Sync: **abgestellt**. | +| `run_activity_post_write_hooks` / `…_import` | Nur **Auto-Eval** (optional); Kommentar: **kein** Spalte→EAV-Sync. | +| Universal-CSV (`executor.py`) | Kernfelder → `activity_log` (`activity_csv_registry_updates_from_mapped` + `update_activity_columns` / Insert); EAV → `upsert_session_metrics_from_csv_mapped`. Registry-Keys werden **nicht** nach EAV geschrieben; bei `source_field` wird EAV **übersprungen**, wenn die Spalte **bereits befüllt** ist — vermeidet bewusst doppelte Speicherung. | +| REST `PUT /metrics` | Kommentar in Code: **kein** `sync_column_backed` nach EAV-Ersatz. | +| Migrationen 055 / 057 | **Einmaliger** Backfill/Schwenk, kein fortlaufender Sync. | + +**Lesepfad (2026-04-16):** `merge_column_backed_and_eav_metrics` bevorzugt **immer** `activity_log`, wenn ein kanonischer Spaltenwert existiert: zuerst `source_field`, dann Registry-Spalte gleichen Keys, dann Legacy-Spalten für EAV-primäre Parameter, zuletzt EAV. Doppelte physische Schreiborte sind damit in der effektiven Sicht **ohne EAV-Vorrang** behoben. + --- ### Phase D – Composite MVP @@ -200,4 +212,4 @@ Phasen sind **sequentiell** wo „Abhängigkeit“ steht; Teile können parallel --- -**Version:** 1.3 · Phase-B-Export an `enrich_sessions_with_metrics` angebunden (`exportdata.py`). +**Version:** 1.5 · Merge: activity_log (Registry + Legacy-Spalten) vor EAV bei Lesen. diff --git a/.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md b/.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md index c51816b..8d3fc96 100644 --- a/.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md +++ b/.claude/docs/technical/ACTIVITY_SCALAR_KANON_TABLE.md @@ -46,19 +46,19 @@ Schreibpfad: Universal-CSV und API sollen diese Keys auf **`activity_log`** mapp `ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS` in `activity_data_canon.py`. **`training_parameters.source_field`** = NULL (nach Kanon / Migration 057): kanonischer Speicher ist **`activity_session_metrics`**. -| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` (nur Lesefallback) | Kanonische Quelle | Lesefallback | -|-------------------------------------------|-------------------------------------------------|-------------------|--------------| -| `min_hr` | `hr_min` | **EAV** | Spalte, wenn EAV leer | -| `pace_min_per_km` | `pace_min_per_km` | **EAV** | Spalte, wenn EAV leer | -| `cadence` | `cadence` | **EAV** | Spalte, wenn EAV leer | -| `avg_power` | `avg_power` | **EAV** | Spalte, wenn EAV leer | -| `elevation_gain` | `elevation_gain` | **EAV** | Spalte, wenn EAV leer | -| `temperature_celsius` | `temperature_celsius` | **EAV** | Spalte, wenn EAV leer | -| `humidity_percent` | `humidity_percent` | **EAV** | Spalte, wenn EAV leer | -| `avg_hr_percent` | `avg_hr_percent` | **EAV** | Spalte, wenn EAV leer | -| `kcal_per_km` | `kcal_per_km` | **EAV** | Spalte, wenn EAV leer | +| Parameter-Key (`training_parameters.key`) | Legacy-Spalte `activity_log` | Schreib-Kanon (Ziel) | +|-------------------------------------------|------------------------------|------------------------| +| `min_hr` | `hr_min` | **EAV** | +| `pace_min_per_km` | `pace_min_per_km` | **EAV** | +| `cadence` | `cadence` | **EAV** | +| `avg_power` | `avg_power` | **EAV** | +| `elevation_gain` | `elevation_gain` | **EAV** | +| `temperature_celsius` | `temperature_celsius` | **EAV** | +| `humidity_percent` | `humidity_percent` | **EAV** | +| `avg_hr_percent` | `avg_hr_percent` | **EAV** | +| `kcal_per_km` | `kcal_per_km` | **EAV** | -Merge-Implementierung: `merge_column_backed_and_eav_metrics` in `activity_session_metrics.py`. +**Lesen:** `merge_column_backed_and_eav_metrics` — wenn Legacy-Spalte **und** EAV einen Wert haben, **gewinnt die Spalte** (kanonische `activity_log`-Sicht). EAV nur, wenn die Spalte leer/nicht koerzierbar ist. --- diff --git a/backend/data_layer/activity_data_canon.py b/backend/data_layer/activity_data_canon.py index 11c171d..c474c06 100644 --- a/backend/data_layer/activity_data_canon.py +++ b/backend/data_layer/activity_data_canon.py @@ -31,7 +31,7 @@ ACTIVITY_MODULE_REGISTRY_FIELD_KEYS: Final[frozenset[str]] = get_activity_module ACTIVITY_LOG_PATCHABLE_COLUMNS: Final[frozenset[str]] = ACTIVITY_MODULE_REGISTRY_FIELD_KEYS - {"date"} # Parameter-Keys (training_parameters.key), die primär in EAV geführt werden; source_field nach Migration 057 NULL. -# Lesefallback: activity_log-Spalte unter ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM, falls EAV leer. +# Lesen (Merge): activity_log-Legacy-Spalte schlägt EAV, wenn beide befüllt; sonst EAV. ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset( { "min_hr", @@ -46,7 +46,7 @@ ACTIVITY_EAV_PRIMARY_PARAMETER_KEYS: Final[frozenset[str]] = frozenset( } ) -# Spaltenname activity_log für Legacy-Lesefallback (Merge), wenn EAV für den Parameter fehlt. +# Spaltenname activity_log für Legacy-Merge (Vorrang vor EAV bei gesetztem Spaltenwert). ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM: Final[Dict[str, str]] = { "min_hr": "hr_min", "pace_min_per_km": "pace_min_per_km", diff --git a/backend/data_layer/activity_persistence_orchestrator.py b/backend/data_layer/activity_persistence_orchestrator.py index 6a390dd..2e128e2 100644 --- a/backend/data_layer/activity_persistence_orchestrator.py +++ b/backend/data_layer/activity_persistence_orchestrator.py @@ -1,5 +1,5 @@ """ -Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval, Spalten→EAV). +Zentrale Persistenz für activity_log + EAV-Nebenwirkungen (Eval). Alle Schreibpfade (REST, Universal-CSV, Legacy-Upload) laufen hier zusammen. diff --git a/backend/data_layer/activity_session_metrics.py b/backend/data_layer/activity_session_metrics.py index 62783ef..fcd0c68 100644 --- a/backend/data_layer/activity_session_metrics.py +++ b/backend/data_layer/activity_session_metrics.py @@ -323,9 +323,15 @@ def merge_column_backed_and_eav_metrics( """ Effektive Metrikliste **nur** für Parameter aus ``schema`` (Kategorie + Trainingstyp / tcp+ttp). - Pro Schema-Parameter mit ``source_field`` hat ``activity_log`` Vorrang, wenn die Spalte befüllt und - koerzierbar ist; sonst EAV, sonst Legacy-Spalte. EAV-Zeilen zu Parametern, die nicht im Schema sind, - werden nicht ausgegeben (Darstellung und Auswertung folgen ausschließlich dem konfigurierten Profil). + Kanon beim Lesen: **activity_log** schlägt EAV, sobald ein passender Spaltenwert existiert und + koerzierbar ist — in dieser Reihenfolge: + + 1. ``source_field`` → Spalte + 2. Parameter-Key = Registry-Kernfeld (``ACTIVITY_MODULE_REGISTRY_FIELD_KEYS``) → gleichnamige Spalte + 3. EAV-primäre Keys → Legacy-Spalte laut ``ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM`` + 4. sonst EAV + + EAV-Zeilen zu Parametern, die nicht im Schema sind, werden nicht ausgegeben. """ eav_by_key = {m["key"]: m for m in eav_metrics} merged: List[Dict[str, Any]] = [] @@ -360,10 +366,23 @@ def merge_column_backed_and_eav_metrics( if used_column: continue - if k in eav_by_key: - merged.append(dict(eav_by_key[k])) - keys_handled.add(k) - continue + + if k in ACTIVITY_MODULE_REGISTRY_FIELD_KEYS and k in header and header[k] is not None: + try: + val = _coerce_raw_value_for_parameter(dt, header[k]) + merged.append( + { + "training_parameter_id": tid, + "key": k, + "data_type": dt, + "unit": unit, + "value": val, + } + ) + keys_handled.add(k) + continue + except (TypeError, ValueError): + pass legacy_col = ACTIVITY_LOG_LEGACY_COLUMN_FOR_EAV_PRIMARY_PARAM.get(k) if legacy_col and legacy_col in header and header[legacy_col] is not None: @@ -379,9 +398,14 @@ def merge_column_backed_and_eav_metrics( } ) keys_handled.add(k) + continue except (TypeError, ValueError): pass + if k in eav_by_key: + merged.append(dict(eav_by_key[k])) + keys_handled.add(k) + merged.sort(key=lambda x: x["key"]) return merged diff --git a/backend/tests/test_activity_session_metrics.py b/backend/tests/test_activity_session_metrics.py index 21dd2e5..2178b0f 100644 --- a/backend/tests/test_activity_session_metrics.py +++ b/backend/tests/test_activity_session_metrics.py @@ -307,6 +307,58 @@ def test_merge_eav_primary_falls_back_to_legacy_hr_min_column(): assert out[0]["value"] == 88 +def test_merge_eav_primary_prefers_legacy_column_over_eav_when_both(): + """Kanon: bei min_hr + hr_min und EAV-Zeile gewinnt activity_log (hr_min).""" + schema = [ + { + "training_parameter_id": 9, + "key": "min_hr", + "data_type": "integer", + "unit": "bpm", + "validation_rules": {}, + "source_field": None, + } + ] + eav = [ + { + "training_parameter_id": 9, + "key": "min_hr", + "data_type": "integer", + "unit": "bpm", + "value": 100, + } + ] + out = merge_column_backed_and_eav_metrics({"hr_min": 88}, schema, eav) + assert len(out) == 1 + assert out[0]["value"] == 88 + + +def test_merge_registry_key_prefers_activity_log_column_over_eav(): + """Parameter-Key = Registry-Feld (z. B. duration_min): Spalte vor EAV.""" + schema = [ + { + "training_parameter_id": 3, + "key": "duration_min", + "data_type": "float", + "unit": "min", + "validation_rules": {}, + "source_field": None, + } + ] + eav = [ + { + "training_parameter_id": 3, + "key": "duration_min", + "data_type": "float", + "unit": "min", + "value": 99.0, + } + ] + out = merge_column_backed_and_eav_metrics({"duration_min": 45.0}, schema, eav) + assert len(out) == 1 + assert out[0]["value"] == 45.0 + + @patch("data_layer.activity_session_metrics.resolve_activity_attribute_schema") def test_upsert_csv_skips_eav_when_source_field_maps_activity_log(mock_schema): """Parameter mit source_field: kanonisch activity_log — kein doppeltes EAV (z. B. avg_hr → hr_avg)."""