Universal CSV Importer #70

Merged
Lars merged 54 commits from develop into main 2026-04-11 07:06:47 +02:00
3 changed files with 120 additions and 30 deletions
Showing only changes of commit fe7a69fb07 - Show all commits

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,14 +641,16 @@ 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}>
<label style={{ display: 'block' }}>
<span className="form-label" style={{ display: 'block', marginBottom: 6 }}> <span className="form-label" style={{ display: 'block', marginBottom: 6 }}>
{fkey} {fkey}
</span> </span>
@ -621,6 +673,28 @@ export default function AdminCsvTemplateEditorPage() {
))} ))}
</select> </select>
</label> </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>