feat(repairs): add repair management foundation

This commit is contained in:
Schubert Ferenc 2026-07-04 11:46:58 +02:00
parent 22e54f212c
commit e9ec207617
37 changed files with 2149 additions and 7 deletions

View file

@ -18,3 +18,4 @@ STORAGE_BASE_PATH=/data/storage
STORAGE_MAX_UPLOAD_MB=50
KNOWLEDGE_STORAGE_PATH=/data/knowledge
KNOWLEDGE_MAX_UPLOAD_MB=50
OLYMPUS_REPAIR_INTAKE_TOKEN=

View file

@ -8,6 +8,11 @@ from alembic import context
from app.core.config import settings
from app.db.database import Base
import app.models.user
import app.models.customer
import app.models.knowledge
import app.models.audit
import app.models.rbac
import app.models.repair
config = context.config

View file

@ -0,0 +1,204 @@
"""create repairs module
Revision ID: c8e2f1a9b704
Revises: b6f3d8c1a920
Create Date: 2026-07-04 11:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = "c8e2f1a9b704"
down_revision: Union[str, Sequence[str], None] = "b6f3d8c1a920"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
REPAIR_PERMISSIONS = [
("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"),
("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"),
("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"),
("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"),
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
]
ROLE_PERMISSIONS = {
"administrator": [permission[0] for permission in REPAIR_PERMISSIONS],
"management": ["repairs.read", "repairs.create", "repairs.update", "repairs.status.update"],
"technician": ["repairs.read", "repairs.update", "repairs.status.update"],
"support": ["repairs.read", "repairs.create", "repairs.update", "repairs.status.update", "repairs.intake"],
}
def upgrade() -> None:
op.create_table(
"repairs",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_number", sa.String(length=20), nullable=False),
sa.Column("customer_id", sa.Integer(), nullable=True),
sa.Column("contact_id", sa.Integer(), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("priority", sa.String(length=30), nullable=False),
sa.Column("source", sa.String(length=50), nullable=False),
sa.Column("source_reference", sa.String(length=120), nullable=True),
sa.Column("customer_name", sa.String(length=255), nullable=False),
sa.Column("customer_email", sa.String(length=255), server_default="", nullable=False),
sa.Column("customer_phone", sa.String(length=80), server_default="", nullable=False),
sa.Column("device_manufacturer", sa.String(length=160), nullable=False),
sa.Column("device_model", sa.String(length=160), nullable=False),
sa.Column("device_serial_number", sa.String(length=160), nullable=True),
sa.Column("device_type", sa.String(length=120), server_default="", nullable=False),
sa.Column("accessories", sa.Text(), server_default="", nullable=False),
sa.Column("fault_description", sa.Text(), nullable=False),
sa.Column("previous_work", sa.Text(), server_default="", nullable=False),
sa.Column("device_opened", sa.Boolean(), server_default="false", nullable=False),
sa.Column("intake_notes", sa.Text(), server_default="", nullable=False),
sa.Column("diagnosis_notes", sa.Text(), server_default="", nullable=False),
sa.Column("repair_notes", sa.Text(), server_default="", nullable=False),
sa.Column("estimate_notes", sa.Text(), server_default="", nullable=False),
sa.Column("estimated_cost_cents", sa.Integer(), nullable=True),
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.CheckConstraint("status in ('new','accepted','diagnosis','estimate','waiting_for_customer','approved','repair','final_test','ready_for_pickup','shipped','completed','cancelled')", name="ck_repairs_status"),
sa.CheckConstraint("priority in ('low','normal','high','urgent')", name="ck_repairs_priority"),
sa.CheckConstraint("source in ('manual','website','customer_portal','email','phone')", name="ck_repairs_source"),
sa.ForeignKeyConstraint(["contact_id"], ["customer_contacts.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["customer_id"], ["customers.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("repair_number"),
)
op.create_index(op.f("ix_repairs_contact_id"), "repairs", ["contact_id"], unique=False)
op.create_index(op.f("ix_repairs_customer_id"), "repairs", ["customer_id"], unique=False)
op.create_index(op.f("ix_repairs_customer_name"), "repairs", ["customer_name"], unique=False)
op.create_index(op.f("ix_repairs_device_manufacturer"), "repairs", ["device_manufacturer"], unique=False)
op.create_index(op.f("ix_repairs_device_model"), "repairs", ["device_model"], unique=False)
op.create_index(op.f("ix_repairs_priority"), "repairs", ["priority"], unique=False)
op.create_index(op.f("ix_repairs_repair_number"), "repairs", ["repair_number"], unique=True)
op.create_index(op.f("ix_repairs_source"), "repairs", ["source"], unique=False)
op.create_index(op.f("ix_repairs_source_reference"), "repairs", ["source_reference"], unique=False)
op.create_index(op.f("ix_repairs_status"), "repairs", ["status"], unique=False)
op.create_table(
"repair_status_history",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("old_status", sa.String(length=50), nullable=True),
sa.Column("new_status", sa.String(length=50), nullable=False),
sa.Column("note", sa.Text(), nullable=True),
sa.Column("actor_user_id", sa.Integer(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["actor_user_id"], ["users.id"], ondelete="SET NULL"),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_status_history_actor_user_id"), "repair_status_history", ["actor_user_id"], unique=False)
op.create_index(op.f("ix_repair_status_history_new_status"), "repair_status_history", ["new_status"], unique=False)
op.create_index(op.f("ix_repair_status_history_repair_id"), "repair_status_history", ["repair_id"], unique=False)
op.create_table(
"repair_intake_events",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=True),
sa.Column("external_source", sa.String(length=80), nullable=False),
sa.Column("external_reference", sa.String(length=160), nullable=True),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("error_message", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="SET NULL"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_intake_events_external_reference"), "repair_intake_events", ["external_reference"], unique=False)
op.create_index(op.f("ix_repair_intake_events_external_source"), "repair_intake_events", ["external_source"], unique=False)
op.create_index(op.f("ix_repair_intake_events_repair_id"), "repair_intake_events", ["repair_id"], unique=False)
op.create_index(op.f("ix_repair_intake_events_status"), "repair_intake_events", ["status"], unique=False)
op.create_table(
"repair_documents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("repair_id", sa.Integer(), nullable=False),
sa.Column("file_id", sa.Integer(), nullable=True),
sa.Column("storage_path", sa.String(length=500), nullable=True),
sa.Column("title", sa.String(length=255), nullable=False),
sa.Column("document_type", sa.String(length=80), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.ForeignKeyConstraint(["repair_id"], ["repairs.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_repair_documents_document_type"), "repair_documents", ["document_type"], unique=False)
op.create_index(op.f("ix_repair_documents_repair_id"), "repair_documents", ["repair_id"], unique=False)
for name, display_name, description, module in REPAIR_PERMISSIONS:
op.execute(
sa.text(
"""
insert into permissions (name, display_name, description, module)
values (:name, :display_name, :description, :module)
on conflict (name) do update set
display_name = excluded.display_name,
description = excluded.description,
module = excluded.module
"""
).bindparams(name=name, display_name=display_name, description=description, module=module)
)
for role_name, permissions in ROLE_PERMISSIONS.items():
for permission_name in permissions:
op.execute(
sa.text(
"""
insert into role_permissions (role_id, permission_id)
select roles.id, permissions.id
from roles, permissions
where roles.name = :role_name and permissions.name = :permission_name
on conflict do nothing
"""
).bindparams(role_name=role_name, permission_name=permission_name)
)
def downgrade() -> None:
for permission_name, *_ in REPAIR_PERMISSIONS:
op.execute(
sa.text(
"""
delete from role_permissions
using permissions
where role_permissions.permission_id = permissions.id
and permissions.name = :permission_name
"""
).bindparams(permission_name=permission_name)
)
op.execute(sa.text("delete from permissions where name = :name").bindparams(name=permission_name))
op.drop_index(op.f("ix_repair_documents_repair_id"), table_name="repair_documents")
op.drop_index(op.f("ix_repair_documents_document_type"), table_name="repair_documents")
op.drop_table("repair_documents")
op.drop_index(op.f("ix_repair_intake_events_status"), table_name="repair_intake_events")
op.drop_index(op.f("ix_repair_intake_events_repair_id"), table_name="repair_intake_events")
op.drop_index(op.f("ix_repair_intake_events_external_source"), table_name="repair_intake_events")
op.drop_index(op.f("ix_repair_intake_events_external_reference"), table_name="repair_intake_events")
op.drop_table("repair_intake_events")
op.drop_index(op.f("ix_repair_status_history_repair_id"), table_name="repair_status_history")
op.drop_index(op.f("ix_repair_status_history_new_status"), table_name="repair_status_history")
op.drop_index(op.f("ix_repair_status_history_actor_user_id"), table_name="repair_status_history")
op.drop_table("repair_status_history")
op.drop_index(op.f("ix_repairs_status"), table_name="repairs")
op.drop_index(op.f("ix_repairs_source_reference"), table_name="repairs")
op.drop_index(op.f("ix_repairs_source"), table_name="repairs")
op.drop_index(op.f("ix_repairs_repair_number"), table_name="repairs")
op.drop_index(op.f("ix_repairs_priority"), table_name="repairs")
op.drop_index(op.f("ix_repairs_device_model"), table_name="repairs")
op.drop_index(op.f("ix_repairs_device_manufacturer"), table_name="repairs")
op.drop_index(op.f("ix_repairs_customer_name"), table_name="repairs")
op.drop_index(op.f("ix_repairs_customer_id"), table_name="repairs")
op.drop_index(op.f("ix_repairs_contact_id"), table_name="repairs")
op.drop_table("repairs")

View file

@ -56,6 +56,8 @@ def can_read_activity(action: str, permissions: set[str]) -> bool:
return "roles.read" in permissions
if action.startswith("knowledge."):
return "knowledge.read" in permissions
if action.startswith("repairs."):
return "repairs.read" in permissions
if action.startswith("audit_logs."):
return "audit_logs.read" in permissions
if action.startswith("auth."):

View file

@ -9,6 +9,7 @@ from app.db.database import get_db
from app.models.rbac import Role
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.repositories.repair_repository import RepairRepository
from app.repositories.user_repository import UserRepository
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard
@ -30,6 +31,7 @@ def get_dashboard_summary(
latest_customers = []
users: list[MetricCard] = []
roles: list[MetricCard] = []
repairs: list[MetricCard] = []
if "customers.read" in permissions:
customers = [
@ -54,6 +56,14 @@ def get_dashboard_summary(
)
]
if "repairs.read" in permissions:
repairs = [
MetricCard(label="Neue Reparaturen", value=RepairRepository.count_by_status(db, "new")),
MetricCard(label="In Diagnose", value=RepairRepository.count_by_status(db, "diagnosis")),
MetricCard(label="Warten auf Kunde", value=RepairRepository.count_by_status(db, "waiting_for_customer")),
MetricCard(label="Abgeschlossen", value=RepairRepository.count_by_status(db, "completed")),
]
logger.info("dashboard.summary", extra={"actor_user_id": current_user.id})
return DashboardSummary(
@ -61,6 +71,7 @@ def get_dashboard_summary(
latest_customers=latest_customers,
users=users,
roles=roles,
repairs=repairs,
activities=EmptyWidget(
title="Letzte Aktivitäten",
message="Noch keine Aktivitäten vorhanden.",

View file

@ -0,0 +1,142 @@
from fastapi import APIRouter, Depends, Header, HTTPException, Query, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from typing import cast
from app.core.config import settings
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.repair import Repair
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import (
RepairCreate,
RepairIntakePayload,
RepairIntakeResponse,
RepairListResponse,
RepairResponse,
RepairStatus,
RepairStatusHistoryResponse,
RepairStatusUpdate,
RepairUpdate,
)
from app.services.repair_service import RepairService
router = APIRouter(tags=["Repairs"])
def get_repair_or_404(db: Session, repair_id: int) -> Repair:
repair = RepairRepository.get_by_id(db, repair_id)
if repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparatur nicht gefunden")
return repair
@router.get("/repairs", response_model=RepairListResponse)
@router.get("/repairs/", response_model=RepairListResponse, include_in_schema=False)
def get_repairs(
status_filter: str | None = Query(default=None, alias="status"),
q: str | None = None,
customer_id: int | None = None,
source: str | None = None,
priority: str | None = None,
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
items, total = RepairRepository.list(
db,
status=status_filter,
q=q,
customer_id=customer_id,
source=source,
priority=priority,
limit=limit,
offset=offset,
)
return RepairListResponse(items=items, total=total, limit=limit, offset=offset)
@router.get("/repairs/{repair_id}", response_model=RepairResponse)
def get_repair(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
return get_repair_or_404(db, repair_id)
@router.post("/repairs", response_model=RepairResponse, status_code=status.HTTP_201_CREATED)
@router.post("/repairs/", response_model=RepairResponse, status_code=status.HTTP_201_CREATED, include_in_schema=False)
def create_repair(
repair: RepairCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.create")),
):
return RepairService.create(db, repair, actor=current_user, request=request)
@router.put("/repairs/{repair_id}", response_model=RepairResponse)
def update_repair(
repair_id: int,
repair: RepairUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.update")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairService.update(db, db_repair, repair, actor=current_user, request=request)
@router.delete("/repairs/{repair_id}", response_model=RepairResponse)
def delete_repair(
repair_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.delete")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairService.cancel(db, db_repair, actor=current_user, request=request)
@router.put("/repairs/{repair_id}/status", response_model=RepairResponse)
def update_repair_status(
repair_id: int,
payload: RepairStatusUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.status.update")),
):
db_repair = get_repair_or_404(db, repair_id)
return RepairService.update_status(db, db_repair, payload, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/history", response_model=list[RepairStatusHistoryResponse])
def get_repair_history(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repairs.read")),
):
get_repair_or_404(db, repair_id)
return RepairRepository.get_history(db, repair_id)
@router.post("/public/repair-intake", response_model=RepairIntakeResponse, status_code=status.HTTP_201_CREATED)
def create_repair_intake(
payload: RepairIntakePayload,
request: Request,
x_olympus_intake_token: str | None = Header(default=None, alias="X-Olympus-Intake-Token"),
db: Session = Depends(get_db),
):
expected_token = settings.olympus_repair_intake_token
if not expected_token or not x_olympus_intake_token or x_olympus_intake_token != expected_token:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Nicht autorisiert")
repair = RepairService.create_from_intake(db, payload, request=request)
return RepairIntakeResponse(
repair_number=repair.repair_number,
status=cast(RepairStatus, repair.status),
message="Reparaturanfrage übernommen",
)

View file

@ -23,6 +23,7 @@ class Settings(BaseSettings):
storage_max_upload_mb: int = 50
knowledge_storage_path: str = "/data/knowledge"
knowledge_max_upload_mb: int = 50
olympus_repair_intake_token: str | None = None
model_config = SettingsConfigDict(
env_file=".env",

View file

@ -15,6 +15,7 @@ from app.api.customers import router as customers_router
from app.api.dashboard import router as dashboard_router
from app.api.knowledge import router as knowledge_router
from app.api.permissions import router as permissions_router
from app.api.repairs import router as repairs_router
from app.api.roles import router as roles_router
from app.api.users import router as users_router
from app.db.database import SessionLocal
@ -39,6 +40,7 @@ app.include_router(roles_router)
app.include_router(permissions_router)
app.include_router(customers_router)
app.include_router(knowledge_router)
app.include_router(repairs_router)
app.include_router(dashboard_router)
logger = logging.getLogger(__name__)

View file

@ -0,0 +1,81 @@
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, JSON, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class Repair(Base):
__tablename__ = "repairs"
id: Mapped[int] = mapped_column(primary_key=True)
repair_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
customer_id: Mapped[int | None] = mapped_column(ForeignKey("customers.id", ondelete="SET NULL"), nullable=True, index=True)
contact_id: Mapped[int | None] = mapped_column(ForeignKey("customer_contacts.id", ondelete="SET NULL"), nullable=True, index=True)
status: Mapped[str] = mapped_column(String(50), index=True)
priority: Mapped[str] = mapped_column(String(30), index=True)
source: Mapped[str] = mapped_column(String(50), index=True)
source_reference: Mapped[str | None] = mapped_column(String(120), nullable=True, index=True)
customer_name: Mapped[str] = mapped_column(String(255), index=True)
customer_email: Mapped[str] = mapped_column(String(255), default="", server_default="")
customer_phone: Mapped[str] = mapped_column(String(80), default="", server_default="")
device_manufacturer: Mapped[str] = mapped_column(String(160), index=True)
device_model: Mapped[str] = mapped_column(String(160), index=True)
device_serial_number: Mapped[str | None] = mapped_column(String(160), nullable=True)
device_type: Mapped[str] = mapped_column(String(120), default="", server_default="")
accessories: Mapped[str] = mapped_column(Text, default="", server_default="")
fault_description: Mapped[str] = mapped_column(Text)
previous_work: Mapped[str] = mapped_column(Text, default="", server_default="")
device_opened: Mapped[bool] = mapped_column(Boolean, default=False, server_default="false")
intake_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
diagnosis_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
repair_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
estimate_notes: Mapped[str] = mapped_column(Text, default="", server_default="")
estimated_cost_cents: Mapped[int | None] = mapped_column(Integer, nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
history: Mapped[list["RepairStatusHistory"]] = relationship(back_populates="repair", cascade="all, delete-orphan", lazy="selectin")
class RepairStatusHistory(Base):
__tablename__ = "repair_status_history"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
old_status: Mapped[str | None] = mapped_column(String(50), nullable=True)
new_status: Mapped[str] = mapped_column(String(50), index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
repair: Mapped[Repair] = relationship(back_populates="history")
class RepairIntakeEvent(Base):
__tablename__ = "repair_intake_events"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int | None] = mapped_column(ForeignKey("repairs.id", ondelete="SET NULL"), nullable=True, index=True)
external_source: Mapped[str] = mapped_column(String(80), index=True)
external_reference: Mapped[str | None] = mapped_column(String(160), nullable=True, index=True)
payload: Mapped[dict] = mapped_column(JSON)
status: Mapped[str] = mapped_column(String(50), index=True)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
processed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class RepairDocument(Base):
__tablename__ = "repair_documents"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
file_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
storage_path: Mapped[str | None] = mapped_column(String(500), nullable=True)
title: Mapped[str] = mapped_column(String(255))
document_type: Mapped[str] = mapped_column(String(80), index=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())

View file

@ -76,6 +76,13 @@ STANDARD_PERMISSIONS = [
("knowledge.delete", "Wissen löschen", "Wissensdatenbank-Einträge entfernen", "knowledge"),
("knowledge.upload", "Wissen hochladen", "Wissensdokumente hochladen", "knowledge"),
("knowledge.download", "Wissen herunterladen", "Wissensdokumente herunterladen", "knowledge"),
("repairs.read", "Reparaturen lesen", "Reparaturen anzeigen", "repairs"),
("repairs.create", "Reparaturen erstellen", "Reparaturen anlegen", "repairs"),
("repairs.update", "Reparaturen bearbeiten", "Reparaturen aktualisieren", "repairs"),
("repairs.delete", "Reparaturen löschen", "Reparaturen stornieren", "repairs"),
("repairs.status.update", "Reparaturstatus ändern", "Status von Reparaturen ändern", "repairs"),
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
]
ROLE_PERMISSION_NAMES = {
@ -88,6 +95,10 @@ ROLE_PERMISSION_NAMES = {
"knowledge.download",
"projects.read",
"tickets.read",
"repairs.read",
"repairs.create",
"repairs.update",
"repairs.status.update",
},
"sales": {
"dashboard.read",
@ -108,6 +119,9 @@ ROLE_PERMISSION_NAMES = {
"knowledge.update",
"knowledge.upload",
"knowledge.download",
"repairs.read",
"repairs.update",
"repairs.status.update",
},
"support": {
"dashboard.read",
@ -118,6 +132,11 @@ ROLE_PERMISSION_NAMES = {
"tickets.read",
"tickets.create",
"tickets.update",
"repairs.read",
"repairs.create",
"repairs.update",
"repairs.status.update",
"repairs.intake",
},
"warehouse": {
"dashboard.read",

View file

@ -0,0 +1,219 @@
from datetime import UTC, datetime
from sqlalchemy import Select, func, or_, select
from sqlalchemy.orm import Session, selectinload
from app.models.repair import Repair, RepairIntakeEvent, RepairStatusHistory
from app.schemas.repair import RepairCreate, RepairStatusUpdate, RepairUpdate
class RepairRepository:
@staticmethod
def query(
*,
status: str | None = None,
q: str | None = None,
customer_id: int | None = None,
source: str | None = None,
priority: str | None = None,
) -> Select[tuple[Repair]]:
query = select(Repair).options(selectinload(Repair.history))
if status:
query = query.where(Repair.status == status)
if customer_id is not None:
query = query.where(Repair.customer_id == customer_id)
if source:
query = query.where(Repair.source == source)
if priority:
query = query.where(Repair.priority == priority)
if q:
term = f"%{q.strip()}%"
query = query.where(
or_(
Repair.repair_number.ilike(term),
Repair.customer_name.ilike(term),
Repair.device_manufacturer.ilike(term),
Repair.device_model.ilike(term),
Repair.fault_description.ilike(term),
Repair.source_reference.ilike(term),
)
)
return query
@staticmethod
def list(
db: Session,
*,
status: str | None = None,
q: str | None = None,
customer_id: int | None = None,
source: str | None = None,
priority: str | None = None,
limit: int = 50,
offset: int = 0,
) -> tuple[list[Repair], int]:
base_query = RepairRepository.query(
status=status,
q=q,
customer_id=customer_id,
source=source,
priority=priority,
)
total = db.scalar(select(func.count()).select_from(base_query.subquery())) or 0
items = list(
db.scalars(
base_query
.order_by(Repair.created_at.desc(), Repair.id.desc())
.offset(offset)
.limit(limit)
)
)
return items, total
@staticmethod
def get_by_id(db: Session, repair_id: int) -> Repair | None:
return db.scalar(select(Repair).options(selectinload(Repair.history)).where(Repair.id == repair_id))
@staticmethod
def get_next_number(db: Session, year: int) -> str:
prefix = f"R{year}-"
latest = db.scalar(
select(Repair.repair_number)
.where(Repair.repair_number.like(f"{prefix}%"))
.order_by(Repair.repair_number.desc())
.limit(1)
)
next_number = 1
if latest:
next_number = int(latest.split("-")[-1]) + 1
return f"{prefix}{next_number:06d}"
@staticmethod
def create(db: Session, payload: RepairCreate, *, repair_number: str) -> Repair:
repair = Repair(repair_number=repair_number, **RepairRepository._payload_data(payload))
db.add(repair)
db.flush()
RepairRepository.add_history(db, repair.id, old_status=None, new_status=repair.status, note="Reparatur angelegt", actor_user_id=None)
db.commit()
db.refresh(repair)
return RepairRepository.get_by_id(db, repair.id) or repair
@staticmethod
def update(db: Session, repair: Repair, payload: RepairUpdate) -> Repair:
for key, value in RepairRepository._payload_data(payload).items():
setattr(repair, key, value)
RepairRepository._apply_status_timestamps(repair)
db.commit()
db.refresh(repair)
return RepairRepository.get_by_id(db, repair.id) or repair
@staticmethod
def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor_user_id: int | None) -> Repair:
old_status = repair.status
repair.status = payload.status
RepairRepository._apply_status_timestamps(repair)
RepairRepository.add_history(
db,
repair.id,
old_status=old_status,
new_status=payload.status,
note=payload.note,
actor_user_id=actor_user_id,
)
db.commit()
db.refresh(repair)
return RepairRepository.get_by_id(db, repair.id) or repair
@staticmethod
def cancel(db: Session, repair: Repair, *, actor_user_id: int | None) -> Repair:
old_status = repair.status
repair.status = "cancelled"
repair.completed_at = datetime.now(UTC)
RepairRepository.add_history(
db,
repair.id,
old_status=old_status,
new_status="cancelled",
note="Reparatur storniert",
actor_user_id=actor_user_id,
)
db.commit()
db.refresh(repair)
return repair
@staticmethod
def add_history(db: Session, repair_id: int, *, old_status: str | None, new_status: str, note: str | None, actor_user_id: int | None) -> RepairStatusHistory:
history = RepairStatusHistory(
repair_id=repair_id,
old_status=old_status,
new_status=new_status,
note=note,
actor_user_id=actor_user_id,
)
db.add(history)
return history
@staticmethod
def get_history(db: Session, repair_id: int) -> list[RepairStatusHistory]:
return list(
db.scalars(
select(RepairStatusHistory)
.where(RepairStatusHistory.repair_id == repair_id)
.order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc())
)
)
@staticmethod
def count_by_status(db: Session, status: str) -> int:
return db.scalar(select(func.count(Repair.id)).where(Repair.status == status)) or 0
@staticmethod
def create_intake_event(
db: Session,
*,
external_source: str,
external_reference: str | None,
payload: dict,
status: str,
error_message: str | None = None,
repair_id: int | None = None,
processed_at: datetime | None = None,
) -> RepairIntakeEvent:
event = RepairIntakeEvent(
external_source=external_source,
external_reference=external_reference,
payload=payload,
status=status,
error_message=error_message,
repair_id=repair_id,
processed_at=processed_at,
)
db.add(event)
db.commit()
db.refresh(event)
return event
@staticmethod
def update_intake_event(db: Session, event: RepairIntakeEvent, *, status: str, repair_id: int | None = None, error_message: str | None = None) -> RepairIntakeEvent:
event.status = status
event.repair_id = repair_id
event.error_message = error_message
event.processed_at = datetime.now(UTC)
db.commit()
db.refresh(event)
return event
@staticmethod
def _payload_data(payload: RepairCreate | RepairUpdate) -> dict:
data = payload.model_dump()
data["customer_email"] = str(payload.customer_email or "")
data["device_serial_number"] = payload.device_serial_number or None
data["source_reference"] = payload.source_reference or None
return data
@staticmethod
def _apply_status_timestamps(repair: Repair) -> None:
if repair.status == "approved" and repair.approved_at is None:
repair.approved_at = datetime.now(UTC)
if repair.status in {"completed", "cancelled"} and repair.completed_at is None:
repair.completed_at = datetime.now(UTC)

View file

@ -18,6 +18,7 @@ class DashboardSummary(BaseModel):
latest_customers: list[CustomerResponse] = []
users: list[MetricCard] = []
roles: list[MetricCard] = []
repairs: list[MetricCard] = []
activities: EmptyWidget
tasks: EmptyWidget
tickets: EmptyWidget

View file

@ -0,0 +1,162 @@
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
RepairStatus = Literal[
"new",
"accepted",
"diagnosis",
"estimate",
"waiting_for_customer",
"approved",
"repair",
"final_test",
"ready_for_pickup",
"shipped",
"completed",
"cancelled",
]
RepairPriority = Literal["low", "normal", "high", "urgent"]
RepairSource = Literal["manual", "website", "customer_portal", "email", "phone"]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
class RepairBase(BaseModel):
customer_id: int | None = None
contact_id: int | None = None
status: RepairStatus = "new"
priority: RepairPriority = "normal"
source: RepairSource = "manual"
source_reference: str | None = Field(default=None, max_length=120)
customer_name: str = Field(default="", max_length=255)
customer_email: EmailStr | None = None
customer_phone: str = Field(default="", max_length=80)
device_manufacturer: str = Field(min_length=1, max_length=160)
device_model: str = Field(min_length=1, max_length=160)
device_serial_number: str | None = Field(default=None, max_length=160)
device_type: str = Field(default="", max_length=120)
accessories: str = ""
fault_description: str = Field(min_length=1)
previous_work: str = ""
device_opened: bool = False
intake_notes: str = ""
diagnosis_notes: str = ""
repair_notes: str = ""
estimate_notes: str = ""
estimated_cost_cents: int | None = Field(default=None, ge=0)
@field_validator(
"source_reference",
"customer_name",
"customer_phone",
"device_manufacturer",
"device_model",
"device_serial_number",
"device_type",
"accessories",
"fault_description",
"previous_work",
"intake_notes",
"diagnosis_notes",
"repair_notes",
"estimate_notes",
mode="before",
)
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
@model_validator(mode="after")
def validate_customer_identity(self):
if self.customer_id is None and not self.customer_name:
raise ValueError("Kundenname ist erforderlich, wenn kein Kunde ausgewählt ist")
return self
class RepairCreate(RepairBase):
pass
class RepairUpdate(RepairBase):
pass
class RepairStatusUpdate(BaseModel):
status: RepairStatus
note: str | None = None
@field_validator("note", mode="before")
@classmethod
def normalize_note(cls, value: object) -> str | None:
if value is None:
return None
text = normalize_text(value)
return text or None
class RepairResponse(RepairBase):
id: int
repair_number: str
customer_email: str
device_serial_number: str | None
approved_at: datetime | None
completed_at: datetime | None
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairListResponse(BaseModel):
items: list[RepairResponse]
total: int
limit: int
offset: int
class RepairStatusHistoryResponse(BaseModel):
id: int
repair_id: int
old_status: str | None
new_status: str
note: str | None
actor_user_id: int | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairIntakePayload(BaseModel):
name: str = Field(min_length=1, max_length=255)
email: EmailStr | None = None
phone: str = Field(default="", max_length=80)
manufacturer: str = Field(min_length=1, max_length=160)
model: str = Field(min_length=1, max_length=160)
serial_number: str | None = Field(default=None, max_length=160)
device_type: str = Field(default="", max_length=120)
accessories: str = ""
fault_description: str = Field(min_length=1)
previous_work: str = ""
device_opened: bool = False
source_reference: str | None = Field(default=None, max_length=120)
@field_validator("*", mode="before")
@classmethod
def normalize_payload(cls, value: object) -> object:
if isinstance(value, str):
return value.strip()
return value
class RepairIntakeResponse(BaseModel):
repair_number: str
status: RepairStatus
message: str

View file

@ -157,6 +157,12 @@ def action_title(action: str) -> str:
"knowledge.notes.create": "Wissensnotiz erstellt",
"knowledge.notes.update": "Wissensnotiz bearbeitet",
"knowledge.notes.delete": "Wissensnotiz gelöscht",
"repairs.create": "Reparatur erstellt",
"repairs.update": "Reparatur bearbeitet",
"repairs.status_update": "Reparaturstatus geändert",
"repairs.cancel": "Reparatur storniert",
"repairs.intake_processed": "Reparatur-Intake verarbeitet",
"repairs.intake_failed": "Reparatur-Intake fehlgeschlagen",
}
return labels.get(action, action)

View file

@ -0,0 +1,157 @@
from datetime import UTC, datetime
from fastapi import HTTPException, status
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.models.repair import Repair
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairCreate, RepairIntakePayload, RepairStatusUpdate, RepairUpdate
from app.services.audit_service import sanitize, write_audit_log
def repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
class RepairService:
@staticmethod
def create(db: Session, payload: RepairCreate, *, actor: User | None, request: Request | None = None) -> Repair:
RepairService._validate_customer(db, payload.customer_id)
repair_number = RepairRepository.get_next_number(db, datetime.now(UTC).year)
try:
repair = RepairRepository.create(db, payload, repair_number=repair_number)
except IntegrityError:
db.rollback()
repair_number = RepairRepository.get_next_number(db, datetime.now(UTC).year)
repair = RepairRepository.create(db, payload, repair_number=repair_number)
write_audit_log(
db,
action="repairs.create",
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
actor=actor,
request=request,
after_data=repair,
)
return repair
@staticmethod
def update(db: Session, repair: Repair, payload: RepairUpdate, *, actor: User, request: Request) -> Repair:
RepairService._validate_customer(db, payload.customer_id)
before_data = sanitize(repair)
updated = RepairRepository.update(db, repair, payload)
write_audit_log(
db,
action="repairs.update",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=updated,
)
return updated
@staticmethod
def update_status(db: Session, repair: Repair, payload: RepairStatusUpdate, *, actor: User, request: Request) -> Repair:
before_data = sanitize(repair)
updated = RepairRepository.update_status(db, repair, payload, actor_user_id=actor.id)
write_audit_log(
db,
action="repairs.status_update",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=updated,
metadata={"old_status": before_data.get("status") if isinstance(before_data, dict) else None, "new_status": updated.status},
)
return updated
@staticmethod
def cancel(db: Session, repair: Repair, *, actor: User, request: Request) -> Repair:
before_data = sanitize(repair)
updated = RepairRepository.cancel(db, repair, actor_user_id=actor.id)
write_audit_log(
db,
action="repairs.cancel",
entity_type="repairs",
entity_id=updated.id,
entity_label=repair_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=updated,
)
return updated
@staticmethod
def create_from_intake(db: Session, payload: RepairIntakePayload, *, request: Request | None = None) -> Repair:
event = RepairRepository.create_intake_event(
db,
external_source="website",
external_reference=payload.source_reference,
payload=payload.model_dump(mode="json"),
status="received",
)
try:
repair_payload = RepairCreate(
customer_id=None,
contact_id=None,
status="new",
priority="normal",
source="website",
source_reference=payload.source_reference,
customer_name=payload.name,
customer_email=payload.email,
customer_phone=payload.phone,
device_manufacturer=payload.manufacturer,
device_model=payload.model,
device_serial_number=payload.serial_number,
device_type=payload.device_type,
accessories=payload.accessories,
fault_description=payload.fault_description,
previous_work=payload.previous_work,
device_opened=payload.device_opened,
intake_notes="Website Intake",
)
repair = RepairService.create(db, repair_payload, actor=None, request=request)
RepairRepository.update_intake_event(db, event, status="processed", repair_id=repair.id)
write_audit_log(
db,
action="repairs.intake_processed",
entity_type="repairs",
entity_id=repair.id,
entity_label=repair_label(repair),
request=request,
metadata={"external_source": "website", "external_reference": payload.source_reference},
)
return repair
except Exception as exc:
RepairRepository.update_intake_event(db, event, status="failed", error_message="Intake konnte nicht verarbeitet werden")
write_audit_log(
db,
action="repairs.intake_failed",
entity_type="repairs",
request=request,
metadata={"external_source": "website", "external_reference": payload.source_reference},
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Reparaturannahme konnte nicht verarbeitet werden",
) from exc
@staticmethod
def _validate_customer(db: Session, customer_id: int | None) -> None:
if customer_id is None:
return
if CustomerRepository.get_by_id(db, customer_id) is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kunde nicht gefunden")