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.
|
||||
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
|
||||
|
||||
Das Auth-Cookie wird von Athena gesetzt.
|
||||
|
|
@ -238,6 +244,56 @@ Kunden:
|
|||
- `customers.read`
|
||||
- `customers.create`
|
||||
- `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`
|
||||
|
||||
Projekte:
|
||||
|
|
|
|||
|
|
@ -105,6 +105,7 @@ uv run alembic upgrade head
|
|||
- Erfolg und Fehler in mutierenden CRUD-Flows ueber den Toast-Provider melden.
|
||||
- Keine Browser-Dialoge wie `alert()` oder `confirm()`.
|
||||
- Keine Tokens in Browser-JavaScript speichern.
|
||||
- Datei-Uploads vom Browser laufen ueber Athena-BFF-Routen und werden serverseitig an Hermes weitergeleitet.
|
||||
|
||||
### Backend
|
||||
|
||||
|
|
@ -118,6 +119,7 @@ uv run alembic upgrade head
|
|||
- Berechtigungen mit `require_permission`, `require_any_permission` oder `require_all_permissions` pruefen.
|
||||
- Mutierende Kernaktionen mit Audit Logs erfassen, sofern fachlich relevant.
|
||||
- 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
|
||||
|
||||
|
|
@ -143,6 +145,7 @@ Diese Regeln sind verbindlich:
|
|||
- Frontend-Permissions dienen nur der UI und ersetzen keine Backend-Pruefung.
|
||||
- Audit Logs werden serverseitig in Hermes geschrieben.
|
||||
- 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.
|
||||
|
||||
|
|
@ -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.
|
||||
|
||||
### 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 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
|
||||
- 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
|
||||
|
||||
- Projektstammdaten
|
||||
|
|
|
|||
|
|
@ -4,3 +4,12 @@ SECRET_KEY=CHANGE_ME
|
|||
|
||||
APP_NAME=Hermes API
|
||||
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)):
|
||||
return {
|
||||
"id": current_user.id,
|
||||
"first_name": current_user.first_name,
|
||||
"last_name": current_user.last_name,
|
||||
"username": current_user.username,
|
||||
"email": current_user.email,
|
||||
"role": current_user.primary_role.name,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
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 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.models.customer import Customer, CustomerContact
|
||||
from app.models.user import User
|
||||
|
|
@ -17,7 +17,13 @@ from app.schemas.customer import (
|
|||
CustomerResponse,
|
||||
CustomerUpdate,
|
||||
)
|
||||
from app.schemas.customer_import import (
|
||||
CustomerImportCommitResponse,
|
||||
CustomerImportPreviewResponse,
|
||||
ImportMode,
|
||||
)
|
||||
from app.services.audit_service import sanitize, write_audit_log
|
||||
from app.services.customer_import_service import CustomerImportService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -26,6 +32,23 @@ router = APIRouter(
|
|||
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:
|
||||
customer = CustomerRepository.get_by_id(db, customer_id)
|
||||
|
|
@ -116,6 +139,44 @@ def create_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)
|
||||
def update_customer(
|
||||
customer_id: int,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ class Settings(BaseSettings):
|
|||
access_token_expire_minutes: int = 60
|
||||
jwt_issuer: str = "hermes"
|
||||
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(
|
||||
env_file=".env",
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from app.db.database import SessionLocal
|
|||
from app.db.health import check_database
|
||||
from app.core.logging import configure_logging
|
||||
from app.rbac.seed import seed_rbac
|
||||
from app.services.initial_admin_bootstrap import bootstrap_initial_admin
|
||||
|
||||
configure_logging()
|
||||
|
||||
|
|
@ -73,6 +74,7 @@ def startup_seed_rbac():
|
|||
db = SessionLocal()
|
||||
try:
|
||||
seed_rbac(db)
|
||||
bootstrap_initial_admin(db)
|
||||
finally:
|
||||
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):
|
||||
id: int
|
||||
first_name: str
|
||||
last_name: str
|
||||
username: str
|
||||
email: str
|
||||
role: str
|
||||
|
|
|
|||
|
|
@ -126,9 +126,11 @@ def action_title(action: str) -> str:
|
|||
"customers.create": "Kunde erstellt",
|
||||
"customers.update": "Kunde bearbeitet",
|
||||
"customers.delete": "Kunde gelöscht",
|
||||
"customers.import": "Kunden importiert",
|
||||
"customer_contacts.create": "Ansprechpartner erstellt",
|
||||
"customer_contacts.update": "Ansprechpartner bearbeitet",
|
||||
"customer_contacts.delete": "Ansprechpartner gelöscht",
|
||||
"users.initial_admin_bootstrap": "Initialer Administrator erstellt",
|
||||
}
|
||||
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
|
||||
|
||||
environment:
|
||||
DATABASE_URL: postgresql+psycopg://olympus:FsFs03285310!!!@olympus-db:5432/olympus
|
||||
DATABASE_URL: ${DATABASE_URL}
|
||||
APP_NAME: Hermes API
|
||||
APP_VERSION: 0.1.0
|
||||
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}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-INFO}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,11 @@ services:
|
|||
ACCESS_TOKEN_EXPIRE_MINUTES: ${ACCESS_TOKEN_EXPIRE_MINUTES:-60}
|
||||
JWT_ISSUER: ${JWT_ISSUER:-hermes}
|
||||
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:
|
||||
- "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 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 DataTable, { type DataTableColumn } from "@/components/common/DataTable";
|
||||
|
|
@ -10,11 +10,12 @@ import SearchInput from "@/components/common/SearchInput";
|
|||
import { useToast } from "@/components/common/ToastProvider";
|
||||
import { Button, buttonVariants } from "@/components/ui/button";
|
||||
import CustomerFormDialog from "@/components/customers/CustomerFormDialog";
|
||||
import CustomerImportDialog from "@/components/customers/CustomerImportDialog";
|
||||
import CustomerStatusBadge from "@/components/customers/CustomerStatusBadge";
|
||||
import { api } from "@/lib/api";
|
||||
import { hasPermission } from "@/lib/permissions";
|
||||
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;
|
||||
|
||||
|
|
@ -64,6 +65,7 @@ export default function CustomersPage() {
|
|||
const [sortDirection, setSortDirection] = useState<"asc" | "desc">("desc");
|
||||
const [page, setPage] = useState(1);
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [selectedCustomer, setSelectedCustomer] = 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 currentPage = Math.min(page, pageCount);
|
||||
const pageCustomers = filteredCustomers.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
const canImportCustomers = hasPermission(currentUser, "customers.create") || hasPermission(currentUser, "customers.update");
|
||||
|
||||
const columns: DataTableColumn<Customer>[] = [
|
||||
{ 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 (
|
||||
<div className="space-y-6">
|
||||
<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>
|
||||
<p className="mt-1 text-sm text-slate-500">{filteredCustomers.length} von {customers.length} Kunden</p>
|
||||
</div>
|
||||
{hasPermission(currentUser, "customers.create") && (
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={16} />
|
||||
Neuer Kunde
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{canImportCustomers && (
|
||||
<Button type="button" variant="outline" onClick={() => setImportOpen(true)}>
|
||||
<Upload size={16} />
|
||||
Import
|
||||
</Button>
|
||||
)}
|
||||
{hasPermission(currentUser, "customers.create") && (
|
||||
<Button type="button" onClick={openCreateDialog}>
|
||||
<Plus size={16} />
|
||||
Neuer Kunde
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
/>
|
||||
|
||||
<CustomerImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onCommitted={handleImportCommitted}
|
||||
onError={(message) => showToast({ type: "error", title: "Kundenimport fehlgeschlagen", description: message })}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteCustomer)}
|
||||
title="Kunde löschen"
|
||||
|
|
|
|||
|
|
@ -1,31 +1,51 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import LogoutButton from "@/components/LogoutButton";
|
||||
import { api } from "@/lib/api";
|
||||
import type { CurrentUser } from "@/types/rbac";
|
||||
|
||||
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">
|
||||
|
||||
<span className="font-semibold">
|
||||
|
||||
admin.schubert
|
||||
|
||||
</span>
|
||||
|
||||
<LogoutButton />
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
|
||||
);
|
||||
return (
|
||||
<header className="flex h-20 items-center justify-between border-b bg-white px-8">
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
|
||||
<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;
|
||||
const isMultipart = request.headers.get("content-type")?.includes("multipart/form-data") ?? false;
|
||||
const hasBody = request.method !== "GET" && request.method !== "DELETE";
|
||||
|
||||
try {
|
||||
hermesResponse = await fetch(`${hermesUrl}${path}`, {
|
||||
|
|
@ -34,11 +36,13 @@ export async function proxyHermesRequest(
|
|||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...(!isMultipart ? { "Content-Type": "application/json" } : {}),
|
||||
},
|
||||
body: request.method === "GET" || request.method === "DELETE"
|
||||
body: !hasBody
|
||||
? undefined
|
||||
: await request.text(),
|
||||
: isMultipart
|
||||
? await request.formData()
|
||||
: await request.text(),
|
||||
cache: "no-store",
|
||||
});
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
export type CustomerStatus = "lead" | "active" | "inactive" | "blocked" | "archived";
|
||||
export type CustomerType = "company" | "private" | "public_sector" | "partner" | "supplier";
|
||||
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 {
|
||||
id: number;
|
||||
|
|
@ -87,3 +89,46 @@ export type CustomerContactPayload = {
|
|||
is_primary: boolean;
|
||||
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 {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
username: string;
|
||||
email: string;
|
||||
role: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue