From fe7a69fb078556cb49d4a80367baebab4beae775 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 10 Apr 2026 11:19:44 +0200 Subject: [PATCH] feat(csv-import): Enhance source unit handling and custom conversion options - Updated the source_unit_choices_for_field function to include a custom option for user-defined conversion factors, improving flexibility in unit conversions. - Modified the AdminCsvTemplateEditorPage to support custom conversion factors, allowing users to input specific scaling factors for their data. - Added tests to ensure the custom option is correctly included in the source unit choices and functions as expected in the template editor. --- backend/csv_parser/field_units.py | 11 +- backend/tests/test_csv_parser_core.py | 7 + .../src/pages/AdminCsvTemplateEditorPage.jsx | 132 ++++++++++++++---- 3 files changed, 120 insertions(+), 30 deletions(-) diff --git a/backend/csv_parser/field_units.py b/backend/csv_parser/field_units.py index f5579fe..3f81cf5 100644 --- a/backend/csv_parser/field_units.py +++ b/backend/csv_parser/field_units.py @@ -68,7 +68,7 @@ def source_unit_choices_for_field(module: str, db_field: str) -> list[dict[str, choices = _UNIT_FAMILY.get(cu) if not choices: return [] - return [ + out: list[dict[str, Any]] = [ { "id": c["id"], "label": c["label"], @@ -77,6 +77,15 @@ def source_unit_choices_for_field(module: str, db_field: str) -> list[dict[str, } for c in choices ] + out.append( + { + "id": "custom", + "label": "Benutzerdefiniert (Konvertierungsfaktor, z. B. ml→g je nach Dichte)", + "canonical_unit": cu, + "is_canonical": False, + } + ) + return out def factor_source_to_canonical(module: str, db_field: str, source_unit: str | None) -> float: diff --git a/backend/tests/test_csv_parser_core.py b/backend/tests/test_csv_parser_core.py index 6fae6a9..4a5c9b0 100644 --- a/backend/tests/test_csv_parser_core.py +++ b/backend/tests/test_csv_parser_core.py @@ -12,6 +12,7 @@ from csv_parser.core import ( get_csv_import_limits, iter_csv_dict_rows, ) +from csv_parser.field_units import source_unit_choices_for_field from csv_parser.mapping_suggest import build_type_conversions_for_mapping from csv_parser.type_converter import convert_value, build_row_after_mapping @@ -213,3 +214,9 @@ def test_datetime_flexible(): spec = {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True} dtv = convert_value("15.01.2024 14:30:00", "t", spec) assert dtv.month == 1 and dtv.day == 15 and dtv.hour == 14 + + +def test_source_unit_choices_include_custom_at_end(): + opts = source_unit_choices_for_field("nutrition", "protein_g") + assert opts[-1]["id"] == "custom" + assert any(o["id"] == "mg" for o in opts) diff --git a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx index 5d27a7b..b3ee163 100644 --- a/frontend/src/pages/AdminCsvTemplateEditorPage.jsx +++ b/frontend/src/pages/AdminCsvTemplateEditorPage.jsx @@ -187,6 +187,10 @@ export default function AdminCsvTemplateEditorPage() { const hit = opts.find((o) => o.id === sid) if (hit) return hit.id } + if (tc[fieldKey]?.conversion_factor != null && tc[fieldKey]?.conversion_factor !== '') { + const hasCustomOpt = opts.some((o) => o.id === 'custom') + if (hasCustomOpt) return 'custom' + } return canonical || '' } @@ -204,13 +208,59 @@ export default function AdminCsvTemplateEditorPage() { tc[fieldKey] && typeof tc[fieldKey] === 'object' ? { ...tc[fieldKey] } : { type: 'float', decimal_separator: 'auto', flexible: true } + delete base.target_unit if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) { delete base.source_unit + delete base.conversion_factor + } else if (sourceUnitId === 'custom') { + base.source_unit = 'custom' + // conversion_factor nur per Eingabefeld setzen/löschen } else { base.source_unit = sourceUnitId + delete base.conversion_factor + } + tc[fieldKey] = base + setTypeConversionsText(JSON.stringify(tc, null, 2)) + setError(null) + } + + const getCustomConversionFactorInputValue = (fieldKey) => { + let tc + try { + tc = JSON.parse(typeConversionsText || '{}') + } catch { + return '' + } + const v = tc[fieldKey]?.conversion_factor + if (v == null || v === '') return '' + return String(v) + } + + const updateCustomConversionFactor = (fieldKey, raw) => { + let tc + try { + tc = JSON.parse(typeConversionsText || '{}') + } catch { + setError('type_conversions: ungültiges JSON (Faktor kann nicht gesetzt werden).') + return + } + const base = + tc[fieldKey] && typeof tc[fieldKey] === 'object' + ? { ...tc[fieldKey] } + : { type: 'float', decimal_separator: 'auto', flexible: true } + const t = String(raw).trim().replace(',', '.') + if (t === '') { + delete base.conversion_factor + } else { + const n = Number(t) + if (Number.isNaN(n)) { + setError('Konvertierungsfaktor: bitte eine gültige Zahl eingeben.') + return + } + base.conversion_factor = n + base.source_unit = 'custom' } delete base.target_unit - delete base.conversion_factor tc[fieldKey] = base setTypeConversionsText(JSON.stringify(tc, null, 2)) setError(null) @@ -591,36 +641,60 @@ export default function AdminCsvTemplateEditorPage() {
3b. Quelleinheit (optional)

- Ziel-Einheit kommt aus dem Datenmodell (z. B. kcal, g, kg, km). Hier nur angeben, falls die CSV - abweicht (z. B. FDDB kJ → kcal, Makros in kg → g, Gewicht in lb → kg). Beim Ändern hier werden - pro Feld conversion_factor und target_unit entfernt (verhindert - Doppel-Umrechnung); bei Bedarf wieder in Abschnitt 4 ergänzen. + Ziel-Einheit kommt aus dem Datenmodell (z. B. kcal, g, kg, km). Standard-Umrechnungen wählen; für + abweichende Skalen (z. B. Volumen→Masse mit variabler Dichte) "Benutzerdefiniert" und den + Faktor eintragen (CSV-Wert × Faktor → Speicher-Einheit). Bei Registry-Optionen werden{' '} + target_unit und ein alter conversion_factor entfernt; bei + Benutzerdefiniert bleibt der Faktor erhalten, bis du ihn leerst.

-
+
{unitTargets.map(({ field: fkey, options }) => ( - +
+ + {getSourceUnitSelectValue(fkey) === 'custom' && ( +
+ + Konvertierungsfaktor (× CSV-Wert → Wert in Speicher-Einheit) + + updateCustomConversionFactor(fkey, e.target.value)} + style={{ width: '100%', textAlign: 'left' }} + /> + {getCustomConversionFactorInputValue(fkey) === '' && ( +

+ Ohne Faktor gilt keine zusätzliche Skalierung (wie 1:1 nach dem Parsen). +

+ )} +
+ )} +
))}