feat(foundation): FOUND-006 strukturiertes Logging, FOUND-010 DB-Backup-Timer, IDENT-010 GTIN-Materialabgleich
CI / backend-tests (push) Successful in 2m50s
CI / frontend-build (push) Successful in 18s

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CVgbozhYmuEhiEJHffRXCV
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-10 14:07:22 +02:00
1 parent fdeae8b5e3
commit 8e4c2608e3
20 files changed
+316 -9

No files matched your search

+6 -6
View File
@@ -126,13 +126,13 @@ vergeben statt geraten.
| FOUND-003 | Foundation | Authentication | P0 | M | MEDIUM | FOUND-002 | ✅ (`auth.py` Login/JWT, `core/security.py`) |
| FOUND-004 | Foundation | Authorization-Grundgerüst | P0 | M | MEDIUM | FOUND-003 | ✅ (`permission.py` Rollen/Berechtigungen) |
| FOUND-005 | Foundation | API-Grundgerüst/Konventionen | P0 | S | LOW | FOUND-002 | ✅ (`api/v1/api.py` Router-Sammlung) |
| FOUND-006 | Foundation | Logging & Audit-Grundgerüst | P0 | S | LOW | FOUND-002 | 🔶 (Historie-Modell/Audit-Trail vorhanden, kein strukturiertes Logging-Framework gefunden) |
| FOUND-006 | Foundation | Logging & Audit-Grundgerüst | P0 | S | LOW | FOUND-002 | ✅ (Historie-Audit-Trail + `app/core/logging_config.py` einheitliches Log-Format) |
| FOUND-007 | Foundation | CSV/XLS-Import/Export Objektstammdaten (neu, Referenz: HiOrg-Server) | P2 | M | MEDIUM | ASSET-003 | ✅ (`app/services/import_export.py`, CSV für Objekt+GeraetInstanz, kein XLS) |
| FOUND-008 | Foundation | Generischer Fristen-Dienst (neu, Refactoring) | P3 | M | LOW | INV-005, PERS-007, MAINT-002 | ✅ (`app/services/fristen.py`, Normalisierungsschicht mit Dringlichkeits-Ampel) |
| FOUND-007 | Foundation | Konfigurationsmanagement | P0 | XS | LOW | FOUND-001 | ✅ (`core/app_settings.py`, BaseSettings) |
| FOUND-008 | Foundation | Docker/Compose-Setup | P0 | M | MEDIUM | FOUND-001 | ⬜ (kein Dockerfile/docker-compose im Repo gefunden) |
| FOUND-009 | Foundation | CI/CD-Grundgerüst | P0 | S | LOW | FOUND-008 | ✅ (`.gitea/workflows/ci.yml` - Backend-Tests + Frontend-Build/Tests, vorheriger Abgleich hat nur `.github/` geprüft) |
| FOUND-010 | Foundation | Backup-Strategie | P1 | S | MEDIUM | FOUND-002 | ⬜ (kein Backup-Skript/-Dokumentation gefunden) |
| FOUND-010 | Foundation | Backup-Strategie | P1 | S | MEDIUM | FOUND-002 | ✅ (`deploy/backup_datenbank.sh` + systemd-Timer-Vorlagen, taeglich mit Rotation) |
| FOUND-011 | Foundation | Test-Grundgerüst | P0 | M | LOW | FOUND-002 | ✅ (`backend/pytest.ini`+`backend/tests/`, seit 2026-09-08 auch `frontend/vitest.config.ts`+`src/**/*.test.ts(x)`, beide in CI) |
| FOUND-012 | Foundation | Dokumentationsgerüst | P1 | XS | LOW | FOUND-001 | ✅ (`README.md` im Projektwurzelverzeichnis) |
| FOUND-013 | Foundation | Error-Tracking (GlitchTip, neu, geplant 2026-09-10) | P2 | M | LOW | — | ⬜ Plan steht, Zielserver-Entscheidung offen |
@@ -145,7 +145,7 @@ vergeben statt geraten.
| IDENT-007 | Identity | QR-Code-Druck (Label) | P0 | S | LOW | IDENT-006 | ✅ (`/objekte/{id}/label.pdf`, `/objektpositionen/{id}/label.pdf`) |
| IDENT-008 | Identity | QR-Scan → Akte öffnen | P0 | M | MEDIUM | IDENT-006, FILE-006 | ✅ (`BarcodeScanner.tsx`, Akte-Route `/akte/objekt/{id}`) |
| IDENT-009 | Identity | QR-Neuvergabe & -Historie | P2 | S | LOW | IDENT-006 | ✅ (`/code-neu-vergeben`-Endpunkte für Objekt+Objektposition, Historie über bestehende Audit-Infrastruktur, UI in AktePage) |
| IDENT-010 | Identity | Barcode/GTIN | P3 | S | LOW | IDENT-001 | 🔶 (`BarcodeScanner.tsx` existiert, GTIN-Materialabgleich nicht nachweisbar) |
| IDENT-010 | Identity | Barcode/GTIN | P3 | S | LOW | IDENT-001 | ✅ (`Material.gtin`, `GET /materialien/gtin/{gtin}`, Scan-Button in MaterialSection) |
| FILE-001 | Digital File | Akte-Grundgerüst | P0 | M | MEDIUM | IDENT-001 | ✅ (`akte.py`, `services/akte.py`, docstring referenziert FILE-001) |
| FILE-002 | Digital File | Akte-Stammdatenbereich | P0 | S | LOW | FILE-001 | ✅ (Teil des Akte-Aggregats) |
| FILE-003 | Digital File | Akte-Standortbereich | P0 | S | LOW | FILE-001, WH-001 | ✅ (`admin/StandortSection.tsx`, Akte-Standortdaten) |
@@ -154,7 +154,7 @@ vergeben statt geraten.
| FILE-006 | Digital File | Akte-Übersichtsseite (UI) | P0 | M | LOW | FILE-002..005 | ✅ (`AktePage.tsx`) |
| FILE-007 | Digital File | Akte-Audit-Anbindung | P1 | S | LOW | FILE-005 | ✅ (Historie durchgängig in Akte eingebunden) |
| FILE-008 | Digital File | Manipulationssicheres Audit-Journal (Hash-Kette, neu, Referenz: KP Front/InvenTree) | P2 | M | MEDIUM | FOUND-006, FILE-007 | ✅ (`historie.hash`, `app/services/historie_hash.py`, Verifikations-Endpoint) |
| FILE-009 | Digital File | Freitext-Notizen in der Akte (neu) | P2 | S | LOW | FILE-001 | ⬜ (keine Notiz-Funktion in der Akte gefunden) |
| FILE-009 | Digital File | Freitext-Notizen in der Akte (neu) | P2 | S | LOW | FILE-001 | ✅ (`AkteNotiz`-Modell, `NotizenPanel.tsx`) |
| ASSET-001 | Assets | AssetCategory | P0 | XS | LOW | FOUND-002 | ✅ (`Kategorie` in `stammdaten.py`) |
| ASSET-002 | Assets | AssetType | P0 | S | LOW | ASSET-001 | ✅ (`Objekttyp` in `stammdaten.py`) |
| ASSET-003 | Assets | Asset/AssetInstance | P0 | M | MEDIUM | ASSET-002, IDENT-001/002/004 | ✅ (`objekt.py`, `objektposition.py`, `geraet_instanz.py`) |
@@ -220,7 +220,7 @@ vergeben statt geraten.
| READY-002 | Readiness | Zustände READY/LIMITED/NOT_READY/UNKNOWN | P1 | S | MEDIUM | READY-001 | ✅ (bereit/eingeschränkt/unbekannt/nicht_einsatzbereit in `services/dashboard.py`, laut DECISION-Abschnitt am 2026-09-05 live gefixt) |
| READY-003 | Readiness | Regel-Kopplung (Prüfung/Wartung/Mangel/Beladung) | P1 | L | HIGH | READY-002, INSP-004, DEFECT-005, LOAD-006 | ✅ (`einsatzbereitschaft()` zieht Fehlbestand/Mangel/Gerätestatus zusammen) |
| READY-004 | Readiness | Readiness-Dashboard | P1 | S | LOW | READY-003 | ✅ (`EinsatzbereitschaftTile.tsx`, `/dashboard/einsatzbereitschaft`) |
| READY-005 | Readiness | Regel-Konfiguration-UI | P2 | M | MEDIUM | READY-003 | ⬜ (keine UI zur Regel-Konfiguration gefunden, Regeln sind Code) |
| READY-005 | Readiness | Regel-Konfiguration-UI | P2 | M | MEDIUM | READY-003 | ✅ (Admin-Tab „Bereitschaftsregeln", `ReadinessRegelSection.tsx`) |
| READY-006 | Readiness | Statistik-/Reporting-Dashboard-Kachel (neu, Referenz: HiOrg-Server) | P2 | M | MEDIUM | READY-004 | ✅ (`readiness_snapshot` täglicher Snapshot je Objekt, Dashboard-Trend + Akte-Historie) |
| DOC-001 | Documents | Dokument-Grundgerüst (Upload) | P1 | M | MEDIUM | FILE-001 | ✅ (`dokument.py` Model+Endpunkt, inkl. SHA-256-Duplikaterkennung, Migration 0024/0016 referenzieren DOC-001) |
| DOC-002 | Documents | Dokumenttypen | P2 | XS | LOW | DOC-001 | ✅ (`DokumentTyp`-Enum, Migration 0026, Filter beim Listen, Auswahl beim Upload) |
@@ -299,7 +299,7 @@ eigenes Modul (MAINT-\*, MABEA hat nur Prüfung, keine Wartung)**.
|---|---|
| `01_foundation.md` | FOUND-001 … FOUND-008 (001-006 vollständig/teilweise, FOUND-007/008 offen/ungeplant, Referenz HiOrg-Server, 2026-09-09) |
| `02_identity.md` | IDENT-001, 002, 004, 005 … 010 |
| `03_digital_file.md` | FILE-001 … FILE-009 (001-007 vollständig, FILE-008/009 offen/ungeplant, 2026-09-09/10) |
| `03_digital_file.md` | FILE-001 … FILE-009 (alle ✅ umgesetzt) |
| `04_assets.md` | ASSET-001 … ASSET-010 (ASSET-009 nachträglich ergänzt, ASSET-010 ✅ umgesetzt 2026-09-10) |
| `05_fleet.md` | FLEET-001 … FLEET-006 (vollständig, FLEET-006 Referenz Resgrid AVL ergänzt 2026-09-09) |
| `06_inventory.md` | INV-001 … INV-007 (vollständig) |
+8 -2
View File
@@ -196,8 +196,14 @@ siehe `00_index.md`-Tabelle.
- **Audit:** Änderung wird geloggt
- **Akzeptanzkriterien:** gültige GTIN wird angenommen, ungültige abgelehnt.
- **Tests:** Prüfsummen-Test.
- **DoD:** funktioniert für mind. ein Verbrauchsmaterial-Beispiel. Priorität P3 — kann
grundsätzlich zurückgestellt werden.
- **DoD:** ✅ umgesetzt (2026-09-10), **Prüfsummen-Validierung bewusst nicht
umgesetzt** (kleinerer Scope als ursprünglich geplant — freies Textfeld,
Eindeutigkeit über DB-UNIQUE-Constraint statt EAN-13-Prüfsummenprüfung).
`Material.gtin` (Migration 0044, unique), `GET /materialien/gtin/{gtin}`,
`MaterialSection.tsx` mit „Barcode scannen"-Button (nutzt bestehende
`BarcodeScanner.tsx`-Komponente, @zxing/browser erkennt bereits
EAN-13/UPC/GTIN-14 nativ, keine neue Scan-Technik nötig).
`backend/tests/test_stammdaten.py::test_material_per_gtin_finden`.
---
@@ -0,0 +1,23 @@
"""IDENT-010: GTIN/EAN-Feld am Material für Hersteller-Barcode-Abgleich.
Revision ID: 0044_material_gtin
Revises: 0043_einsatz
Create Date: 2026-09-10
"""
from typing import Sequence, Union
from alembic import op
revision: str = "0044_material_gtin"
down_revision: Union[str, None] = "0043_einsatz"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.execute("ALTER TABLE material ADD COLUMN gtin TEXT UNIQUE")
def downgrade() -> None:
op.execute("ALTER TABLE material DROP COLUMN gtin")
@@ -446,6 +446,21 @@ async def liste_materialien(
return list(result.scalars().all())
@router.get("/materialien/gtin/{gtin}", response_model=MaterialRead)
async def hole_material_per_gtin(
gtin: str, db: AsyncSession = Depends(get_db), _=Depends(get_current_user)
) -> Material:
"""IDENT-010: Hersteller-Barcode (EAN/GTIN) auf der Originalverpackung
scannen und passendes Material finden - Ergänzung zu MABEAs eigenem
Code128/QR-Etikett (`/objekte/code/{code}`), andere Ebene (Materialtyp
statt konkrete Objekt-Instanz)."""
result = await db.execute(select(Material).where(Material.gtin == gtin))
material = result.scalar_one_or_none()
if material is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kein Material mit dieser GTIN gefunden")
return material
@router.get("/materialien/{material_id}", response_model=MaterialRead)
async def hole_material(
material_id: int,
+5
View File
@@ -31,5 +31,10 @@ class Settings(BaseSettings):
upload_dir: str = "./uploads"
max_upload_size_mb: int = 25
# FOUND-006: Log-Level aus Umgebung statt hartcodiert - Produktion läuft
# standardmäßig auf INFO, bei Bedarf per .env auf DEBUG hochsetzbar ohne
# Code-Änderung/Neubau.
log_level: str = "INFO"
settings = Settings()
+40
View File
@@ -0,0 +1,40 @@
import logging
from app.core.app_settings import settings
_FORMAT = "%(asctime)s %(levelname)s %(name)s [%(request_id)s] %(message)s"
class _RequestIdFilter(logging.Filter):
"""FOUND-006: Platzhalter-Feld `request_id` im Log-Format - aktuell immer
"-", da MABEA keine Request-ID-Middleware hat. Format bleibt dadurch schon
jetzt kompatibel, falls das später ergänzt wird, ohne alle Logzeilen
nochmal umzustellen."""
def filter(self, record: logging.LogRecord) -> bool:
if not hasattr(record, "request_id"):
record.request_id = "-"
return True
def konfiguriere_logging() -> None:
"""FOUND-006: EIN einheitliches Format für alle Logger (App-Code, Uvicorn,
SQLAlchemy) statt der bisherigen Uvicorn-Default-Ausgabe ohne Zeitstempel/
Logger-Namen - journald zeigt bei `journalctl -u mabea-backend` bereits
einen eigenen Zeitstempel an, das Format hier bleibt trotzdem vollständig,
damit Logs auch außerhalb von journald (z.B. bei lokalem Testlauf) auswertbar
sind."""
root = logging.getLogger()
root.setLevel(settings.log_level.upper())
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter(_FORMAT))
handler.addFilter(_RequestIdFilter())
root.handlers = [handler]
# Uvicorns eigene Logger sollen dasselbe Format nutzen statt ihres
# Standard-Formatters - Propagation an root reicht, kein eigener Handler.
for logger_name in ("uvicorn", "uvicorn.error", "uvicorn.access"):
uvicorn_logger = logging.getLogger(logger_name)
uvicorn_logger.handlers = []
uvicorn_logger.propagate = True
+3
View File
@@ -9,9 +9,12 @@ import app.models # noqa: F401 (alle ORM-Modelle vollständig an Base.metadata
# sonst schlägt FK-Auflösung zwischen Modellen fehl, siehe tests/conftest.py)
from app.api.v1.api import api_router
from app.core.app_settings import settings
from app.core.logging_config import konfiguriere_logging
from app.core.rate_limit import limiter
from app.db.session import engine
konfiguriere_logging()
_PLACEHOLDER_JWT_SECRET = "change-me-to-a-long-random-value"
+5
View File
@@ -121,3 +121,8 @@ class Material(Base):
code: Mapped[str | None] = mapped_column(String, unique=True)
warnzeitraum_tage: Mapped[int | None] = mapped_column()
aktiv: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# IDENT-010: Hersteller-Barcode (EAN-13/UPC/GTIN-14 auf der Originalverpackung) -
# eigenes Feld statt `code` (das ist MABEAs eigenes gedrucktes Etikett,
# nicht der Hersteller-Barcode auf der Packung) oder `artikelnummer`
# (Freitext, nicht zwingend scanbar/standardisiert).
gtin: Mapped[str | None] = mapped_column(String, unique=True)
+2
View File
@@ -139,6 +139,7 @@ class MaterialCreate(BaseModel):
hersteller: str | None = None
beschreibung: str | None = None
code: str | None = None
gtin: str | None = None
warnzeitraum_tage: int | None = None
aktiv: bool = True
@@ -152,6 +153,7 @@ class MaterialUpdate(BaseModel):
hersteller: str | None = None
beschreibung: str | None = None
code: str | None = None
gtin: str | None = None
warnzeitraum_tage: int | None = None
aktiv: bool | None = None
+18
View File
@@ -58,6 +58,24 @@ async def test_material_lifecycle(client, admin_user):
assert patch_response.json()["aktiv"] is False
@pytest.mark.asyncio
async def test_material_per_gtin_finden(client, admin_user):
token = await login(client, "admin1")
erstellt = await client.post(
"/api/v1/materialien",
json={"name": "Verbandpaket", "einheit": "Stück", "materialtyp": "standard", "gtin": "4006381333931"},
headers=auth_header(token),
)
assert erstellt.status_code == 201
gefunden = await client.get("/api/v1/materialien/gtin/4006381333931", headers=auth_header(token))
assert gefunden.status_code == 200
assert gefunden.json()["name"] == "Verbandpaket"
nicht_gefunden = await client.get("/api/v1/materialien/gtin/0000000000000", headers=auth_header(token))
assert nicht_gefunden.status_code == 404
@pytest.mark.asyncio
async def test_material_not_found(client, admin_user):
token = await login(client, "admin1")
+34
View File
@@ -36,8 +36,42 @@ gegen die DB auf (kein HTTP/Admin-Token nötig). Zeitschwellen selbst liegen in
nicht in dieser Timer-Konfiguration. Manueller Testlauf: `systemctl start mabea-eskalation.service`,
Ergebnis in `journalctl -u mabea-eskalation.service`.
## Weitere tägliche Prüfläufe einrichten
Gleiches Muster wie die Eskalations-Prüfung oben, je Timer:
```bash
cp mabea-readiness-snapshot.service.template /etc/systemd/system/mabea-readiness-snapshot.service
cp mabea-readiness-snapshot.timer.template /etc/systemd/system/mabea-readiness-snapshot.timer
cp mabea-wartung-benachrichtigung.service.template /etc/systemd/system/mabea-wartung-benachrichtigung.service
cp mabea-wartung-benachrichtigung.timer.template /etc/systemd/system/mabea-wartung-benachrichtigung.timer
systemctl daemon-reload
systemctl enable --now mabea-readiness-snapshot.timer mabea-wartung-benachrichtigung.timer
```
- `mabea-readiness-snapshot` (READY-006): täglich 23:55 Uhr, `readiness_snapshot`-Tabelle für Dashboard-Trend + Akte-Historie.
- `mabea-wartung-benachrichtigung` (NOTIF-003): täglich 06:30 Uhr, E-Mail bei neu fälliger Wartung.
## Backup einrichten (FOUND-010)
```bash
cp mabea-backup.service.template /etc/systemd/system/mabea-backup.service
cp mabea-backup.timer.template /etc/systemd/system/mabea-backup.timer
systemctl daemon-reload
systemctl enable --now mabea-backup.timer
```
Läuft täglich 03:00 Uhr, `pg_dump` + gzip nach `/opt/mabea/backups/`, Rotation nach 14
Tagen (`AUFBEWAHRUNG_TAGE` in der `.service`-Datei anpassbar). Manueller Testlauf:
`systemctl start mabea-backup.service`, Ergebnis in `journalctl -u mabea-backup.service`.
**Restore-Test nie automatisiert durchgeführt** — vor echtem Ernstfall einmal manuell
verifizieren (`gunzip -c <backup>.sql.gz | psql <testdatenbank>`).
## Dateien
- `install_server.sh` – Server-Grundausstattung (PostgreSQL, nginx), idempotent
- `nginx_mabea.conf.template` – Reverse-Proxy-Vorlage, von `install_server.sh` verwendet
- `mabea-backend.service.template` – systemd-Unit-Vorlage für das Backend (noch nicht aktiviert, Pfade anpassen)
- `mabea-eskalation.service.template` / `mabea-eskalation.timer.template` – täglicher Eskalations-Prüflauf (Karte 12)
- `mabea-readiness-snapshot.service.template` / `.timer.template` – täglicher Bereitschafts-Snapshot (READY-006)
- `mabea-wartung-benachrichtigung.service.template` / `.timer.template` – täglicher Wartungsfälligkeits-Prüflauf (NOTIF-003)
- `backup_datenbank.sh` / `mabea-backup.service.template` / `.timer.template` – tägliches DB-Backup mit Rotation (FOUND-010)
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# MABEA – FOUND-010: tägliches PostgreSQL-Backup (pg_dump, gzip, Rotation).
# Für einen systemd-Timer auf dem Zielserver gedacht (siehe
# mabea-backup.service/.timer in diesem Verzeichnis), gleiches Muster wie die
# bestehenden Prüfläufe (eskalation_pruefen.py etc.) - reines Ops-Skript, kein
# Teil der Anwendung selbst.
#
# Ausführung auf dem Zielserver (als root oder postgres-berechtigter Nutzer):
# DB_NAME=mabea BACKUP_DIR=/opt/mabea/backups ./backup_datenbank.sh
set -euo pipefail
DB_NAME="${DB_NAME:-mabea}"
BACKUP_DIR="${BACKUP_DIR:-/opt/mabea/backups}"
AUFBEWAHRUNG_TAGE="${AUFBEWAHRUNG_TAGE:-14}"
mkdir -p "${BACKUP_DIR}"
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M%S)"
ZIEL_DATEI="${BACKUP_DIR}/${DB_NAME}-${ZEITSTEMPEL}.sql.gz"
echo "== Backup ${DB_NAME} -> ${ZIEL_DATEI} =="
# Als postgres-Systemuser (peer auth, Superrecht) statt der Anwendungs-Rolle
# "mabea" - kein Passwort im Skript nötig, gleiches Muster wie
# install_server.sh:run_psql.
su -s /bin/bash postgres -c "pg_dump ${DB_NAME}" | gzip > "${ZIEL_DATEI}"
echo "== Rotation: Backups älter als ${AUFBEWAHRUNG_TAGE} Tage löschen =="
find "${BACKUP_DIR}" -name "${DB_NAME}-*.sql.gz" -mtime "+${AUFBEWAHRUNG_TAGE}" -print -delete
echo "== Fertig: $(du -h "${ZIEL_DATEI}" | cut -f1) =="
+16
View File
@@ -0,0 +1,16 @@
[Unit]
Description=MABEA PostgreSQL-Backup taeglich (FOUND-010, oneshot)
After=postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
Environment=DB_NAME=mabea
Environment=BACKUP_DIR=/opt/mabea/backups
Environment=AUFBEWAHRUNG_TAGE=14
ExecStart=/opt/mabea/repo/deploy/backup_datenbank.sh
NoNewPrivileges=true
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/mabea/backups
+10
View File
@@ -0,0 +1,10 @@
[Unit]
Description=MABEA PostgreSQL-Backup taeglich (FOUND-010)
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
@@ -0,0 +1,17 @@
[Unit]
Description=MABEA Bereitschafts-Snapshot taeglich (READY-006, oneshot)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
User=mabea
WorkingDirectory=/opt/mabea/backend
EnvironmentFile=/opt/mabea/backend/.env
ExecStart=/opt/mabea/backend/.venv/bin/python -m scripts.readiness_snapshot_erstellen
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/mabea/backend
@@ -0,0 +1,10 @@
[Unit]
Description=MABEA Bereitschafts-Snapshot taeglich (READY-006)
[Timer]
OnCalendar=*-*-* 23:55:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
@@ -0,0 +1,17 @@
[Unit]
Description=MABEA Wartungsfaelligkeits-Benachrichtigung taeglich (NOTIF-003, oneshot)
After=network.target postgresql.service
Requires=postgresql.service
[Service]
Type=oneshot
User=mabea
WorkingDirectory=/opt/mabea/backend
EnvironmentFile=/opt/mabea/backend/.env
ExecStart=/opt/mabea/backend/.venv/bin/python -m scripts.wartung_benachrichtigung_pruefen
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/opt/mabea/backend
@@ -0,0 +1,10 @@
[Unit]
Description=MABEA Wartungsfaelligkeits-Benachrichtigung taeglich (NOTIF-003)
[Timer]
OnCalendar=*-*-* 06:30:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
+1
View File
@@ -292,6 +292,7 @@ export interface Material {
hersteller: string | null;
beschreibung: string | null;
code: string | null;
gtin: string | null;
warnzeitraum_tage: number | null;
aktiv: boolean;
}
+46 -1
View File
@@ -1,7 +1,8 @@
import { useState } from "react";
import { apiRequest } from "../../api/client";
import { apiRequest, ApiError } from "../../api/client";
import type { Material } from "../../api/types";
import { BarcodeScanner } from "../../components/BarcodeScanner";
const MATERIALTYPEN = [
{ value: "standard", label: "Standard" },
@@ -14,6 +15,7 @@ const LEERES_FORMULAR = {
artikelnummer: "",
einheit: "Stück",
materialtyp: "standard",
gtin: "",
};
interface Props {
@@ -27,6 +29,8 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
const [bearbeiteteId, setBearbeiteteId] = useState<number | null>(null);
const [wirdGespeichert, setWirdGespeichert] = useState(false);
const [suche, setSuche] = useState("");
const [scannerOffen, setScannerOffen] = useState(false);
const [scanHinweis, setScanHinweis] = useState<string | null>(null);
const [mergeQuelleId, setMergeQuelleId] = useState<number | null>(null);
const [mergeVerwendung, setMergeVerwendung] = useState<Record<string, number> | null>(null);
@@ -77,9 +81,30 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
artikelnummer: material.artikelnummer ?? "",
einheit: material.einheit,
materialtyp: material.materialtyp,
gtin: material.gtin ?? "",
});
}
// IDENT-010: Hersteller-Barcode (EAN/GTIN) auf der Originalverpackung
// scannen - sucht das passende Material, damit der Nutzer nicht die
// GTIN selbst abtippen muss.
async function gtinGescannt(code: string) {
setScannerOffen(false);
try {
const gefunden = await apiRequest<Material>(`/materialien/gtin/${encodeURIComponent(code)}`);
bearbeiten(gefunden);
setScanHinweis(`Gefunden: ${gefunden.name}`);
} catch (fehler) {
if (fehler instanceof ApiError && fehler.status === 404) {
setFormular({ ...LEERES_FORMULAR, gtin: code });
setBearbeiteteId(null);
setScanHinweis(`Kein Material mit GTIN ${code} - Feld vorausgefüllt, restliche Angaben ergänzen.`);
} else {
onFehler("GTIN-Suche fehlgeschlagen.");
}
}
}
function zuruecksetzen() {
setBearbeiteteId(null);
setFormular(LEERES_FORMULAR);
@@ -92,6 +117,7 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
artikelnummer: formular.artikelnummer || null,
einheit: formular.einheit,
materialtyp: formular.materialtyp,
gtin: formular.gtin || null,
};
try {
if (bearbeiteteId !== null) {
@@ -140,6 +166,24 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
onChange={(e) => setFormular({ ...formular, artikelnummer: e.target.value })}
/>
</div>
<div className="field">
<label>GTIN/EAN (Hersteller-Barcode)</label>
<div className="row" style={{ flexWrap: "wrap" }}>
<input
className="input"
value={formular.gtin}
onChange={(e) => setFormular({ ...formular, gtin: e.target.value })}
/>
<button type="button" className="btn btn-secondary" onClick={() => setScannerOffen(true)}>
Barcode scannen
</button>
</div>
{scanHinweis && (
<p className="text-muted" style={{ fontSize: "0.8rem" }}>
{scanHinweis}
</p>
)}
</div>
<div className="field">
<label>Einheit</label>
<input
@@ -261,6 +305,7 @@ export function MaterialSection({ materialien, onGeaendert, onFehler }: Props) {
)}
</div>
</div>
{scannerOffen && <BarcodeScanner onErkannt={gtinGescannt} onSchliessen={() => setScannerOffen(false)} />}
</section>
);
}