feat(repairs): add repair management foundation
This commit is contained in:
parent
22e54f212c
commit
e9ec207617
37 changed files with 2149 additions and 7 deletions
|
|
@ -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."):
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
142
backend/hermes/app/api/repairs.py
Normal file
142
backend/hermes/app/api/repairs.py
Normal 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",
|
||||
)
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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__)
|
||||
|
|
|
|||
81
backend/hermes/app/models/repair.py
Normal file
81
backend/hermes/app/models/repair.py
Normal 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())
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
219
backend/hermes/app/repositories/repair_repository.py
Normal file
219
backend/hermes/app/repositories/repair_repository.py
Normal 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)
|
||||
|
|
@ -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
|
||||
|
|
|
|||
162
backend/hermes/app/schemas/repair.py
Normal file
162
backend/hermes/app/schemas/repair.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
157
backend/hermes/app/services/repair_service.py
Normal file
157
backend/hermes/app/services/repair_service.py
Normal 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")
|
||||
Loading…
Add table
Add a link
Reference in a new issue