feat(csv-import): Enhance source unit handling and custom conversion options
Some checks failed
Deploy Development / deploy (push) Successful in 52s
Build Test / pytest-backend (push) Failing after 2s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 16s

- 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.
This commit is contained in:
Lars 2026-04-10 11:19:44 +02:00
parent bb6eefc837
commit fe7a69fb07
3 changed files with 120 additions and 30 deletions

View File

@ -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:

View File

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

View File

@ -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() {
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3b. Quelleinheit (optional)</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.55 }}>
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 <code>conversion_factor</code> und <code>target_unit</code> 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. VolumenMasse mit variabler Dichte) &quot;Benutzerdefiniert&quot; und den
Faktor eintragen (CSV-Wert × Faktor Speicher-Einheit). Bei Registry-Optionen werden{' '}
<code>target_unit</code> und ein alter <code>conversion_factor</code> entfernt; bei
Benutzerdefiniert bleibt der Faktor erhalten, bis du ihn leerst.
</p>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 12 }}>
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 16 }}>
{unitTargets.map(({ field: fkey, options }) => (
<label key={fkey} style={{ display: 'block' }}>
<span className="form-label" style={{ display: 'block', marginBottom: 6 }}>
{fkey}
</span>
<select
className="form-input"
value={getSourceUnitSelectValue(fkey) || ''}
onChange={(e) => updateSourceUnit(fkey, e.target.value)}
style={{
width: '100%',
minHeight: 46,
textAlign: 'left',
padding: '11px 14px',
fontSize: 15,
}}
>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</label>
<div key={fkey}>
<label style={{ display: 'block' }}>
<span className="form-label" style={{ display: 'block', marginBottom: 6 }}>
{fkey}
</span>
<select
className="form-input"
value={getSourceUnitSelectValue(fkey) || ''}
onChange={(e) => updateSourceUnit(fkey, e.target.value)}
style={{
width: '100%',
minHeight: 46,
textAlign: 'left',
padding: '11px 14px',
fontSize: 15,
}}
>
{options.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</label>
{getSourceUnitSelectValue(fkey) === 'custom' && (
<div style={{ marginTop: 10 }}>
<span className="form-label" style={{ display: 'block', marginBottom: 6 }}>
Konvertierungsfaktor (× CSV-Wert Wert in Speicher-Einheit)
</span>
<input
type="text"
inputMode="decimal"
className="form-input"
placeholder="z. B. 1.03 bei ml→g (Dichte ≈ 1,03 g/ml)"
value={getCustomConversionFactorInputValue(fkey)}
onChange={(e) => updateCustomConversionFactor(fkey, e.target.value)}
style={{ width: '100%', textAlign: 'left' }}
/>
{getCustomConversionFactorInputValue(fkey) === '' && (
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 6 }}>
Ohne Faktor gilt keine zusätzliche Skalierung (wie 1:1 nach dem Parsen).
</p>
)}
</div>
)}
</div>
))}
</div>
</div>