fix(import): Spalte D ist Ablaufdatum-Hinweis, keine Artikelnummer
CI / backend-tests (push) Successful in 53s
CI / frontend-build (push) Successful in 53s

Nutzer-Fund: "Kompresse 10x10 / 5 / Apr 29" - Wert 47209 in Spalte D ist
Excel-Datum 2029-04-01, kein Artikelnummer. Vollständige Prüfung aller drei
Listen bestätigt: D ist überwiegend ein echtes Ablaufdatum, keine
Artikelnummer-Spalte existiert überhaupt. Material-Abgleich läuft jetzt über
den Namen; ein plausibles Datum in Spalte D klassifiziert das Material als
'ablauf_charge' statt 'standard' (Wert selbst wird nicht gespeichert - gehört
auf objektposition, nicht auf die Vorlage). Bekannte Ausreißer (Platzhalter-
wert 12754, Freitext "Jahr 2023"/"MTK 23") werden per Plausibilitätsfenster
ignoriert statt fehlzuklassifizieren.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L85hmKbvX7Cqkq47KnQhFt
This commit is contained in:
2026-09-04 00:58:25 +02:00
co-authored by Claude Sonnet 5
parent dcc75dd2d0
commit f47cefcc28
2 changed files with 48 additions and 26 deletions
+41 -19
View File
@@ -2,7 +2,7 @@
(Prompt-Sprintplan Abschnitt 5, Detail-Mapping in ergebnisse/testphasen.md
„Excel-Import-Mapping"). Erzeugt je Datei einen Objekttyp + Beladungsvorlage
Version 1 + Vorlagenpositionen; legt Material bei Bedarf an (Abgleich über
Artikelnummer, nicht über Namensgleichheit).
den Namen - die Listen haben keine echte Artikelnummer-Spalte, siehe unten).
Deployment-Regel (Prompt 19): dieses Skript wird hier nur als Code erzeugt.
Ausführung nur auf dem Zielsystem/Test-Deployment gegen eine echte DB, nicht
@@ -39,17 +39,39 @@ from app.models.vorlage import Beladungsvorlage, Vorlagenposition, VorlageStatus
NS = {"a": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
SPALTE_ARTIKEL = "B"
SPALTE_MENGE = "C"
SPALTE_ARTIKELNUMMER = "D"
SPALTE_ABLAUFHINWEIS = "D"
SPALTE_SERIENNUMMER = "E"
STANDARD_EINHEIT = "Stück"
# Excel-Datumsserial (Tage seit 1899-12-30) - Spalte D ist als Datum formatiert
# und enthält bei den meisten Zeilen ein echtes Ablaufdatum (z. B. 47209 ->
# 2029-04-01, geprüft anhand der Reihe "Kompresse 10x10 / 5 / Apr 29" aus der
# Quelldatei). Ursprüngliche Annahme "D=Artikelnummer" war falsch - es gibt in
# den drei Listen gar keine echte Artikelnummer-Spalte. Plausibilitätsfenster
# grenzt Platzhalter-/Fehlwerte aus (z. B. 12754 -> 1934-12-01, mehrfach mit
# identischem Wert bei völlig unterschiedlichen Artikeln - kein echtes Datum).
_EXCEL_EPOCH_TAGE_MIN = 42000 # ca. 2015-01-01
_EXCEL_EPOCH_TAGE_MAX = 51000 # ca. 2039-08-01
def _ist_plausibles_ablaufdatum(roh_wert: str | None) -> bool:
if roh_wert is None:
return False
try:
tage = int(roh_wert)
except ValueError:
# Freitext wie "Jahr 2023" oder "MTK 23" kommt in den Quelldaten vor -
# kein auswertbares Datum, aber auch kein Grund den Import abzubrechen.
return False
return _EXCEL_EPOCH_TAGE_MIN <= tage <= _EXCEL_EPOCH_TAGE_MAX
@dataclass
class ImportZeile:
fach: str | None
artikel: str
menge: float
artikelnummer: str | None
hat_ablaufhinweis: bool
hat_seriennummer: bool
@@ -75,11 +97,12 @@ def _lade_shared_strings(archiv: zipfile.ZipFile) -> list[str]:
def lies_xlsx(pfad: Path) -> ImportDatei:
"""Liest Fach/Artikel/Menge/Artikelnummer/SN-Hinweis aus dem ersten Arbeitsblatt.
"""Liest Fach/Artikel/Menge/Ablaufhinweis/SN-Hinweis aus dem ersten Arbeitsblatt.
Layout laut Mapping-Tabelle (ergebnisse/testphasen.md): B=Artikel, C=Menge,
D=Artikelnummer, E=Seriennummer (optional). Zeilen mit gesetztem B aber
ohne gesetztes C sind Fach-Überschriften, kein Artikel.
D=Ablaufdatum (Excel-Datumsserial, oft plausibel, gelegentlich Platzhalter/
Freitext), E=Seriennummer (optional). Zeilen mit gesetztem B aber ohne
gesetztes C sind Fach-Überschriften, kein Artikel.
"""
with zipfile.ZipFile(pfad) as archiv:
strings = _lade_shared_strings(archiv)
@@ -123,7 +146,7 @@ def lies_xlsx(pfad: Path) -> ImportDatei:
fach=aktuelles_fach,
artikel=artikel,
menge=menge_zahl,
artikelnummer=werte.get(SPALTE_ARTIKELNUMMER),
hat_ablaufhinweis=_ist_plausibles_ablaufdatum(werte.get(SPALTE_ABLAUFHINWEIS)),
hat_seriennummer=werte.get(SPALTE_SERIENNUMMER) is not None,
)
)
@@ -151,24 +174,23 @@ async def _hole_oder_erstelle_kategorie(db: AsyncSession, bereich: Bereich, name
async def _hole_oder_erstelle_material(db: AsyncSession, zeile: ImportZeile) -> Material:
"""Abgleich primär über Artikelnummer (Prompt-Vorgabe: Namensgleichheit ist
Tippfehler-anfällig). Ohne Artikelnummer bleibt Name der einzige Schlüssel
r diesen Importlauf - Review-Pflicht laut Sprintplan.
"""Abgleich über den Namen - die drei Quelllisten haben keine echte
Artikelnummer-Spalte (Review-Fund, siehe Modul-Docstring). Tippfehler-Risiko
bleibt daher wie im Sprintplan vermerkt eine manuelle Review-Pflicht.
"""
material = None
if zeile.artikelnummer:
material = (
await db.execute(select(Material).where(Material.artikelnummer == zeile.artikelnummer))
).scalar_one_or_none()
if material is None:
material = (await db.execute(select(Material).where(Material.name == zeile.artikel))).scalar_one_or_none()
material = (await db.execute(select(Material).where(Material.name == zeile.artikel))).scalar_one_or_none()
if material is not None:
return material
materialtyp = MaterialTyp.geraet_sn if zeile.hat_seriennummer else MaterialTyp.standard
if zeile.hat_seriennummer:
materialtyp = MaterialTyp.geraet_sn
elif zeile.hat_ablaufhinweis:
materialtyp = MaterialTyp.ablauf_charge
else:
materialtyp = MaterialTyp.standard
material = Material(
name=zeile.artikel,
artikelnummer=zeile.artikelnummer,
einheit=STANDARD_EINHEIT,
materialtyp=materialtyp,
)