refactor(csv-import): Simplify test execution and enhance custom equivalence handling
- Updated the test execution command in the CI workflow to run all tests excluding slow ones, improving efficiency. - Enhanced the AdminCsvTemplateEditorPage to support custom equivalence for unit conversions, allowing for more flexible data handling. - Added markers in pytest configuration for categorizing tests, facilitating better test management.
This commit is contained in:
parent
8ee9fb84ba
commit
8b67f7ab55
|
|
@ -34,13 +34,7 @@ jobs:
|
||||||
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
docker compose -f "$COMPOSE_FILE" exec -T backend sh -lc "
|
||||||
pip install -r /app/requirements-dev.txt &&
|
pip install -r /app/requirements-dev.txt &&
|
||||||
cd /app &&
|
cd /app &&
|
||||||
python -m pytest \
|
python -m pytest tests -m 'not slow' -q --tb=short
|
||||||
tests/test_csv_parser_core.py \
|
|
||||||
tests/test_csv_import_executor.py \
|
|
||||||
tests/test_mapping_suggest.py \
|
|
||||||
tests/test_placeholder_metadata.py \
|
|
||||||
tests/test_placeholder_metadata_v2.py \
|
|
||||||
-q --tb=short
|
|
||||||
"
|
"
|
||||||
|
|
||||||
lint-backend:
|
lint-backend:
|
||||||
|
|
|
||||||
|
|
@ -3,3 +3,7 @@ testpaths = tests
|
||||||
python_files = test_*.py
|
python_files = test_*.py
|
||||||
python_functions = test_*
|
python_functions = test_*
|
||||||
addopts = -q --tb=short
|
addopts = -q --tb=short
|
||||||
|
markers =
|
||||||
|
smoke: Fast smoke tests for core regression checks.
|
||||||
|
integration: Integration tests across modules/db/api behavior.
|
||||||
|
slow: Long-running or heavy tests.
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,19 @@ const MODULE_LABEL = {
|
||||||
vitals_baseline: 'Vitalwerte (Baseline)',
|
vitals_baseline: 'Vitalwerte (Baseline)',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Vorschläge für Freitext „Quelleinheit“ (Volumen, Stück …); Ziel kommt aus dem Datenmodell. */
|
||||||
|
const CUSTOM_SOURCE_UNIT_HINTS = [
|
||||||
|
'ml',
|
||||||
|
'l',
|
||||||
|
'cl',
|
||||||
|
'dl',
|
||||||
|
'µl',
|
||||||
|
'mg',
|
||||||
|
'µg',
|
||||||
|
'Stück',
|
||||||
|
'Portion',
|
||||||
|
]
|
||||||
|
|
||||||
/** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */
|
/** Erlaubt Eingaben wie 1,03 oder 1.03 während des Tippens; Finale normalisiert bei Blur/Speichern. */
|
||||||
function normalizeDecimalInputString(raw) {
|
function normalizeDecimalInputString(raw) {
|
||||||
let s = String(raw).trim().replace(/\s/g, '')
|
let s = String(raw).trim().replace(/\s/g, '')
|
||||||
|
|
@ -31,21 +44,70 @@ function normalizeDecimalInputString(raw) {
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyCustomFactorToTcObject(tc, fieldKey, raw) {
|
/**
|
||||||
|
* Semantik: „source_amount [source_unit_label] entspricht target_amount [canonical storage unit]“
|
||||||
|
* → CSV-Zahl (in der linken Einheit) × (target_amount / source_amount) → Speicherwert.
|
||||||
|
* custom_equivalence dient der Dokumentation im JSON; der Importer nutzt nur conversion_factor.
|
||||||
|
*/
|
||||||
|
function loadEquivalenceFromTc(tc, fieldKey) {
|
||||||
|
const row = tc[fieldKey]
|
||||||
|
if (!row || typeof row !== 'object') {
|
||||||
|
return { srcAmt: '1', srcUnit: '', tgtAmt: '' }
|
||||||
|
}
|
||||||
|
const eq = row.custom_equivalence
|
||||||
|
if (eq && typeof eq === 'object') {
|
||||||
|
const sa = eq.source_amount
|
||||||
|
const ta = eq.target_amount
|
||||||
|
return {
|
||||||
|
srcAmt: sa != null && sa !== '' ? String(sa) : '',
|
||||||
|
srcUnit: eq.source_unit_label != null ? String(eq.source_unit_label) : '',
|
||||||
|
tgtAmt: ta != null && ta !== '' ? String(ta) : '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const f = row.conversion_factor
|
||||||
|
if (f != null && f !== '') {
|
||||||
|
return { srcAmt: '1', srcUnit: '', tgtAmt: String(f) }
|
||||||
|
}
|
||||||
|
return { srcAmt: '1', srcUnit: '', tgtAmt: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyCustomEquivalenceToTcObject(tc, fieldKey, draft, canonicalUnitLabel) {
|
||||||
const base =
|
const base =
|
||||||
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 }
|
||||||
const normalized = normalizeDecimalInputString(raw)
|
const sa = normalizeDecimalInputString(draft.srcAmt ?? '')
|
||||||
if (normalized === '') {
|
const ta = normalizeDecimalInputString(draft.tgtAmt ?? '')
|
||||||
|
const unitLbl = String(draft.srcUnit ?? '').trim()
|
||||||
|
const bothEmpty = !sa && !ta
|
||||||
|
|
||||||
|
if (bothEmpty) {
|
||||||
delete base.conversion_factor
|
delete base.conversion_factor
|
||||||
} else {
|
delete base.custom_equivalence
|
||||||
const num = Number(normalized)
|
|
||||||
if (Number.isNaN(num)) {
|
|
||||||
return { ok: false, message: `Konvertierungsfaktor (${fieldKey}): keine gültige Zahl.` }
|
|
||||||
}
|
|
||||||
base.conversion_factor = num
|
|
||||||
base.source_unit = 'custom'
|
base.source_unit = 'custom'
|
||||||
|
delete base.target_unit
|
||||||
|
tc[fieldKey] = base
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
if (!sa || !ta) {
|
||||||
|
return { ok: true, partial: true }
|
||||||
|
}
|
||||||
|
const srcNum = Number(sa)
|
||||||
|
const tgtNum = Number(ta)
|
||||||
|
if (Number.isNaN(srcNum) || Number.isNaN(tgtNum)) {
|
||||||
|
return { ok: false, message: `Umrechnung (${fieldKey}): keine gültigen Zahlen.` }
|
||||||
|
}
|
||||||
|
if (srcNum === 0) {
|
||||||
|
return { ok: false, message: `Umrechnung (${fieldKey}): die linke Menge darf nicht 0 sein.` }
|
||||||
|
}
|
||||||
|
const factor = tgtNum / srcNum
|
||||||
|
base.conversion_factor = factor
|
||||||
|
base.source_unit = 'custom'
|
||||||
|
base.custom_equivalence = {
|
||||||
|
source_amount: srcNum,
|
||||||
|
source_unit_label: unitLbl || '(Quelleinheit)',
|
||||||
|
target_amount: tgtNum,
|
||||||
|
target_unit_label: canonicalUnitLabel || '(Ziel)',
|
||||||
}
|
}
|
||||||
delete base.target_unit
|
delete base.target_unit
|
||||||
tc[fieldKey] = base
|
tc[fieldKey] = base
|
||||||
|
|
@ -121,8 +183,8 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
const [analyzing, setAnalyzing] = useState(false)
|
const [analyzing, setAnalyzing] = useState(false)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState(null)
|
const [error, setError] = useState(null)
|
||||||
/** Lokaler Text für Konvertierungsfaktor (nur bei source_unit custom), Commit bei Blur/Speichern — verhindert 1. → 1 beim Tippen. */
|
/** Entwurf für „Quelle entspricht Ziel“ (nur source_unit custom); Commit bei Blur/Speichern. */
|
||||||
const [customFactorDraftByField, setCustomFactorDraftByField] = useState({})
|
const [customEquivalenceDraftByField, setCustomEquivalenceDraftByField] = useState({})
|
||||||
|
|
||||||
const modMeta = useMemo(() => modules.find((m) => m.id === module), [modules, module])
|
const modMeta = useMemo(() => modules.find((m) => m.id === module), [modules, module])
|
||||||
const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate'
|
const aggregateSleepImport = modMeta?.import_mode === 'apple_sleep_aggregate'
|
||||||
|
|
@ -176,7 +238,7 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
setFieldMappings(fm)
|
setFieldMappings(fm)
|
||||||
setColumns(Object.keys(fm))
|
setColumns(Object.keys(fm))
|
||||||
setTypeConversionsText(JSON.stringify(t.type_conversions || {}, null, 2))
|
setTypeConversionsText(JSON.stringify(t.type_conversions || {}, null, 2))
|
||||||
setCustomFactorDraftByField({})
|
setCustomEquivalenceDraftByField({})
|
||||||
setSampleRows([])
|
setSampleRows([])
|
||||||
setSeedHint(null)
|
setSeedHint(null)
|
||||||
})
|
})
|
||||||
|
|
@ -229,6 +291,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
|
||||||
}
|
}
|
||||||
|
const ce = tc[fieldKey]?.custom_equivalence
|
||||||
|
if (ce && typeof ce === 'object' && opts.some((o) => o.id === 'custom')) {
|
||||||
|
return 'custom'
|
||||||
|
}
|
||||||
if (tc[fieldKey]?.conversion_factor != null && tc[fieldKey]?.conversion_factor !== '') {
|
if (tc[fieldKey]?.conversion_factor != null && tc[fieldKey]?.conversion_factor !== '') {
|
||||||
const hasCustomOpt = opts.some((o) => o.id === 'custom')
|
const hasCustomOpt = opts.some((o) => o.id === 'custom')
|
||||||
if (hasCustomOpt) return 'custom'
|
if (hasCustomOpt) return 'custom'
|
||||||
|
|
@ -254,16 +320,17 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) {
|
if (sourceUnitId === canonical || sourceUnitId === '' || sourceUnitId == null) {
|
||||||
delete base.source_unit
|
delete base.source_unit
|
||||||
delete base.conversion_factor
|
delete base.conversion_factor
|
||||||
|
delete base.custom_equivalence
|
||||||
} else if (sourceUnitId === 'custom') {
|
} else if (sourceUnitId === 'custom') {
|
||||||
base.source_unit = '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
|
delete base.conversion_factor
|
||||||
|
delete base.custom_equivalence
|
||||||
}
|
}
|
||||||
tc[fieldKey] = base
|
tc[fieldKey] = base
|
||||||
setTypeConversionsText(JSON.stringify(tc, null, 2))
|
setTypeConversionsText(JSON.stringify(tc, null, 2))
|
||||||
setCustomFactorDraftByField((prev) => {
|
setCustomEquivalenceDraftByField((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[fieldKey]
|
delete next[fieldKey]
|
||||||
return next
|
return next
|
||||||
|
|
@ -271,28 +338,40 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCustomConversionFactorInputValue = (fieldKey) => {
|
const getCanonicalStorageUnitLabel = (fieldKey) => {
|
||||||
let tc
|
const opts = modMeta?.fields?.[fieldKey]?.source_unit_options || []
|
||||||
|
const c = opts.find((o) => o.is_canonical)
|
||||||
|
return c?.canonical_unit || c?.id || '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
const getEquivalenceDisplay = (fieldKey) => {
|
||||||
|
if (customEquivalenceDraftByField[fieldKey]) {
|
||||||
|
return customEquivalenceDraftByField[fieldKey]
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
tc = JSON.parse(typeConversionsText || '{}')
|
const tc = JSON.parse(typeConversionsText || '{}')
|
||||||
|
return loadEquivalenceFromTc(tc, fieldKey)
|
||||||
} catch {
|
} catch {
|
||||||
return ''
|
return { srcAmt: '1', srcUnit: '', tgtAmt: '' }
|
||||||
}
|
}
|
||||||
const v = tc[fieldKey]?.conversion_factor
|
|
||||||
if (v == null || v === '') return ''
|
|
||||||
return String(v)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCustomFactorFieldDisplay = (fieldKey) => {
|
const mergeEquivalenceDraft = (fieldKey, patch) => {
|
||||||
if (Object.prototype.hasOwnProperty.call(customFactorDraftByField, fieldKey)) {
|
setCustomEquivalenceDraftByField((prev) => {
|
||||||
return customFactorDraftByField[fieldKey]
|
let baseTc
|
||||||
}
|
try {
|
||||||
return getCustomConversionFactorInputValue(fieldKey)
|
baseTc = JSON.parse(typeConversionsText || '{}')
|
||||||
|
} catch {
|
||||||
|
baseTc = {}
|
||||||
|
}
|
||||||
|
const cur = prev[fieldKey] || loadEquivalenceFromTc(baseTc, fieldKey)
|
||||||
|
return { ...prev, [fieldKey]: { ...cur, ...patch } }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const commitCustomFactorOnBlur = (fieldKey, raw) => {
|
const commitCustomEquivalenceOnBlur = (fieldKey) => {
|
||||||
if (getSourceUnitSelectValue(fieldKey) !== 'custom') {
|
if (getSourceUnitSelectValue(fieldKey) !== 'custom') {
|
||||||
setCustomFactorDraftByField((prev) => {
|
setCustomEquivalenceDraftByField((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[fieldKey]
|
delete next[fieldKey]
|
||||||
return next
|
return next
|
||||||
|
|
@ -303,16 +382,23 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
try {
|
try {
|
||||||
tc = JSON.parse(typeConversionsText || '{}')
|
tc = JSON.parse(typeConversionsText || '{}')
|
||||||
} catch {
|
} catch {
|
||||||
setError('type_conversions: ungültiges JSON (Faktor kann nicht gesetzt werden).')
|
setError('type_conversions: ungültiges JSON (Umrechnung kann nicht gespeichert werden).')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const draft =
|
||||||
|
customEquivalenceDraftByField[fieldKey] || loadEquivalenceFromTc(tc, fieldKey)
|
||||||
|
const canon = getCanonicalStorageUnitLabel(fieldKey)
|
||||||
|
const result = applyCustomEquivalenceToTcObject(tc, fieldKey, draft, canon)
|
||||||
|
if (result.partial) {
|
||||||
|
setError(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const result = applyCustomFactorToTcObject(tc, fieldKey, raw)
|
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.message || 'Konvertierungsfaktor ungültig.')
|
setError(result.message || 'Ungültige Umrechnung.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTypeConversionsText(JSON.stringify(tc, null, 2))
|
setTypeConversionsText(JSON.stringify(tc, null, 2))
|
||||||
setCustomFactorDraftByField((prev) => {
|
setCustomEquivalenceDraftByField((prev) => {
|
||||||
const next = { ...prev }
|
const next = { ...prev }
|
||||||
delete next[fieldKey]
|
delete next[fieldKey]
|
||||||
return next
|
return next
|
||||||
|
|
@ -320,6 +406,20 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
setError(null)
|
setError(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const derivedFactorHintLine = (fieldKey) => {
|
||||||
|
const d = getEquivalenceDisplay(fieldKey)
|
||||||
|
const sa = normalizeDecimalInputString(d.srcAmt ?? '')
|
||||||
|
const ta = normalizeDecimalInputString(d.tgtAmt ?? '')
|
||||||
|
if (!sa || !ta) return null
|
||||||
|
const a = Number(sa)
|
||||||
|
const b = Number(ta)
|
||||||
|
if (Number.isNaN(a) || Number.isNaN(b) || a === 0) return null
|
||||||
|
const f = b / a
|
||||||
|
const fDisp = Number.isFinite(f) ? Math.round(f * 1e9) / 1e9 : f
|
||||||
|
const canon = getCanonicalStorageUnitLabel(fieldKey)
|
||||||
|
return `Abgeleitet: CSV-Zahl × ${fDisp} → Wert in ${canon} (Speicher)`
|
||||||
|
}
|
||||||
|
|
||||||
const handleAnalyze = async () => {
|
const handleAnalyze = async () => {
|
||||||
if (!file) {
|
if (!file) {
|
||||||
setError('Bitte eine CSV-Datei wählen.')
|
setError('Bitte eine CSV-Datei wählen.')
|
||||||
|
|
@ -339,7 +439,7 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
setDelimiter(res.delimiter || ';')
|
setDelimiter(res.delimiter || ';')
|
||||||
setEncoding(res.encoding || 'utf-8')
|
setEncoding(res.encoding || 'utf-8')
|
||||||
setSeedHint(res.seed_template || null)
|
setSeedHint(res.seed_template || null)
|
||||||
setCustomFactorDraftByField({})
|
setCustomEquivalenceDraftByField({})
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(e.message || 'Analyse fehlgeschlagen')
|
setError(e.message || 'Analyse fehlgeschlagen')
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -354,22 +454,32 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setError(null)
|
setError(null)
|
||||||
let textForTc = typeConversionsText
|
let textForTc = typeConversionsText
|
||||||
const pendingFactorDrafts = { ...customFactorDraftByField }
|
const pendingEquivalenceDrafts = { ...customEquivalenceDraftByField }
|
||||||
if (Object.keys(pendingFactorDrafts).length > 0) {
|
if (Object.keys(pendingEquivalenceDrafts).length > 0) {
|
||||||
try {
|
try {
|
||||||
const tco = JSON.parse(textForTc || '{}')
|
const tco = JSON.parse(textForTc || '{}')
|
||||||
for (const [fk, raw] of Object.entries(pendingFactorDrafts)) {
|
for (const fk of Object.keys(pendingEquivalenceDrafts)) {
|
||||||
const su = String(tco[fk]?.source_unit || '').toLowerCase()
|
const su = String(tco[fk]?.source_unit || '').toLowerCase()
|
||||||
if (su !== 'custom') continue
|
if (su !== 'custom') continue
|
||||||
const result = applyCustomFactorToTcObject(tco, fk, raw)
|
const draft = pendingEquivalenceDrafts[fk]
|
||||||
|
const opts = modMeta?.fields?.[fk]?.source_unit_options || []
|
||||||
|
const c = opts.find((o) => o.is_canonical)
|
||||||
|
const canon = c?.canonical_unit || c?.id || '—'
|
||||||
|
const result = applyCustomEquivalenceToTcObject(tco, fk, draft, canon)
|
||||||
|
if (result.partial) {
|
||||||
|
setError(
|
||||||
|
`Benutzerdefinierte Umrechnung (${fk}): beide Mengen ausfüllen oder Entwurf verwerfen (Tab durch alle Felder).`,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (!result.ok) {
|
if (!result.ok) {
|
||||||
setError(result.message || 'Konvertierungsfaktor ungültig.')
|
setError(result.message || 'Benutzerdefinierte Umrechnung ungültig.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
textForTc = JSON.stringify(tco, null, 2)
|
textForTc = JSON.stringify(tco, null, 2)
|
||||||
setTypeConversionsText(textForTc)
|
setTypeConversionsText(textForTc)
|
||||||
setCustomFactorDraftByField({})
|
setCustomEquivalenceDraftByField({})
|
||||||
} catch {
|
} catch {
|
||||||
setError('type_conversions: ungültiges JSON.')
|
setError('type_conversions: ungültiges JSON.')
|
||||||
return
|
return
|
||||||
|
|
@ -718,11 +828,10 @@ 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). Standard-Umrechnungen wählen; für
|
Ziel-Einheit kommt aus dem Datenmodell. Standard-Umrechnungen im Dropdown; bei "Benutzerdefiniert"
|
||||||
abweichende Skalen (z. B. Volumen→Masse mit variabler Dichte) "Benutzerdefiniert" und den
|
die Bezugsgröße eintragen: <strong>Menge [Quelleinheit] entspricht Menge [Zieleinheit]</strong> (Ziel ist die
|
||||||
Faktor eintragen (CSV-Wert × Faktor → Speicher-Einheit). Bei Registry-Optionen werden{' '}
|
Speicher-Einheit des Feldes). Im JSON siehst du weiterhin <code>conversion_factor</code> und{' '}
|
||||||
<code>target_unit</code> und ein alter <code>conversion_factor</code> entfernt; bei
|
<code>custom_equivalence</code> zur Dokumentation.
|
||||||
Benutzerdefiniert bleibt der Faktor erhalten, bis du ihn leerst.
|
|
||||||
</p>
|
</p>
|
||||||
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
<div style={{ marginTop: 14, display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
{unitTargets.map(({ field: fkey, options }) => (
|
{unitTargets.map(({ field: fkey, options }) => (
|
||||||
|
|
@ -751,30 +860,74 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
{getSourceUnitSelectValue(fkey) === 'custom' && (
|
{getSourceUnitSelectValue(fkey) === 'custom' && (
|
||||||
<div style={{ marginTop: 10 }}>
|
<div style={{ marginTop: 12 }}>
|
||||||
<span className="form-label" style={{ display: 'block', marginBottom: 6 }}>
|
<span className="form-label" style={{ display: 'block', marginBottom: 8 }}>
|
||||||
Konvertierungsfaktor (× CSV-Wert → Wert in Speicher-Einheit)
|
Bezugsgröße (CSV steht in der linken Einheit)
|
||||||
</span>
|
</span>
|
||||||
<input
|
<datalist id={`csv-custom-units-${fkey}`}>
|
||||||
type="text"
|
{CUSTOM_SOURCE_UNIT_HINTS.map((h) => (
|
||||||
inputMode="decimal"
|
<option key={h} value={h} />
|
||||||
className="form-input"
|
))}
|
||||||
placeholder="z. B. 1,03 oder 1.03 (ml→g nach Dichte)"
|
</datalist>
|
||||||
value={getCustomFactorFieldDisplay(fkey)}
|
<div
|
||||||
onChange={(e) =>
|
style={{
|
||||||
setCustomFactorDraftByField((prev) => ({ ...prev, [fkey]: e.target.value }))
|
display: 'flex',
|
||||||
}
|
flexWrap: 'wrap',
|
||||||
onBlur={(e) => commitCustomFactorOnBlur(fkey, e.target.value)}
|
alignItems: 'center',
|
||||||
style={{ width: '100%', textAlign: 'left' }}
|
gap: 10,
|
||||||
/>
|
rowGap: 12,
|
||||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 6 }}>
|
}}
|
||||||
Dezimalkomma oder -punkt; mit Tab oder Klick außerhalb übernehmen (oder direkt Speichern).
|
>
|
||||||
</p>
|
<input
|
||||||
{getCustomFactorFieldDisplay(fkey) === '' && (
|
type="text"
|
||||||
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 6 }}>
|
inputMode="decimal"
|
||||||
Ohne Faktor gilt keine zusätzliche Skalierung (wie 1:1 nach dem Parsen).
|
className="form-input"
|
||||||
|
placeholder="Menge"
|
||||||
|
aria-label="Menge in Quelleinheit"
|
||||||
|
value={getEquivalenceDisplay(fkey).srcAmt}
|
||||||
|
onChange={(e) => mergeEquivalenceDraft(fkey, { srcAmt: e.target.value })}
|
||||||
|
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
|
||||||
|
style={{ width: 96, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Quelleinheit"
|
||||||
|
list={`csv-custom-units-${fkey}`}
|
||||||
|
aria-label="Quelleinheit (z. B. ml)"
|
||||||
|
value={getEquivalenceDisplay(fkey).srcUnit}
|
||||||
|
onChange={(e) => mergeEquivalenceDraft(fkey, { srcUnit: e.target.value })}
|
||||||
|
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
|
||||||
|
style={{ width: 120, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 14, color: 'var(--text2)' }}>entspricht</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode="decimal"
|
||||||
|
className="form-input"
|
||||||
|
placeholder="Menge"
|
||||||
|
aria-label="Menge in Speicher-Einheit"
|
||||||
|
value={getEquivalenceDisplay(fkey).tgtAmt}
|
||||||
|
onChange={(e) => mergeEquivalenceDraft(fkey, { tgtAmt: e.target.value })}
|
||||||
|
onBlur={() => commitCustomEquivalenceOnBlur(fkey)}
|
||||||
|
style={{ width: 96, textAlign: 'left', minHeight: 46, padding: '11px 12px' }}
|
||||||
|
/>
|
||||||
|
<span style={{ fontSize: 15, fontWeight: 600, color: 'var(--text1)' }}>
|
||||||
|
{getCanonicalStorageUnitLabel(fkey)}
|
||||||
|
</span>
|
||||||
|
<span style={{ fontSize: 13, color: 'var(--text3)' }}>(Ziel / Speicher)</span>
|
||||||
|
</div>
|
||||||
|
{derivedFactorHintLine(fkey) ? (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)', marginTop: 10 }}>
|
||||||
|
{derivedFactorHintLine(fkey)}
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text3)', marginTop: 8, lineHeight: 1.5 }}>
|
||||||
|
Beispiel: <strong>1</strong> <strong>ml</strong> entspricht <strong>1,03</strong>{' '}
|
||||||
|
<strong>{getCanonicalStorageUnitLabel(fkey)}</strong>, wenn die CSV Milliliter liefert und die
|
||||||
|
Dichte etwa 1,03 g/ml ist. Alle drei Felder leer lassen = keine Zusatz-Umrechnung. Tab /
|
||||||
|
Fokus weg oder Speichern übernimmt.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -786,11 +939,9 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
<div className="card" style={{ padding: 16, marginBottom: 16 }}>
|
||||||
<div className="form-label">4. type_conversions (JSON)</div>
|
<div className="form-label">4. type_conversions (JSON)</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 }}>
|
||||||
Vom Vorschlag übernommen; bei Dropdowns 3b wird <code>source_unit</code> ergänzt. Zusätzlich manuell
|
Vom Vorschlag übernommen; bei Dropdowns 3b werden <code>source_unit</code>, ggf.{' '}
|
||||||
z. B. Datumsformat. <strong>Freie Umrechnung (nicht in der Liste 3b):</strong>{' '}
|
<code>conversion_factor</code> und <code>custom_equivalence</code> gesetzt. Zusätzlich manuell z. B.
|
||||||
<code>conversion_factor</code> als Multiplikator nach dem Parsen (und nach Registry-
|
Datumsformat.
|
||||||
<code>source_unit</code>). Optional im JSON <code>source_unit: 'custom'</code>, wenn nur{' '}
|
|
||||||
<code>conversion_factor</code> gelten soll.
|
|
||||||
</p>
|
</p>
|
||||||
<textarea
|
<textarea
|
||||||
className="form-input"
|
className="form-input"
|
||||||
|
|
@ -805,7 +956,7 @@ export default function AdminCsvTemplateEditorPage() {
|
||||||
value={typeConversionsText}
|
value={typeConversionsText}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setTypeConversionsText(e.target.value)
|
setTypeConversionsText(e.target.value)
|
||||||
setCustomFactorDraftByField({})
|
setCustomEquivalenceDraftByField({})
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user