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) choices = _UNIT_FAMILY.get(cu)
if not choices: if not choices:
return [] return []
return [ out: list[dict[str, Any]] = [
{ {
"id": c["id"], "id": c["id"],
"label": c["label"], "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 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: 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, get_csv_import_limits,
iter_csv_dict_rows, 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.mapping_suggest import build_type_conversions_for_mapping
from csv_parser.type_converter import convert_value, build_row_after_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} spec = {"type": "datetime", "format": "yyyy-mm-dd HH:MM:SS", "flexible": True}
dtv = convert_value("15.01.2024 14:30:00", "t", spec) dtv = convert_value("15.01.2024 14:30:00", "t", spec)
assert dtv.month == 1 and dtv.day == 15 and dtv.hour == 14 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) const hit = opts.find((o) => o.id === sid)
if (hit) return hit.id 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 || '' return canonical || ''
} }
@ -204,13 +208,59 @@ export default function AdminCsvTemplateEditorPage() {
tc[fieldKey] && typeof tc[fieldKey] === 'object' tc[fieldKey] && typeof tc[fieldKey] === 'object'
? { ...tc[fieldKey] } ? { ...tc[fieldKey] }
: { type: 'float', decimal_separator: 'auto', flexible: true } : { type: 'float', decimal_separator: 'auto', flexible: true }
delete base.target_unit
if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) { if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) {
delete base.source_unit 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 { } else {
base.source_unit = sourceUnitId 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.target_unit
delete base.conversion_factor
tc[fieldKey] = base tc[fieldKey] = base
setTypeConversionsText(JSON.stringify(tc, null, 2)) setTypeConversionsText(JSON.stringify(tc, null, 2))
setError(null) setError(null)
@ -591,36 +641,60 @@ export default function AdminCsvTemplateEditorPage() {
<div className="card" style={{ padding: 16, marginBottom: 16 }}> <div className="card" style={{ padding: 16, marginBottom: 16 }}>
<div className="form-label">3b. Quelleinheit (optional)</div> <div className="form-label">3b. Quelleinheit (optional)</div>
<p style={{ fontSize: 13, color: 'var(--text2)', marginTop: 8, lineHeight: 1.55 }}> <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 Ziel-Einheit kommt aus dem Datenmodell (z.B. kcal, g, kg, km). Standard-Umrechnungen wählen; für
abweicht (z.B. FDDB kJ kcal, Makros in kg g, Gewicht in lb kg). Beim Ändern hier werden abweichende Skalen (z.B. VolumenMasse mit variabler Dichte) &quot;Benutzerdefiniert&quot; und den
pro Feld <code>conversion_factor</code> und <code>target_unit</code> entfernt (verhindert Faktor eintragen (CSV-Wert × Faktor Speicher-Einheit). Bei Registry-Optionen werden{' '}
Doppel-Umrechnung); bei Bedarf wieder in Abschnitt 4 ergänzen. <code>target_unit</code> und ein alter <code>conversion_factor</code> entfernt; bei
Benutzerdefiniert bleibt der Faktor erhalten, bis du ihn leerst.
</p> </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 }) => ( {unitTargets.map(({ field: fkey, options }) => (
<label key={fkey} style={{ display: 'block' }}> <div key={fkey}>
<span className="form-label" style={{ display: 'block', marginBottom: 6 }}> <label style={{ display: 'block' }}>
{fkey} <span className="form-label" style={{ display: 'block', marginBottom: 6 }}>
</span> {fkey}
<select </span>
className="form-input" <select
value={getSourceUnitSelectValue(fkey) || ''} className="form-input"
onChange={(e) => updateSourceUnit(fkey, e.target.value)} value={getSourceUnitSelectValue(fkey) || ''}
style={{ onChange={(e) => updateSourceUnit(fkey, e.target.value)}
width: '100%', style={{
minHeight: 46, width: '100%',
textAlign: 'left', minHeight: 46,
padding: '11px 14px', textAlign: 'left',
fontSize: 15, padding: '11px 14px',
}} fontSize: 15,
> }}
{options.map((o) => ( >
<option key={o.id} value={o.id}> {options.map((o) => (
{o.label} <option key={o.id} value={o.id}>
</option> {o.label}
))} </option>
</select> ))}
</label> </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>
</div> </div>