feat(customers): add initial admin bootstrap and csv import
This commit is contained in:
parent
c816e9869d
commit
ecab0fe6b6
27 changed files with 1197 additions and 34 deletions
14
.env.example
Normal file
14
.env.example
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
DATABASE_URL=postgresql+psycopg://olympus:change-me@postgres:5432/olympus
|
||||||
|
SECRET_KEY=change-me
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
JWT_ISSUER=hermes
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
AUTH_COOKIE_SECURE=false
|
||||||
|
ATHENA_PUBLIC_ORIGIN=http://localhost:3001
|
||||||
|
|
||||||
|
# Optional fuer neue Installationen ohne aktive Benutzer.
|
||||||
|
INITIAL_ADMIN_USERNAME=
|
||||||
|
INITIAL_ADMIN_EMAIL=
|
||||||
|
INITIAL_ADMIN_PASSWORD=
|
||||||
|
INITIAL_ADMIN_FIRST_NAME=
|
||||||
|
INITIAL_ADMIN_LAST_NAME=
|
||||||
|
|
@ -93,6 +93,12 @@ Hermes setzt kein Cookie.
|
||||||
2. Athena loescht das HttpOnly-Cookie.
|
2. Athena loescht das HttpOnly-Cookie.
|
||||||
3. Der Benutzer wird zur Login-Seite gefuehrt.
|
3. Der Benutzer wird zur Login-Seite gefuehrt.
|
||||||
|
|
||||||
|
### Aktueller Benutzer
|
||||||
|
|
||||||
|
Athena stellt `GET /api/me` bereit und ruft dafuer serverseitig Hermes `GET /auth/me` auf. Der Header laedt den aktuellen Benutzer ausschliesslich ueber diese BFF-Route und zeigt bevorzugt Vorname plus Nachname, danach den Benutzernamen und vor dem Laden `Benutzer`.
|
||||||
|
|
||||||
|
Der Browser erhaelt dabei kein Token und ruft Hermes nicht direkt auf.
|
||||||
|
|
||||||
### HttpOnly Cookie
|
### HttpOnly Cookie
|
||||||
|
|
||||||
Das Auth-Cookie wird von Athena gesetzt.
|
Das Auth-Cookie wird von Athena gesetzt.
|
||||||
|
|
@ -238,6 +244,56 @@ Kunden:
|
||||||
- `customers.read`
|
- `customers.read`
|
||||||
- `customers.create`
|
- `customers.create`
|
||||||
- `customers.update`
|
- `customers.update`
|
||||||
|
|
||||||
|
### Initial Admin Bootstrap
|
||||||
|
|
||||||
|
Hermes prueft beim Startup nach dem RBAC-Seed, ob mindestens ein aktiver Benutzer existiert. Existiert ein aktiver Benutzer, wird kein Benutzer automatisch erstellt.
|
||||||
|
|
||||||
|
Existiert kein aktiver Benutzer, kann Hermes ueber optionale Umgebungsvariablen einen initialen Administrator anlegen:
|
||||||
|
|
||||||
|
- `INITIAL_ADMIN_USERNAME`
|
||||||
|
- `INITIAL_ADMIN_EMAIL`
|
||||||
|
- `INITIAL_ADMIN_PASSWORD`
|
||||||
|
- `INITIAL_ADMIN_FIRST_NAME`
|
||||||
|
- `INITIAL_ADMIN_LAST_NAME`
|
||||||
|
|
||||||
|
Benutzername, E-Mail und Passwort sind fuer die automatische Anlage erforderlich. Fehlen Werte oder ist die E-Mail ungueltig, startet Hermes weiter und schreibt nur eine Warnung ohne Secrets. Das Passwort wird mit der bestehenden `hash_password`-Funktion gehasht. Der Benutzer erhaelt `role_id` der Systemrolle `administrator` und das Legacy-Feld `role=administrator`.
|
||||||
|
|
||||||
|
### Kundenimport
|
||||||
|
|
||||||
|
Hermes stellt fuer CSV-Importe zwei Endpunkte bereit:
|
||||||
|
|
||||||
|
- `POST /customers/import/preview`
|
||||||
|
- `POST /customers/import/commit`
|
||||||
|
|
||||||
|
Athena stellt die Browser-BFF-Routen bereit:
|
||||||
|
|
||||||
|
- `POST /api/customers/import/preview`
|
||||||
|
- `POST /api/customers/import/commit`
|
||||||
|
- `GET /api/customers/import/template`
|
||||||
|
|
||||||
|
Der Browser sendet Multipart-FormData nur an Athena. Athena leitet die Datei serverseitig mit Bearer Token aus dem HttpOnly-Cookie an Hermes weiter. Hermes begrenzt CSV-Dateien auf 5 MB, bevorzugt UTF-8, erkennt Semikolon und Komma und ignoriert leere Zeilen.
|
||||||
|
|
||||||
|
Importmodi:
|
||||||
|
|
||||||
|
- `create_only`: neue Kunden erstellen, bestehende Kundennummern ueberspringen
|
||||||
|
- `update_existing`: bestehende Kunden anhand der Kundennummer aktualisieren
|
||||||
|
- `upsert`: bestehende Kunden aktualisieren und neue Kunden erstellen
|
||||||
|
|
||||||
|
RBAC:
|
||||||
|
|
||||||
|
- Preview benoetigt `customers.read`
|
||||||
|
- Commit mit `create_only` benoetigt `customers.create`
|
||||||
|
- Commit mit `update_existing` benoetigt `customers.update`
|
||||||
|
- Commit mit `upsert` benoetigt `customers.create` und `customers.update`
|
||||||
|
|
||||||
|
CSV-Spalten:
|
||||||
|
|
||||||
|
```text
|
||||||
|
customer_number;company_name;legal_name;customer_type;status;industry;website;email;phone;tax_number;vat_id;notes;address_type;street;postal_code;city;state;country;address_is_primary;contact_first_name;contact_last_name;contact_position;contact_email;contact_phone;contact_mobile;contact_is_primary;contact_notes
|
||||||
|
```
|
||||||
|
|
||||||
|
Pflichtfeld ist `company_name`. Wenn `customer_number`, `customer_type` oder `status` fehlen, erzeugt der Import fuer neue Kunden eine Kundennummer bzw. nutzt produktive Defaults und weist in der Preview darauf hin. Vollstaendige CSV-Inhalte werden nicht im Audit Log gespeichert.
|
||||||
- `customers.delete`
|
- `customers.delete`
|
||||||
|
|
||||||
Projekte:
|
Projekte:
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ uv run alembic upgrade head
|
||||||
- Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden.
|
- Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden.
|
||||||
- Keine Browser-Dialoge wie `alert()` oder `confirm()`.
|
- Keine Browser-Dialoge wie `alert()` oder `confirm()`.
|
||||||
- Keine Tokens in Browser-JavaScript speichern.
|
- Keine Tokens in Browser-JavaScript speichern.
|
||||||
|
- Datei-Uploads vom Browser laufen ueber Athena-BFF-Routen und werden serverseitig an Hermes weitergeleitet.
|
||||||
|
|
||||||
### Backend
|
### Backend
|
||||||
|
|
||||||
|
|
@ -118,6 +119,7 @@ uv run alembic upgrade head
|
||||||
- Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen.
|
- Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen.
|
||||||
- Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant.
|
- Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant.
|
||||||
- Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren.
|
- Sensible Felder vor Persistenz in Logs oder Audit-Daten maskieren.
|
||||||
|
- Import- und Bootstrap-Flows duerfen keine Passwoerter, Tokens, Secrets oder vollstaendige CSV-Inhalte loggen.
|
||||||
|
|
||||||
### Allgemein
|
### Allgemein
|
||||||
|
|
||||||
|
|
@ -143,6 +145,7 @@ Diese Regeln sind verbindlich:
|
||||||
- Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung.
|
- Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung.
|
||||||
- Audit Logs werden serverseitig in Hermes geschrieben.
|
- Audit Logs werden serverseitig in Hermes geschrieben.
|
||||||
- Neue Plattform-Endpunkte sollen das einheitliche API-Response-Format nutzen.
|
- Neue Plattform-Endpunkte sollen das einheitliche API-Response-Format nutzen.
|
||||||
|
- Header- und Benutzerkontext wird ueber `GET /api/me` geladen; hart codierte Benutzernamen sind unzulaessig.
|
||||||
|
|
||||||
Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden.
|
Wenn eine Aufgabe diese Regeln zu verletzen scheint, muss zuerst die Architekturentscheidung geklaert werden.
|
||||||
|
|
||||||
|
|
@ -196,6 +199,31 @@ Ein neues CRM-Modul soll sich am Kundenmodul orientieren:
|
||||||
|
|
||||||
Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten.
|
Dashboard-Widgets fuer noch nicht implementierte Module muessen Empty States anzeigen statt hart codierter Beispieldaten.
|
||||||
|
|
||||||
|
### Kundenimport
|
||||||
|
|
||||||
|
Kundenimporte muessen die bestehende Kundenarchitektur nutzen und ueber Athena-BFF-Routen laufen. Der Browser darf keine Hermes-URL kennen.
|
||||||
|
|
||||||
|
CSV-Vorgaben:
|
||||||
|
|
||||||
|
- UTF-8 bevorzugen
|
||||||
|
- Semikolon und Komma unterstuetzen
|
||||||
|
- leere Zeilen ignorieren
|
||||||
|
- Fehler und Warnungen pro Zeile zurueckgeben
|
||||||
|
- Preview darf keine Datenbank-Aenderungen ausloesen
|
||||||
|
- Commit schreibt Audit Logs ohne CSV-Rohinhalt
|
||||||
|
|
||||||
|
Importmodi:
|
||||||
|
|
||||||
|
- `create_only`
|
||||||
|
- `update_existing`
|
||||||
|
- `upsert`
|
||||||
|
|
||||||
|
Die Excel-freundliche Vorlage liegt unter `GET /api/customers/import/template`.
|
||||||
|
|
||||||
|
### Deployment
|
||||||
|
|
||||||
|
Neue Installationen koennen optional ueber `INITIAL_ADMIN_*` einen initialen Administrator anlegen. Diese Variablen werden nur von Hermes gelesen und duerfen nicht im Frontend oder in Logs erscheinen.
|
||||||
|
|
||||||
### Neue Permissions
|
### Neue Permissions
|
||||||
|
|
||||||
Neue Module muessen eigene stabile Permission-Strings erhalten.
|
Neue Module muessen eigene stabile Permission-Strings erhalten.
|
||||||
|
|
|
||||||
10
ROADMAP.md
10
ROADMAP.md
|
|
@ -34,6 +34,16 @@ Die Roadmap beschreibt die geplante fachliche Entwicklung von Olympus CRM. Archi
|
||||||
- Globale Toasts fuer CRUD-Erfolg und Fehler
|
- Globale Toasts fuer CRUD-Erfolg und Fehler
|
||||||
- Docker-Konfiguration fuer Logging-Level
|
- Docker-Konfiguration fuer Logging-Level
|
||||||
|
|
||||||
|
## v0.5.1 - Bootstrap und Kundenimport
|
||||||
|
|
||||||
|
- Initial-Admin-Bootstrap fuer neue Installationen ohne aktive Benutzer
|
||||||
|
- Optionale `INITIAL_ADMIN_*` Deployment-Variablen
|
||||||
|
- Dynamische Header-Benutzeranzeige ueber `GET /api/me`
|
||||||
|
- CSV-Kundenimport mit Preview und Commit
|
||||||
|
- Importmodi `create_only`, `update_existing` und `upsert`
|
||||||
|
- Excel-freundliche CSV-Vorlage ueber Athena
|
||||||
|
- Audit Log fuer Kundenimporte ohne CSV-Rohdaten
|
||||||
|
|
||||||
## v0.6.0 - Projektmodul, geplant
|
## v0.6.0 - Projektmodul, geplant
|
||||||
|
|
||||||
- Projektstammdaten
|
- Projektstammdaten
|
||||||
|
|
|
||||||
|
|
@ -4,3 +4,12 @@ SECRET_KEY=CHANGE_ME
|
||||||
|
|
||||||
APP_NAME=Hermes API
|
APP_NAME=Hermes API
|
||||||
APP_VERSION=0.1.0
|
APP_VERSION=0.1.0
|
||||||
|
ACCESS_TOKEN_EXPIRE_MINUTES=60
|
||||||
|
JWT_ISSUER=hermes
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
INITIAL_ADMIN_USERNAME=
|
||||||
|
INITIAL_ADMIN_EMAIL=
|
||||||
|
INITIAL_ADMIN_PASSWORD=
|
||||||
|
INITIAL_ADMIN_FIRST_NAME=
|
||||||
|
INITIAL_ADMIN_LAST_NAME=
|
||||||
|
|
|
||||||
|
|
@ -81,6 +81,8 @@ def login(
|
||||||
def me(current_user: User = Depends(get_current_active_user)):
|
def me(current_user: User = Depends(get_current_active_user)):
|
||||||
return {
|
return {
|
||||||
"id": current_user.id,
|
"id": current_user.id,
|
||||||
|
"first_name": current_user.first_name,
|
||||||
|
"last_name": current_user.last_name,
|
||||||
"username": current_user.username,
|
"username": current_user.username,
|
||||||
"email": current_user.email,
|
"email": current_user.email,
|
||||||
"role": current_user.primary_role.name,
|
"role": current_user.primary_role.name,
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Response, status
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
|
|
||||||
from app.core.rbac import require_permission
|
from app.core.rbac import get_current_active_user, get_user_permission_names, require_permission
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.models.customer import Customer, CustomerContact
|
from app.models.customer import Customer, CustomerContact
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
|
@ -17,7 +17,13 @@ from app.schemas.customer import (
|
||||||
CustomerResponse,
|
CustomerResponse,
|
||||||
CustomerUpdate,
|
CustomerUpdate,
|
||||||
)
|
)
|
||||||
|
from app.schemas.customer_import import (
|
||||||
|
CustomerImportCommitResponse,
|
||||||
|
CustomerImportPreviewResponse,
|
||||||
|
ImportMode,
|
||||||
|
)
|
||||||
from app.services.audit_service import sanitize, write_audit_log
|
from app.services.audit_service import sanitize, write_audit_log
|
||||||
|
from app.services.customer_import_service import CustomerImportService
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -26,6 +32,23 @@ router = APIRouter(
|
||||||
tags=["Customers"],
|
tags=["Customers"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
MAX_IMPORT_SIZE_BYTES = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
|
||||||
|
async def read_import_file(file: UploadFile) -> bytes:
|
||||||
|
content = await file.read(MAX_IMPORT_SIZE_BYTES + 1)
|
||||||
|
if len(content) > MAX_IMPORT_SIZE_BYTES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE,
|
||||||
|
detail="CSV-Datei darf maximal 5 MB groß sein",
|
||||||
|
)
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail="CSV-Datei ist leer",
|
||||||
|
)
|
||||||
|
return content
|
||||||
|
|
||||||
|
|
||||||
def get_customer_or_404(db: Session, customer_id: int) -> Customer:
|
def get_customer_or_404(db: Session, customer_id: int) -> Customer:
|
||||||
customer = CustomerRepository.get_by_id(db, customer_id)
|
customer = CustomerRepository.get_by_id(db, customer_id)
|
||||||
|
|
@ -116,6 +139,44 @@ def create_customer(
|
||||||
return created_customer
|
return created_customer
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/preview", response_model=CustomerImportPreviewResponse)
|
||||||
|
async def preview_customer_import(
|
||||||
|
mode: ImportMode = Form(...),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(require_permission("customers.read")),
|
||||||
|
):
|
||||||
|
logger.info("customers.import.preview", extra={"actor_user_id": current_user.id, "mode": mode})
|
||||||
|
content = await read_import_file(file)
|
||||||
|
try:
|
||||||
|
return CustomerImportService.preview(db, content, mode)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/commit", response_model=CustomerImportCommitResponse)
|
||||||
|
async def commit_customer_import(
|
||||||
|
request: Request,
|
||||||
|
mode: ImportMode = Form(...),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_active_user),
|
||||||
|
):
|
||||||
|
permissions = get_user_permission_names(current_user)
|
||||||
|
required_permissions = {"customers.create"} if mode == "create_only" else {"customers.update"}
|
||||||
|
if mode == "upsert":
|
||||||
|
required_permissions = {"customers.create", "customers.update"}
|
||||||
|
if not required_permissions.issubset(permissions):
|
||||||
|
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Keine Berechtigung")
|
||||||
|
|
||||||
|
logger.info("customers.import.commit", extra={"actor_user_id": current_user.id, "mode": mode})
|
||||||
|
content = await read_import_file(file)
|
||||||
|
try:
|
||||||
|
return CustomerImportService.commit(db, content, mode, actor=current_user, request=request)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{customer_id}", response_model=CustomerResponse)
|
@router.put("/{customer_id}", response_model=CustomerResponse)
|
||||||
def update_customer(
|
def update_customer(
|
||||||
customer_id: int,
|
customer_id: int,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ class Settings(BaseSettings):
|
||||||
access_token_expire_minutes: int = 60
|
access_token_expire_minutes: int = 60
|
||||||
jwt_issuer: str = "hermes"
|
jwt_issuer: str = "hermes"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
initial_admin_username: str | None = None
|
||||||
|
initial_admin_email: str | None = None
|
||||||
|
initial_admin_password: str | None = None
|
||||||
|
initial_admin_first_name: str = ""
|
||||||
|
initial_admin_last_name: str = ""
|
||||||
|
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from app.db.database import SessionLocal
|
||||||
from app.db.health import check_database
|
from app.db.health import check_database
|
||||||
from app.core.logging import configure_logging
|
from app.core.logging import configure_logging
|
||||||
from app.rbac.seed import seed_rbac
|
from app.rbac.seed import seed_rbac
|
||||||
|
from app.services.initial_admin_bootstrap import bootstrap_initial_admin
|
||||||
|
|
||||||
configure_logging()
|
configure_logging()
|
||||||
|
|
||||||
|
|
@ -73,6 +74,7 @@ def startup_seed_rbac():
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
seed_rbac(db)
|
seed_rbac(db)
|
||||||
|
bootstrap_initial_admin(db)
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
|
||||||
49
backend/hermes/app/schemas/customer_import.py
Normal file
49
backend/hermes/app/schemas/customer_import.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
ImportMode = Literal["create_only", "update_existing", "upsert"]
|
||||||
|
ImportAction = Literal["create", "update", "skip", "error"]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerImportIssue(BaseModel):
|
||||||
|
row: int
|
||||||
|
field: str = ""
|
||||||
|
message: str
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerImportPreviewRow(BaseModel):
|
||||||
|
row: int
|
||||||
|
customer_number: str = ""
|
||||||
|
company_name: str = ""
|
||||||
|
action: ImportAction
|
||||||
|
errors: list[CustomerImportIssue] = Field(default_factory=list)
|
||||||
|
warnings: list[CustomerImportIssue] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerImportSummary(BaseModel):
|
||||||
|
total_rows: int
|
||||||
|
valid_rows: int
|
||||||
|
error_count: int
|
||||||
|
warning_count: int
|
||||||
|
duplicate_count: int
|
||||||
|
create_count: int
|
||||||
|
update_count: int
|
||||||
|
skip_count: int
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerImportPreviewResponse(BaseModel):
|
||||||
|
mode: ImportMode
|
||||||
|
summary: CustomerImportSummary
|
||||||
|
rows: list[CustomerImportPreviewRow]
|
||||||
|
errors: list[CustomerImportIssue]
|
||||||
|
warnings: list[CustomerImportIssue]
|
||||||
|
duplicates: list[CustomerImportIssue]
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerImportCommitResponse(BaseModel):
|
||||||
|
mode: ImportMode
|
||||||
|
summary: CustomerImportSummary
|
||||||
|
created: int
|
||||||
|
updated: int
|
||||||
|
skipped: int
|
||||||
|
|
@ -68,6 +68,8 @@ class RoleResponse(BaseModel):
|
||||||
|
|
||||||
class CurrentUserResponse(BaseModel):
|
class CurrentUserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
|
first_name: str
|
||||||
|
last_name: str
|
||||||
username: str
|
username: str
|
||||||
email: str
|
email: str
|
||||||
role: str
|
role: str
|
||||||
|
|
|
||||||
|
|
@ -126,9 +126,11 @@ def action_title(action: str) -> str:
|
||||||
"customers.create": "Kunde erstellt",
|
"customers.create": "Kunde erstellt",
|
||||||
"customers.update": "Kunde bearbeitet",
|
"customers.update": "Kunde bearbeitet",
|
||||||
"customers.delete": "Kunde gelöscht",
|
"customers.delete": "Kunde gelöscht",
|
||||||
|
"customers.import": "Kunden importiert",
|
||||||
"customer_contacts.create": "Ansprechpartner erstellt",
|
"customer_contacts.create": "Ansprechpartner erstellt",
|
||||||
"customer_contacts.update": "Ansprechpartner bearbeitet",
|
"customer_contacts.update": "Ansprechpartner bearbeitet",
|
||||||
"customer_contacts.delete": "Ansprechpartner gelöscht",
|
"customer_contacts.delete": "Ansprechpartner gelöscht",
|
||||||
|
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
|
||||||
}
|
}
|
||||||
return labels.get(action, action)
|
return labels.get(action, action)
|
||||||
|
|
||||||
|
|
|
||||||
407
backend/hermes/app/services/customer_import_service.py
Normal file
407
backend/hermes/app/services/customer_import_service.py
Normal file
|
|
@ -0,0 +1,407 @@
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from pydantic import EmailStr, HttpUrl, TypeAdapter, ValidationError
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.customer import Customer, CustomerAddress, CustomerContact
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.customer_repository import CustomerRepository
|
||||||
|
from app.schemas.customer_import import (
|
||||||
|
CustomerImportCommitResponse,
|
||||||
|
CustomerImportIssue,
|
||||||
|
CustomerImportPreviewResponse,
|
||||||
|
CustomerImportPreviewRow,
|
||||||
|
CustomerImportSummary,
|
||||||
|
ImportMode,
|
||||||
|
)
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
|
||||||
|
CSV_COLUMNS = [
|
||||||
|
"customer_number",
|
||||||
|
"company_name",
|
||||||
|
"legal_name",
|
||||||
|
"customer_type",
|
||||||
|
"status",
|
||||||
|
"industry",
|
||||||
|
"website",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"tax_number",
|
||||||
|
"vat_id",
|
||||||
|
"notes",
|
||||||
|
"address_type",
|
||||||
|
"street",
|
||||||
|
"postal_code",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"country",
|
||||||
|
"address_is_primary",
|
||||||
|
"contact_first_name",
|
||||||
|
"contact_last_name",
|
||||||
|
"contact_position",
|
||||||
|
"contact_email",
|
||||||
|
"contact_phone",
|
||||||
|
"contact_mobile",
|
||||||
|
"contact_is_primary",
|
||||||
|
"contact_notes",
|
||||||
|
]
|
||||||
|
|
||||||
|
VALID_STATUSES = {"lead", "active", "inactive", "blocked", "archived"}
|
||||||
|
VALID_TYPES = {"company", "private", "public_sector", "partner", "supplier"}
|
||||||
|
VALID_ADDRESS_TYPES = {"billing", "shipping", "primary", "other"}
|
||||||
|
TRUE_VALUES = {"1", "true", "yes", "ja", "j", "x"}
|
||||||
|
FALSE_VALUES = {"0", "false", "no", "nein", "n", ""}
|
||||||
|
|
||||||
|
email_adapter = TypeAdapter(EmailStr)
|
||||||
|
url_adapter = TypeAdapter(HttpUrl)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ParsedImportRow:
|
||||||
|
row_number: int
|
||||||
|
data: dict[str, str]
|
||||||
|
action: str
|
||||||
|
errors: list[CustomerImportIssue]
|
||||||
|
warnings: list[CustomerImportIssue]
|
||||||
|
existing_customer: Customer | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_customer_import_template() -> str:
|
||||||
|
return ";".join(CSV_COLUMNS) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def decode_csv(content: bytes) -> str:
|
||||||
|
try:
|
||||||
|
return content.decode("utf-8-sig")
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise ValueError("CSV-Datei muss UTF-8-kodiert sein") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool(value: str) -> bool:
|
||||||
|
normalized = value.strip().lower()
|
||||||
|
if normalized in TRUE_VALUES:
|
||||||
|
return True
|
||||||
|
if normalized in FALSE_VALUES:
|
||||||
|
return False
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_bool_default(value: str, default: bool = False) -> bool:
|
||||||
|
if value.strip() == "":
|
||||||
|
return default
|
||||||
|
return parse_bool(value)
|
||||||
|
|
||||||
|
|
||||||
|
def sniff_dialect(text: str) -> csv.Dialect:
|
||||||
|
sample = text[:4096]
|
||||||
|
try:
|
||||||
|
return csv.Sniffer().sniff(sample, delimiters=",;")
|
||||||
|
except csv.Error:
|
||||||
|
return csv.excel
|
||||||
|
|
||||||
|
|
||||||
|
def read_csv_rows(content: bytes) -> list[tuple[int, dict[str, str]]]:
|
||||||
|
text = decode_csv(content)
|
||||||
|
reader = csv.DictReader(io.StringIO(text), dialect=sniff_dialect(text))
|
||||||
|
rows: list[tuple[int, dict[str, str]]] = []
|
||||||
|
|
||||||
|
for index, row in enumerate(reader, start=2):
|
||||||
|
normalized = {column: (row.get(column) or "").strip() for column in CSV_COLUMNS}
|
||||||
|
if not any(normalized.values()):
|
||||||
|
continue
|
||||||
|
rows.append((index, normalized))
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def validate_email(value: str, row_number: int, field: str, errors: list[CustomerImportIssue]) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
return str(email_adapter.validate_python(value))
|
||||||
|
except ValidationError:
|
||||||
|
errors.append(CustomerImportIssue(row=row_number, field=field, message="E-Mail ist ungültig"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def validate_url(value: str, row_number: int, errors: list[CustomerImportIssue]) -> str:
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
candidate = value if value.startswith(("http://", "https://")) else f"https://{value}"
|
||||||
|
try:
|
||||||
|
return str(url_adapter.validate_python(candidate))
|
||||||
|
except ValidationError:
|
||||||
|
errors.append(CustomerImportIssue(row=row_number, field="website", message="Website ist ungültig"))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def planned_action(mode: ImportMode, existing_customer: Customer | None) -> str:
|
||||||
|
if existing_customer is None:
|
||||||
|
return "create" if mode in {"create_only", "upsert"} else "skip"
|
||||||
|
if mode == "create_only":
|
||||||
|
return "skip"
|
||||||
|
return "update"
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerImportService:
|
||||||
|
@staticmethod
|
||||||
|
def preview(db: Session, content: bytes, mode: ImportMode) -> CustomerImportPreviewResponse:
|
||||||
|
rows = CustomerImportService._parse_and_validate(db, content, mode)
|
||||||
|
return CustomerImportService._build_preview(mode, rows)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def commit(
|
||||||
|
db: Session,
|
||||||
|
content: bytes,
|
||||||
|
mode: ImportMode,
|
||||||
|
*,
|
||||||
|
actor: User,
|
||||||
|
request,
|
||||||
|
) -> CustomerImportCommitResponse:
|
||||||
|
rows = CustomerImportService._parse_and_validate(db, content, mode)
|
||||||
|
preview = CustomerImportService._build_preview(mode, rows)
|
||||||
|
if preview.summary.valid_rows == 0:
|
||||||
|
return CustomerImportCommitResponse(
|
||||||
|
mode=mode,
|
||||||
|
summary=preview.summary,
|
||||||
|
created=0,
|
||||||
|
updated=0,
|
||||||
|
skipped=preview.summary.skip_count,
|
||||||
|
)
|
||||||
|
|
||||||
|
created = 0
|
||||||
|
updated = 0
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for row in rows:
|
||||||
|
if row.errors or row.action == "error":
|
||||||
|
continue
|
||||||
|
if row.action == "skip":
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
if row.action == "create":
|
||||||
|
CustomerImportService._create_customer(db, row.data)
|
||||||
|
created += 1
|
||||||
|
elif row.action == "update" and row.existing_customer is not None:
|
||||||
|
CustomerImportService._update_customer(db, row.existing_customer, row.data)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="customers.import",
|
||||||
|
entity_type="customers",
|
||||||
|
actor=actor,
|
||||||
|
request=request,
|
||||||
|
metadata={
|
||||||
|
"mode": mode,
|
||||||
|
"total_rows": preview.summary.total_rows,
|
||||||
|
"created": created,
|
||||||
|
"updated": updated,
|
||||||
|
"skipped": skipped,
|
||||||
|
"errors": preview.summary.error_count,
|
||||||
|
"warnings": preview.summary.warning_count,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
summary = CustomerImportService._build_preview(mode, rows).summary
|
||||||
|
return CustomerImportCommitResponse(
|
||||||
|
mode=mode,
|
||||||
|
summary=summary,
|
||||||
|
created=created,
|
||||||
|
updated=updated,
|
||||||
|
skipped=skipped,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_and_validate(db: Session, content: bytes, mode: ImportMode) -> list[ParsedImportRow]:
|
||||||
|
csv_rows = read_csv_rows(content)
|
||||||
|
number_rows: dict[str, int] = {}
|
||||||
|
duplicates: set[str] = set()
|
||||||
|
|
||||||
|
for row_number, data in csv_rows:
|
||||||
|
customer_number = data["customer_number"]
|
||||||
|
if not customer_number:
|
||||||
|
continue
|
||||||
|
if customer_number in number_rows:
|
||||||
|
duplicates.add(customer_number)
|
||||||
|
else:
|
||||||
|
number_rows[customer_number] = row_number
|
||||||
|
|
||||||
|
parsed_rows: list[ParsedImportRow] = []
|
||||||
|
for row_number, data in csv_rows:
|
||||||
|
errors: list[CustomerImportIssue] = []
|
||||||
|
warnings: list[CustomerImportIssue] = []
|
||||||
|
|
||||||
|
if not data["company_name"]:
|
||||||
|
errors.append(CustomerImportIssue(row=row_number, field="company_name", message="Firmenname ist erforderlich"))
|
||||||
|
|
||||||
|
if not data["customer_type"]:
|
||||||
|
data["customer_type"] = "company"
|
||||||
|
warnings.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ fehlt und wird als company importiert"))
|
||||||
|
elif data["customer_type"] not in VALID_TYPES:
|
||||||
|
errors.append(CustomerImportIssue(row=row_number, field="customer_type", message="Typ ist ungültig"))
|
||||||
|
|
||||||
|
if not data["status"]:
|
||||||
|
data["status"] = "active"
|
||||||
|
warnings.append(CustomerImportIssue(row=row_number, field="status", message="Status fehlt und wird als active importiert"))
|
||||||
|
elif data["status"] not in VALID_STATUSES:
|
||||||
|
errors.append(CustomerImportIssue(row=row_number, field="status", message="Status ist ungültig"))
|
||||||
|
|
||||||
|
if not data["address_type"]:
|
||||||
|
data["address_type"] = "primary"
|
||||||
|
elif data["address_type"] not in VALID_ADDRESS_TYPES:
|
||||||
|
errors.append(CustomerImportIssue(row=row_number, field="address_type", message="Adresstyp ist ungültig"))
|
||||||
|
|
||||||
|
data["email"] = validate_email(data["email"], row_number, "email", errors)
|
||||||
|
data["contact_email"] = validate_email(data["contact_email"], row_number, "contact_email", errors)
|
||||||
|
data["website"] = validate_url(data["website"], row_number, errors)
|
||||||
|
|
||||||
|
if data["customer_number"] and data["customer_number"] in duplicates:
|
||||||
|
errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist in der CSV mehrfach vorhanden"))
|
||||||
|
|
||||||
|
if not data["customer_number"] and mode == "update_existing":
|
||||||
|
errors.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer ist für update_existing erforderlich"))
|
||||||
|
|
||||||
|
if not data["customer_number"]:
|
||||||
|
warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message="Kundennummer fehlt und wird beim Erstellen generiert"))
|
||||||
|
|
||||||
|
existing_customer = (
|
||||||
|
CustomerRepository.get_by_number(db, data["customer_number"])
|
||||||
|
if data["customer_number"]
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
action = planned_action(mode, existing_customer)
|
||||||
|
if action == "skip":
|
||||||
|
reason = "Kunde existiert bereits" if existing_customer is not None else "Kunde existiert nicht"
|
||||||
|
warnings.append(CustomerImportIssue(row=row_number, field="customer_number", message=f"Zeile wird übersprungen: {reason}"))
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
action = "error"
|
||||||
|
|
||||||
|
parsed_rows.append(
|
||||||
|
ParsedImportRow(
|
||||||
|
row_number=row_number,
|
||||||
|
data=data,
|
||||||
|
action=action,
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
existing_customer=existing_customer,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return parsed_rows
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_preview(mode: ImportMode, rows: list[ParsedImportRow]) -> CustomerImportPreviewResponse:
|
||||||
|
preview_rows = [
|
||||||
|
CustomerImportPreviewRow(
|
||||||
|
row=row.row_number,
|
||||||
|
customer_number=row.data["customer_number"],
|
||||||
|
company_name=row.data["company_name"],
|
||||||
|
action=row.action,
|
||||||
|
errors=row.errors,
|
||||||
|
warnings=row.warnings,
|
||||||
|
)
|
||||||
|
for row in rows
|
||||||
|
]
|
||||||
|
errors = [issue for row in rows for issue in row.errors]
|
||||||
|
warnings = [issue for row in rows for issue in row.warnings]
|
||||||
|
duplicates = [issue for issue in errors if issue.field == "customer_number" and "mehrfach" in issue.message]
|
||||||
|
summary = CustomerImportSummary(
|
||||||
|
total_rows=len(rows),
|
||||||
|
valid_rows=sum(1 for row in rows if not row.errors and row.action in {"create", "update"}),
|
||||||
|
error_count=len(errors),
|
||||||
|
warning_count=len(warnings),
|
||||||
|
duplicate_count=len(duplicates),
|
||||||
|
create_count=sum(1 for row in rows if not row.errors and row.action == "create"),
|
||||||
|
update_count=sum(1 for row in rows if not row.errors and row.action == "update"),
|
||||||
|
skip_count=sum(1 for row in rows if not row.errors and row.action == "skip"),
|
||||||
|
)
|
||||||
|
return CustomerImportPreviewResponse(
|
||||||
|
mode=mode,
|
||||||
|
summary=summary,
|
||||||
|
rows=preview_rows,
|
||||||
|
errors=errors,
|
||||||
|
warnings=warnings,
|
||||||
|
duplicates=duplicates,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _customer_number(data: dict[str, str]) -> str:
|
||||||
|
return data["customer_number"] or f"IMP-{uuid.uuid4().hex[:10].upper()}"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _address(data: dict[str, str]) -> CustomerAddress:
|
||||||
|
return CustomerAddress(
|
||||||
|
type=data["address_type"],
|
||||||
|
street=data["street"],
|
||||||
|
postal_code=data["postal_code"],
|
||||||
|
city=data["city"],
|
||||||
|
state=data["state"],
|
||||||
|
country=data["country"] or "Deutschland",
|
||||||
|
is_primary=parse_bool_default(data["address_is_primary"], default=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _contact(data: dict[str, str]) -> CustomerContact | None:
|
||||||
|
if not any(data[key] for key in ("contact_first_name", "contact_last_name", "contact_email", "contact_phone", "contact_mobile")):
|
||||||
|
return None
|
||||||
|
return CustomerContact(
|
||||||
|
first_name=data["contact_first_name"],
|
||||||
|
last_name=data["contact_last_name"],
|
||||||
|
position=data["contact_position"],
|
||||||
|
email=data["contact_email"],
|
||||||
|
phone=data["contact_phone"],
|
||||||
|
mobile=data["contact_mobile"],
|
||||||
|
is_primary=parse_bool(data["contact_is_primary"]),
|
||||||
|
notes=data["contact_notes"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _create_customer(db: Session, data: dict[str, str]) -> Customer:
|
||||||
|
customer = Customer(
|
||||||
|
customer_number=CustomerImportService._customer_number(data),
|
||||||
|
company_name=data["company_name"],
|
||||||
|
legal_name=data["legal_name"],
|
||||||
|
customer_type=data["customer_type"],
|
||||||
|
status=data["status"],
|
||||||
|
industry=data["industry"],
|
||||||
|
website=data["website"],
|
||||||
|
email=data["email"],
|
||||||
|
phone=data["phone"],
|
||||||
|
tax_number=data["tax_number"],
|
||||||
|
vat_id=data["vat_id"],
|
||||||
|
notes=data["notes"],
|
||||||
|
addresses=[CustomerImportService._address(data)],
|
||||||
|
)
|
||||||
|
contact = CustomerImportService._contact(data)
|
||||||
|
if contact is not None:
|
||||||
|
customer.contacts = [contact]
|
||||||
|
db.add(customer)
|
||||||
|
return customer
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _update_customer(db: Session, customer: Customer, data: dict[str, str]) -> Customer:
|
||||||
|
customer.company_name = data["company_name"]
|
||||||
|
customer.legal_name = data["legal_name"]
|
||||||
|
customer.customer_type = data["customer_type"]
|
||||||
|
customer.status = data["status"]
|
||||||
|
customer.industry = data["industry"]
|
||||||
|
customer.website = data["website"]
|
||||||
|
customer.email = data["email"]
|
||||||
|
customer.phone = data["phone"]
|
||||||
|
customer.tax_number = data["tax_number"]
|
||||||
|
customer.vat_id = data["vat_id"]
|
||||||
|
customer.notes = data["notes"]
|
||||||
|
customer.addresses = [CustomerImportService._address(data)]
|
||||||
|
contact = CustomerImportService._contact(data)
|
||||||
|
if contact is not None:
|
||||||
|
if contact.is_primary:
|
||||||
|
for existing_contact in customer.contacts:
|
||||||
|
existing_contact.is_primary = False
|
||||||
|
customer.contacts.append(contact)
|
||||||
|
db.add(customer)
|
||||||
|
return customer
|
||||||
82
backend/hermes/app/services/initial_admin_bootstrap.py
Normal file
82
backend/hermes/app/services/initial_admin_bootstrap.py
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pydantic import EmailStr, TypeAdapter, ValidationError
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import hash_password
|
||||||
|
from app.models.user import User
|
||||||
|
from app.repositories.rbac_repository import RbacRepository
|
||||||
|
from app.services.audit_service import write_audit_log
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
email_adapter = TypeAdapter(EmailStr)
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_initial_admin(db: Session) -> None:
|
||||||
|
active_user_exists = db.scalar(select(User.id).where(User.is_active.is_(True)).limit(1))
|
||||||
|
if active_user_exists is not None:
|
||||||
|
logger.info("initial_admin.skipped_active_user_exists")
|
||||||
|
return
|
||||||
|
|
||||||
|
username = (settings.initial_admin_username or "").strip()
|
||||||
|
email = (settings.initial_admin_email or "").strip()
|
||||||
|
password = settings.initial_admin_password or ""
|
||||||
|
|
||||||
|
if not username or not email or not password:
|
||||||
|
logger.warning(
|
||||||
|
"initial_admin.not_configured",
|
||||||
|
extra={"reason": "missing_required_initial_admin_environment"},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
normalized_email = str(email_adapter.validate_python(email))
|
||||||
|
except ValidationError:
|
||||||
|
logger.warning("initial_admin.invalid_email")
|
||||||
|
return
|
||||||
|
|
||||||
|
administrator_role = RbacRepository.get_role_by_name(db, "administrator")
|
||||||
|
if administrator_role is None:
|
||||||
|
logger.warning("initial_admin.missing_administrator_role")
|
||||||
|
return
|
||||||
|
|
||||||
|
existing_user = db.scalar(select(User).where((User.username == username) | (User.email == normalized_email)))
|
||||||
|
if existing_user is not None:
|
||||||
|
logger.info("initial_admin.skipped_user_already_exists")
|
||||||
|
return
|
||||||
|
|
||||||
|
admin = User(
|
||||||
|
first_name=settings.initial_admin_first_name.strip(),
|
||||||
|
last_name=settings.initial_admin_last_name.strip(),
|
||||||
|
username=username,
|
||||||
|
email=normalized_email,
|
||||||
|
password_hash=hash_password(password),
|
||||||
|
role="administrator",
|
||||||
|
role_id=administrator_role.id,
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
db.add(admin)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(admin)
|
||||||
|
|
||||||
|
write_audit_log(
|
||||||
|
db,
|
||||||
|
action="users.initial_admin_bootstrap",
|
||||||
|
entity_type="users",
|
||||||
|
entity_id=admin.id,
|
||||||
|
entity_label=admin.username,
|
||||||
|
actor_username="system",
|
||||||
|
after_data={
|
||||||
|
"id": admin.id,
|
||||||
|
"username": admin.username,
|
||||||
|
"email": admin.email,
|
||||||
|
"role": admin.role,
|
||||||
|
"role_id": admin.role_id,
|
||||||
|
"is_active": admin.is_active,
|
||||||
|
},
|
||||||
|
metadata={"source": "initial_admin_bootstrap"},
|
||||||
|
)
|
||||||
|
logger.info("initial_admin.created", extra={"user_id": admin.id})
|
||||||
|
|
@ -5,10 +5,15 @@ services:
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus
|
DATABASE_URL: ${DATABASE_URL}
|
||||||
APP_NAME: Hermes API
|
APP_NAME: Hermes API
|
||||||
APP_VERSION: 0.1.0
|
APP_VERSION: 0.1.0
|
||||||
SECRET_KEY: ${SECRET_KEY}
|
SECRET_KEY: ${SECRET_KEY}
|
||||||
|
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
|
||||||
|
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
|
||||||
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
||||||
|
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
|
||||||
|
INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-}
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ services:
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||||
|
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-}
|
||||||
|
INITIAL_ADMIN_EMAIL: ${INITIAL_ADMIN_EMAIL:-}
|
||||||
|
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-}
|
||||||
|
INITIAL_ADMIN_FIRST_NAME: ${INITIAL_ADMIN_FIRST_NAME:-}
|
||||||
|
INITIAL_ADMIN_LAST_NAME: ${INITIAL_ADMIN_LAST_NAME:-}
|
||||||
|
|
||||||
expose:
|
expose:
|
||||||
- "8000"
|
- "8000"
|
||||||
|
|
|
||||||
14
frontend/athena/app/api/customers/import/commit/route.ts
Normal file
14
frontend/athena/app/api/customers/import/commit/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/customers/import/commit");
|
||||||
|
}
|
||||||
14
frontend/athena/app/api/customers/import/preview/route.ts
Normal file
14
frontend/athena/app/api/customers/import/preview/route.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
import { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
import { assertSameOrigin } from "@/lib/server/request-guards";
|
||||||
|
import { proxyHermesRequest } from "@/lib/server/hermes-proxy";
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
const originError = assertSameOrigin(request);
|
||||||
|
|
||||||
|
if (originError) {
|
||||||
|
return originError;
|
||||||
|
}
|
||||||
|
|
||||||
|
return proxyHermesRequest(request, "/customers/import/preview");
|
||||||
|
}
|
||||||
40
frontend/athena/app/api/customers/import/template/route.ts
Normal file
40
frontend/athena/app/api/customers/import/template/route.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
"customer_number",
|
||||||
|
"company_name",
|
||||||
|
"legal_name",
|
||||||
|
"customer_type",
|
||||||
|
"status",
|
||||||
|
"industry",
|
||||||
|
"website",
|
||||||
|
"email",
|
||||||
|
"phone",
|
||||||
|
"tax_number",
|
||||||
|
"vat_id",
|
||||||
|
"notes",
|
||||||
|
"address_type",
|
||||||
|
"street",
|
||||||
|
"postal_code",
|
||||||
|
"city",
|
||||||
|
"state",
|
||||||
|
"country",
|
||||||
|
"address_is_primary",
|
||||||
|
"contact_first_name",
|
||||||
|
"contact_last_name",
|
||||||
|
"contact_position",
|
||||||
|
"contact_email",
|
||||||
|
"contact_phone",
|
||||||
|
"contact_mobile",
|
||||||
|
"contact_is_primary",
|
||||||
|
"contact_notes",
|
||||||
|
];
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return new NextResponse(`${columns.join(";")}\n`, {
|
||||||
|
headers: {
|
||||||
|
"Content-Disposition": 'attachment; filename="kundenimport-vorlage.csv"',
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Edit, Eye, Plus, Trash2 } from "lucide-react";
|
import { Edit, Eye, Plus, Trash2, Upload } from "lucide-react";
|
||||||
|
|
||||||
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
import ConfirmDialog from "@/components/common/ConfirmDialog";
|
||||||
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
import DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||||
|
|
@ -10,11 +10,12 @@ import SearchInput from "@/components/common/SearchInput";
|
||||||
import { useToast } from "@/components/common/ToastProvider";
|
import { useToast } from "@/components/common/ToastProvider";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
|
import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
|
||||||
|
import CustomerImportDialog from "@/components/customers/CustomerImportDialog";
|
||||||
import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge";
|
import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
import { hasPermission } from "@/lib/permissions";
|
import { hasPermission } from "@/lib/permissions";
|
||||||
import type { CurrentUser } from "@/types/rbac";
|
import type { CurrentUser } from "@/types/rbac";
|
||||||
import type { Customer, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer";
|
import type { Customer, CustomerImportCommitResponse, CustomerPayload, CustomerStatus, CustomerType } from "@/types/customer";
|
||||||
|
|
||||||
const pageSize = 10;
|
const pageSize = 10;
|
||||||
|
|
||||||
|
|
@ -64,6 +65,7 @@ export default function CustomersPage() {
|
||||||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [formOpen, setFormOpen] = useState(false);
|
const [formOpen, setFormOpen] = useState(false);
|
||||||
|
const [importOpen, setImportOpen] = useState(false);
|
||||||
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
|
||||||
const [deleteCustomer, setDeleteCustomer] = useState<Customer | null>(null);
|
const [deleteCustomer, setDeleteCustomer] = useState<Customer | null>(null);
|
||||||
|
|
||||||
|
|
@ -122,6 +124,7 @@ export default function CustomersPage() {
|
||||||
const pageCount = Math.max(1, Math.ceil(filteredCustomers.length / pageSize));
|
const pageCount = Math.max(1, Math.ceil(filteredCustomers.length / pageSize));
|
||||||
const currentPage = Math.min(page, pageCount);
|
const currentPage = Math.min(page, pageCount);
|
||||||
const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||||
|
const canImportCustomers = hasPermission(currentUser, "customers.create") || hasPermission(currentUser, "customers.update");
|
||||||
|
|
||||||
const columns: DataTableColumn<Customer>[] = [
|
const columns: DataTableColumn<Customer>[] = [
|
||||||
{ key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number },
|
{ key: "customer_number", label: "Kundennummer", sortable: true, render: (customer) => customer.customer_number },
|
||||||
|
|
@ -221,6 +224,16 @@ export default function CustomersPage() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleImportCommitted(result: CustomerImportCommitResponse) {
|
||||||
|
showToast({
|
||||||
|
type: "success",
|
||||||
|
title: "Kundenimport abgeschlossen",
|
||||||
|
description: `${result.created} erstellt, ${result.updated} aktualisiert, ${result.skipped} übersprungen`,
|
||||||
|
});
|
||||||
|
setLoading(true);
|
||||||
|
await loadCustomers();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
|
@ -228,12 +241,20 @@ export default function CustomersPage() {
|
||||||
<h1 className="text-3xl font-bold text-slate-950">Kunden</h1>
|
<h1 className="text-3xl font-bold text-slate-950">Kunden</h1>
|
||||||
<p className="mt-1 text-sm text-slate-500">{filteredCustomers.length} von {customers.length} Kunden</p>
|
<p className="mt-1 text-sm text-slate-500">{filteredCustomers.length} von {customers.length} Kunden</p>
|
||||||
</div>
|
</div>
|
||||||
{hasPermission(currentUser, "customers.create") && (
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button type="button" onClick={openCreateDialog}>
|
{canImportCustomers && (
|
||||||
<Plus size={16} />
|
<Button type="button" variant="outline" onClick={() => setImportOpen(true)}>
|
||||||
Neuer Kunde
|
<Upload size={16} />
|
||||||
</Button>
|
Import
|
||||||
)}
|
</Button>
|
||||||
|
)}
|
||||||
|
{hasPermission(currentUser, "customers.create") && (
|
||||||
|
<Button type="button" onClick={openCreateDialog}>
|
||||||
|
<Plus size={16} />
|
||||||
|
Neuer Kunde
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
|
<div className="flex flex-col gap-3 rounded-lg border bg-white p-4 lg:flex-row lg:items-center">
|
||||||
|
|
@ -286,6 +307,13 @@ export default function CustomersPage() {
|
||||||
onSubmit={saveCustomer}
|
onSubmit={saveCustomer}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<CustomerImportDialog
|
||||||
|
open={importOpen}
|
||||||
|
onOpenChange={setImportOpen}
|
||||||
|
onCommitted={handleImportCommitted}
|
||||||
|
onError={(message) => showToast({ type: "error", title: "Kundenimport fehlgeschlagen", description: message })}
|
||||||
|
/>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={Boolean(deleteCustomer)}
|
open={Boolean(deleteCustomer)}
|
||||||
title="Kunde löschen"
|
title="Kunde löschen"
|
||||||
|
|
|
||||||
|
|
@ -1,31 +1,51 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
import LogoutButton from "@/components/LogoutButton";
|
import LogoutButton from "@/components/LogoutButton";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type { CurrentUser } from "@/types/rbac";
|
||||||
|
|
||||||
export default function Header() {
|
export default function Header() {
|
||||||
|
const [currentUser, setCurrentUser] = useState<CurrentUser | null>(null);
|
||||||
|
|
||||||
return (
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
|
||||||
<header className="h-20 bg-white border-b flex items-center justify-between px-8">
|
api.get<CurrentUser>("/me")
|
||||||
|
.then((response) => {
|
||||||
|
if (active) {
|
||||||
|
setCurrentUser(response.data);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (active) {
|
||||||
|
setCurrentUser(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
<h1 className="text-3xl font-bold">
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
Dashboard
|
const displayName = useMemo(() => {
|
||||||
|
if (!currentUser) {
|
||||||
|
return "Benutzer";
|
||||||
|
}
|
||||||
|
|
||||||
</h1>
|
const fullName = `${currentUser.first_name} ${currentUser.last_name}`.trim();
|
||||||
|
return fullName || currentUser.username || "Benutzer";
|
||||||
|
}, [currentUser]);
|
||||||
|
|
||||||
<div className="flex items-center gap-4">
|
return (
|
||||||
|
<header className="flex h-20 items-center justify-between border-b bg-white px-8">
|
||||||
<span className="font-semibold">
|
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||||
|
|
||||||
admin.schubert
|
|
||||||
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<LogoutButton />
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</header>
|
|
||||||
|
|
||||||
);
|
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="font-semibold">{displayName}</span>
|
||||||
|
<LogoutButton />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
177
frontend/athena/components/customers/CustomerImportDialog.tsx
Normal file
177
frontend/athena/components/customers/CustomerImportDialog.tsx
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Download, FileUp, Play, Upload } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import ImportPreviewTable from "@/components/customers/ImportPreviewTable";
|
||||||
|
import ImportSummaryCard from "@/components/customers/ImportSummaryCard";
|
||||||
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
CustomerImportCommitResponse,
|
||||||
|
CustomerImportMode,
|
||||||
|
CustomerImportPreviewResponse,
|
||||||
|
} from "@/types/customer";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onCommitted: (result: CustomerImportCommitResponse) => void;
|
||||||
|
onError: (message: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorMessage(error: unknown) {
|
||||||
|
if (typeof error === "object" && error !== null && "response" in error) {
|
||||||
|
const response = (error as { response?: { data?: { detail?: string } } }).response;
|
||||||
|
return response?.data?.detail ?? "Import konnte nicht ausgeführt werden";
|
||||||
|
}
|
||||||
|
return "Import konnte nicht ausgeführt werden";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildFormData(file: File, mode: CustomerImportMode) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("mode", mode);
|
||||||
|
formData.append("file", file);
|
||||||
|
return formData;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CustomerImportDialog({ open, onOpenChange, onCommitted, onError }: Props) {
|
||||||
|
const [file, setFile] = useState<File | null>(null);
|
||||||
|
const [mode, setMode] = useState<CustomerImportMode>("upsert");
|
||||||
|
const [preview, setPreview] = useState<CustomerImportPreviewResponse | null>(null);
|
||||||
|
const [pending, setPending] = useState(false);
|
||||||
|
const [serverError, setServerError] = useState("");
|
||||||
|
|
||||||
|
async function runPreview() {
|
||||||
|
if (!file) {
|
||||||
|
setServerError("Bitte CSV-Datei auswählen");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(true);
|
||||||
|
setServerError("");
|
||||||
|
try {
|
||||||
|
const response = await api.post<CustomerImportPreviewResponse>(
|
||||||
|
"/customers/import/preview",
|
||||||
|
buildFormData(file, mode),
|
||||||
|
);
|
||||||
|
setPreview(response.data);
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
setServerError(message);
|
||||||
|
onError(message);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function commitImport() {
|
||||||
|
if (!file || !preview || preview.summary.valid_rows === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPending(true);
|
||||||
|
setServerError("");
|
||||||
|
try {
|
||||||
|
const response = await api.post<CustomerImportCommitResponse>(
|
||||||
|
"/customers/import/commit",
|
||||||
|
buildFormData(file, mode),
|
||||||
|
);
|
||||||
|
onCommitted(response.data);
|
||||||
|
setPreview(null);
|
||||||
|
setFile(null);
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
const message = errorMessage(error);
|
||||||
|
setServerError(message);
|
||||||
|
onError(message);
|
||||||
|
} finally {
|
||||||
|
setPending(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-5xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Kunden importieren</DialogTitle>
|
||||||
|
<DialogDescription>CSV-Datei prüfen und anschließend gültige Zeilen übernehmen.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_14rem_auto] lg:items-end">
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="customer-import-file">CSV-Datei</Label>
|
||||||
|
<input
|
||||||
|
id="customer-import-file"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
className="h-10 rounded-lg border border-input bg-white px-3 py-2 text-sm"
|
||||||
|
onChange={(event) => {
|
||||||
|
setFile(event.target.files?.[0] ?? null);
|
||||||
|
setPreview(null);
|
||||||
|
setServerError("");
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-2">
|
||||||
|
<Label htmlFor="customer-import-mode">Importmodus</Label>
|
||||||
|
<select
|
||||||
|
id="customer-import-mode"
|
||||||
|
value={mode}
|
||||||
|
className="h-10 rounded-lg border border-input bg-white px-3 text-sm"
|
||||||
|
onChange={(event) => {
|
||||||
|
setMode(event.target.value as CustomerImportMode);
|
||||||
|
setPreview(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="upsert">Upsert</option>
|
||||||
|
<option value="create_only">Nur erstellen</option>
|
||||||
|
<option value="update_existing">Bestehende aktualisieren</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Link href="/api/customers/import/template" className={buttonVariants({ variant: "outline", size: "default" })}>
|
||||||
|
<Download size={16} />
|
||||||
|
Vorlage
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{serverError && <p className="rounded-lg bg-red-50 p-3 text-sm text-red-700">{serverError}</p>}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="grid gap-4">
|
||||||
|
<ImportSummaryCard summary={preview.summary} />
|
||||||
|
<ImportPreviewTable rows={preview.rows} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={pending}>
|
||||||
|
Schließen
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={runPreview} disabled={pending || !file}>
|
||||||
|
<Play size={16} />
|
||||||
|
Preview starten
|
||||||
|
</Button>
|
||||||
|
<Button type="button" onClick={commitImport} disabled={pending || !preview || preview.summary.valid_rows === 0}>
|
||||||
|
{pending ? <FileUp size={16} /> : <Upload size={16} />}
|
||||||
|
Import übernehmen
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
frontend/athena/components/customers/ImportPreviewTable.tsx
Normal file
56
frontend/athena/components/customers/ImportPreviewTable.tsx
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { CustomerImportPreviewRow } from "@/types/customer";
|
||||||
|
|
||||||
|
const actionLabels = {
|
||||||
|
create: "Create",
|
||||||
|
update: "Update",
|
||||||
|
skip: "Skip",
|
||||||
|
error: "Error",
|
||||||
|
};
|
||||||
|
|
||||||
|
const actionClasses = {
|
||||||
|
create: "bg-emerald-50 text-emerald-700",
|
||||||
|
update: "bg-blue-50 text-blue-700",
|
||||||
|
skip: "bg-slate-100 text-slate-600",
|
||||||
|
error: "bg-red-50 text-red-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
function issueText(row: CustomerImportPreviewRow) {
|
||||||
|
const issues = [...row.errors, ...row.warnings];
|
||||||
|
if (issues.length === 0) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return issues.map((issue) => `${issue.field || "Zeile"}: ${issue.message}`).join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ImportPreviewTable({ rows }: { rows: CustomerImportPreviewRow[] }) {
|
||||||
|
return (
|
||||||
|
<div className="max-h-80 overflow-auto rounded-lg border">
|
||||||
|
<table className="w-full min-w-[720px] border-collapse text-sm">
|
||||||
|
<thead className="sticky top-0 bg-slate-50 text-left text-xs uppercase text-slate-500">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2">Zeile</th>
|
||||||
|
<th className="px-3 py-2">Aktion</th>
|
||||||
|
<th className="px-3 py-2">Kundennummer</th>
|
||||||
|
<th className="px-3 py-2">Firma</th>
|
||||||
|
<th className="px-3 py-2">Hinweise</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y bg-white">
|
||||||
|
{rows.map((row) => (
|
||||||
|
<tr key={row.row}>
|
||||||
|
<td className="px-3 py-2 font-medium">{row.row}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`rounded-full px-2 py-1 text-xs font-semibold ${actionClasses[row.action]}`}>
|
||||||
|
{actionLabels[row.action]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">{row.customer_number || "-"}</td>
|
||||||
|
<td className="px-3 py-2">{row.company_name || "-"}</td>
|
||||||
|
<td className="px-3 py-2 text-slate-600">{issueText(row)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
frontend/athena/components/customers/ImportSummaryCard.tsx
Normal file
24
frontend/athena/components/customers/ImportSummaryCard.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { CustomerImportSummary } from "@/types/customer";
|
||||||
|
|
||||||
|
const summaryItems = [
|
||||||
|
["Gesamt", "total_rows"],
|
||||||
|
["Gültig", "valid_rows"],
|
||||||
|
["Fehler", "error_count"],
|
||||||
|
["Warnungen", "warning_count"],
|
||||||
|
["Creates", "create_count"],
|
||||||
|
["Updates", "update_count"],
|
||||||
|
["Skips", "skip_count"],
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function ImportSummaryCard({ summary }: { summary: CustomerImportSummary }) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 lg:grid-cols-7">
|
||||||
|
{summaryItems.map(([label, key]) => (
|
||||||
|
<div key={key} className="rounded-lg border bg-white p-3">
|
||||||
|
<p className="text-xs text-slate-500">{label}</p>
|
||||||
|
<p className="mt-1 text-xl font-semibold text-slate-950">{summary[key]}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,8 @@ export async function proxyHermesRequest(
|
||||||
}
|
}
|
||||||
|
|
||||||
let hermesResponse: Response;
|
let hermesResponse: Response;
|
||||||
|
const isMultipart = request.headers.get("content-type")?.includes("multipart/form-data") ?? false;
|
||||||
|
const hasBody = request.method !== "GET" && request.method !== "DELETE";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
||||||
|
|
@ -34,11 +36,13 @@ export async function proxyHermesRequest(
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Bearer ${token}`,
|
Authorization: `Bearer ${token}`,
|
||||||
Accept: "application/json",
|
Accept: "application/json",
|
||||||
"Content-Type": "application/json",
|
...(!isMultipart ? { "Content-Type": "application/json" } : {}),
|
||||||
},
|
},
|
||||||
body: request.method === "GET" || request.method === "DELETE"
|
body: !hasBody
|
||||||
? undefined
|
? undefined
|
||||||
: await request.text(),
|
: isMultipart
|
||||||
|
? await request.formData()
|
||||||
|
: await request.text(),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived";
|
export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived";
|
||||||
export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier";
|
export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier";
|
||||||
export type AddressType = "billing" | "shipping" | "primary" | "other";
|
export type AddressType = "billing" | "shipping" | "primary" | "other";
|
||||||
|
export type CustomerImportMode = "create_only" | "update_existing" | "upsert";
|
||||||
|
export type CustomerImportAction = "create" | "update" | "skip" | "error";
|
||||||
|
|
||||||
export interface CustomerAddress {
|
export interface CustomerAddress {
|
||||||
id: number;
|
id: number;
|
||||||
|
|
@ -87,3 +89,46 @@ export type CustomerContactPayload = {
|
||||||
is_primary: boolean;
|
is_primary: boolean;
|
||||||
notes: string;
|
notes: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export interface CustomerImportIssue {
|
||||||
|
row: number;
|
||||||
|
field: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerImportPreviewRow {
|
||||||
|
row: number;
|
||||||
|
customer_number: string;
|
||||||
|
company_name: string;
|
||||||
|
action: CustomerImportAction;
|
||||||
|
errors: CustomerImportIssue[];
|
||||||
|
warnings: CustomerImportIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerImportSummary {
|
||||||
|
total_rows: number;
|
||||||
|
valid_rows: number;
|
||||||
|
error_count: number;
|
||||||
|
warning_count: number;
|
||||||
|
duplicate_count: number;
|
||||||
|
create_count: number;
|
||||||
|
update_count: number;
|
||||||
|
skip_count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerImportPreviewResponse {
|
||||||
|
mode: CustomerImportMode;
|
||||||
|
summary: CustomerImportSummary;
|
||||||
|
rows: CustomerImportPreviewRow[];
|
||||||
|
errors: CustomerImportIssue[];
|
||||||
|
warnings: CustomerImportIssue[];
|
||||||
|
duplicates: CustomerImportIssue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CustomerImportCommitResponse {
|
||||||
|
mode: CustomerImportMode;
|
||||||
|
summary: CustomerImportSummary;
|
||||||
|
created: number;
|
||||||
|
updated: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ export interface RolePayload {
|
||||||
|
|
||||||
export interface CurrentUser {
|
export interface CurrentUser {
|
||||||
id: number;
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
username: string;
|
username: string;
|
||||||
email: string;
|
email: string;
|
||||||
role: string;
|
role: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue