feat(repairs): add repair management foundation
This commit is contained in:
parent
22e54f212c
commit
e9ec207617
37 changed files with 2149 additions and 7 deletions
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
204
backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py
Normal file
204
backend/hermes/alembic/versions/c8e2f1a9b704_create_repairs.py
Normal 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")
|
||||
|
|
@ -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