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.
This commit is contained in:
parent
bb6eefc837
commit
fe7a69fb07
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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. Volumen→Masse mit variabler Dichte) "Benutzerdefiniert" 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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user