feat(repairs): add repair estimates

This commit is contained in:
Schubert Ferenc 2026-07-05 00:57:02 +02:00
parent 6e7e75f864
commit 4436fe5f73
25 changed files with 1802 additions and 9 deletions

View file

@ -11,6 +11,7 @@ from app.models.audit import AuditLog
from app.models.user import User
from app.repositories.customer_repository import CustomerRepository
from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.user_repository import UserRepository
from app.schemas.dashboard import DashboardSummary, EmptyWidget, MetricCard, SystemStatusItem
from app.services.system_settings_service import SystemSettingsService
@ -73,6 +74,14 @@ def get_dashboard_summary(
MetricCard(label="Reparaturdokumente", value=RepairRepository.count_documents(db)),
]
if "repair_estimates.read" in permissions:
repairs.extend([
MetricCard(label="Offene KVs", value=RepairEstimateRepository.count_open(db)),
MetricCard(label="Warten auf Freigabe", value=RepairEstimateRepository.count_waiting(db)),
MetricCard(label="KVs freigegeben heute", value=RepairEstimateRepository.count_approved_today(db)),
MetricCard(label="KVs abgelehnt", value=RepairEstimateRepository.count_declined(db)),
])
if "system_settings.manage" in permissions:
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
public_links = SystemSettingsService.get_public_links_settings(db)

View file

@ -0,0 +1,181 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from starlette.requests import Request
from app.core.rbac import require_permission
from app.db.database import get_db
from app.models.repair import Repair
from app.models.repair_estimate import RepairEstimate
from app.models.user import User
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair_estimate import (
PublicEstimateDecisionRequest,
PublicEstimateResponse,
RepairEstimateCreate,
RepairEstimateEventResponse,
RepairEstimateResponse,
RepairEstimateUpdate,
)
from app.services.repair_estimate_service import RepairEstimateService
from app.services.repair_public_link_service import RepairPublicLinkService
router = APIRouter(tags=["Repair Estimates"])
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
def get_estimate_or_404(db: Session, repair_id: int, estimate_id: int) -> RepairEstimate:
estimate = RepairEstimateRepository.get(db, repair_id=repair_id, estimate_id=estimate_id)
if estimate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
return estimate
@router.get("/repairs/{repair_id}/estimates", response_model=list[RepairEstimateResponse])
def list_estimates(
repair_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.read")),
):
get_repair_or_404(db, repair_id)
return RepairEstimateRepository.list_by_repair(db, repair_id)
@router.post("/repairs/{repair_id}/estimates", response_model=RepairEstimateResponse, status_code=status.HTTP_201_CREATED)
def create_estimate(
repair_id: int,
payload: RepairEstimateCreate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.create")),
):
repair = get_repair_or_404(db, repair_id)
return RepairEstimateService.create(db, repair, payload, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse)
def get_estimate(
repair_id: int,
estimate_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.read")),
):
get_repair_or_404(db, repair_id)
return get_estimate_or_404(db, repair_id, estimate_id)
@router.put("/repairs/{repair_id}/estimates/{estimate_id}", response_model=RepairEstimateResponse)
def update_estimate(
repair_id: int,
estimate_id: int,
payload: RepairEstimateUpdate,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.update")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return RepairEstimateService.update(db, repair, estimate, payload, actor=current_user, request=request)
@router.delete("/repairs/{repair_id}/estimates/{estimate_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_estimate(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.delete")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
RepairEstimateService.delete(db, repair, estimate, actor=current_user, request=request)
return None
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/send", response_model=RepairEstimateResponse)
def send_estimate(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.send")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return RepairEstimateService.send(db, repair, estimate, actor=current_user, request=request)
@router.post("/repairs/{repair_id}/estimates/{estimate_id}/cancel", response_model=RepairEstimateResponse)
def cancel_estimate(
repair_id: int,
estimate_id: int,
request: Request,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.update")),
):
repair = get_repair_or_404(db, repair_id)
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return RepairEstimateService.cancel(db, repair, estimate, actor=current_user, request=request)
@router.get("/repairs/{repair_id}/estimates/{estimate_id}/events", response_model=list[RepairEstimateEventResponse])
def list_estimate_events(
repair_id: int,
estimate_id: int,
db: Session = Depends(get_db),
current_user: User = Depends(require_permission("repair_estimates.read")),
):
estimate = get_estimate_or_404(db, repair_id, estimate_id)
return estimate.events
def get_public_repair_and_estimate(db: Session, token: str) -> tuple[Repair, RepairEstimate]:
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
repair = public_link.repair
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
if estimate is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Kostenvoranschlag nicht gefunden")
RepairRepository.mark_public_link_used(db, public_link)
return repair, estimate
@router.post("/public/repairs/status/{token}/estimate/approve", response_model=PublicEstimateResponse)
def approve_public_estimate(
token: str,
payload: PublicEstimateDecisionRequest,
request: Request,
db: Session = Depends(get_db),
):
repair, estimate = get_public_repair_and_estimate(db, token)
updated = RepairEstimateService.customer_decision(db, repair, estimate, "approve", payload, request=request)
return RepairEstimateService.public_response(updated)
@router.post("/public/repairs/status/{token}/estimate/decline", response_model=PublicEstimateResponse)
def decline_public_estimate(
token: str,
payload: PublicEstimateDecisionRequest,
request: Request,
db: Session = Depends(get_db),
):
repair, estimate = get_public_repair_and_estimate(db, token)
updated = RepairEstimateService.customer_decision(db, repair, estimate, "decline", payload, request=request)
return RepairEstimateService.public_response(updated)
@router.post("/public/repairs/status/{token}/estimate/question", response_model=PublicEstimateResponse)
def question_public_estimate(
token: str,
payload: PublicEstimateDecisionRequest,
request: Request,
db: Session = Depends(get_db),
):
repair, estimate = get_public_repair_and_estimate(db, token)
updated = RepairEstimateService.customer_decision(db, repair, estimate, "question", payload, request=request)
return RepairEstimateService.public_response(updated)

View file

@ -26,6 +26,7 @@ import app.models.knowledge
import app.models.audit
import app.models.user
import app.models.repair
import app.models.repair_estimate
import app.models.system_setting

View file

@ -16,6 +16,7 @@ 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.repair_estimates import router as repair_estimates_router
from app.api.roles import router as roles_router
from app.api.system_settings import router as system_settings_router
from app.api.users import router as users_router
@ -42,6 +43,7 @@ app.include_router(permissions_router)
app.include_router(customers_router)
app.include_router(knowledge_router)
app.include_router(repairs_router)
app.include_router(repair_estimates_router)
app.include_router(dashboard_router)
app.include_router(system_settings_router)

View file

@ -0,0 +1,81 @@
from datetime import date, datetime
from decimal import Decimal
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, func
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db.database import Base
class RepairEstimate(Base):
__tablename__ = "repair_estimates"
id: Mapped[int] = mapped_column(primary_key=True)
repair_id: Mapped[int] = mapped_column(ForeignKey("repairs.id", ondelete="CASCADE"), index=True)
estimate_number: Mapped[str] = mapped_column(String(20), unique=True, index=True)
status: Mapped[str] = mapped_column(String(40), default="draft", server_default="draft", index=True)
title: Mapped[str] = mapped_column(String(255))
customer_message: Mapped[str] = mapped_column(Text, default="", server_default="")
internal_note: Mapped[str | None] = mapped_column(Text, nullable=True)
subtotal_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
tax_rate_percent: Mapped[Decimal] = mapped_column(Numeric(5, 2), default=Decimal("19.00"), server_default="19.00")
tax_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
currency: Mapped[str] = mapped_column(String(3), default="EUR", server_default="EUR")
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
sent_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
approved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
declined_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
customer_response_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_by_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())
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
repair = relationship("Repair", lazy="joined")
created_by = relationship("User", lazy="joined")
items: Mapped[list["RepairEstimateItem"]] = relationship(
back_populates="estimate",
cascade="all, delete-orphan",
order_by="RepairEstimateItem.position",
lazy="selectin",
)
events: Mapped[list["RepairEstimateEvent"]] = relationship(
back_populates="estimate",
cascade="all, delete-orphan",
order_by="RepairEstimateEvent.created_at",
lazy="selectin",
)
class RepairEstimateItem(Base):
__tablename__ = "repair_estimate_items"
id: Mapped[int] = mapped_column(primary_key=True)
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
position: Mapped[int] = mapped_column(Integer)
item_type: Mapped[str] = mapped_column(String(40), index=True)
title: Mapped[str] = mapped_column(String(255))
description: Mapped[str | None] = mapped_column(Text, nullable=True)
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 2), default=Decimal("1.00"), server_default="1.00")
unit: Mapped[str] = mapped_column(String(40), default="Stk.", server_default="Stk.")
unit_price_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
total_cents: Mapped[int] = mapped_column(Integer, default=0, server_default="0")
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())
estimate: Mapped[RepairEstimate] = relationship(back_populates="items")
class RepairEstimateEvent(Base):
__tablename__ = "repair_estimate_events"
id: Mapped[int] = mapped_column(primary_key=True)
estimate_id: Mapped[int] = mapped_column(ForeignKey("repair_estimates.id", ondelete="CASCADE"), index=True)
event_type: Mapped[str] = mapped_column(String(40), index=True)
actor_type: Mapped[str] = mapped_column(String(40), index=True)
actor_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True)
note: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
estimate: Mapped[RepairEstimate] = relationship(back_populates="events")
actor = relationship("User", lazy="joined")

View file

@ -85,6 +85,11 @@ STANDARD_PERMISSIONS = [
("repairs.intake", "Reparatur-Intake", "Website-Reparaturanfragen übernehmen", "repairs"),
("repairs.assign", "Reparaturen zuweisen", "Reparaturen Benutzern zuweisen", "repairs"),
("repairs.public_link.manage", "Reparatur-Statuslinks verwalten", "Öffentliche Statuslinks für Reparaturen verwalten", "repairs"),
("repair_estimates.read", "Kostenvoranschläge lesen", "Kostenvoranschläge anzeigen", "repair_estimates"),
("repair_estimates.create", "Kostenvoranschläge erstellen", "Kostenvoranschläge anlegen", "repair_estimates"),
("repair_estimates.update", "Kostenvoranschläge bearbeiten", "Kostenvoranschläge aktualisieren", "repair_estimates"),
("repair_estimates.delete", "Kostenvoranschläge löschen", "Kostenvoranschläge entfernen", "repair_estimates"),
("repair_estimates.send", "Kostenvoranschläge senden", "Kostenvoranschläge an Kunden senden", "repair_estimates"),
]
ROLE_PERMISSION_NAMES = {
@ -102,6 +107,10 @@ ROLE_PERMISSION_NAMES = {
"repairs.update",
"repairs.status.update",
"repairs.public_link.manage",
"repair_estimates.read",
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
},
"sales": {
"dashboard.read",
@ -125,6 +134,10 @@ ROLE_PERMISSION_NAMES = {
"repairs.read",
"repairs.update",
"repairs.status.update",
"repair_estimates.read",
"repair_estimates.create",
"repair_estimates.update",
"repair_estimates.send",
},
"support": {
"dashboard.read",
@ -141,6 +154,8 @@ ROLE_PERMISSION_NAMES = {
"repairs.status.update",
"repairs.intake",
"repairs.public_link.manage",
"repair_estimates.read",
"repair_estimates.send",
},
"warehouse": {
"dashboard.read",

View file

@ -0,0 +1,125 @@
from datetime import UTC, datetime
from sqlalchemy import func, select
from sqlalchemy.orm import Session, selectinload
from app.models.repair_estimate import RepairEstimate, RepairEstimateEvent, RepairEstimateItem
class RepairEstimateRepository:
@staticmethod
def list_by_repair(db: Session, repair_id: int) -> list[RepairEstimate]:
return list(
db.scalars(
select(RepairEstimate)
.options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events))
.where(RepairEstimate.repair_id == repair_id)
.order_by(RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
)
)
@staticmethod
def get(db: Session, *, repair_id: int, estimate_id: int) -> RepairEstimate | None:
return db.scalar(
select(RepairEstimate)
.options(selectinload(RepairEstimate.items), selectinload(RepairEstimate.events))
.where(RepairEstimate.repair_id == repair_id)
.where(RepairEstimate.id == estimate_id)
)
@staticmethod
def get_latest_public(db: Session, repair_id: int) -> RepairEstimate | None:
return db.scalar(
select(RepairEstimate)
.options(selectinload(RepairEstimate.items))
.where(RepairEstimate.repair_id == repair_id)
.where(RepairEstimate.status.in_(["sent", "approved", "declined"]))
.order_by(RepairEstimate.sent_at.desc().nullslast(), RepairEstimate.created_at.desc(), RepairEstimate.id.desc())
.limit(1)
)
@staticmethod
def get_next_number(db: Session, year: int) -> str:
prefix = f"KV{year}-"
latest = db.scalar(
select(RepairEstimate.estimate_number)
.where(RepairEstimate.estimate_number.like(f"{prefix}%"))
.order_by(RepairEstimate.estimate_number.desc())
.limit(1)
)
next_number = 1
if latest:
next_number = int(latest.split("-")[-1]) + 1
return f"{prefix}{next_number:06d}"
@staticmethod
def save(db: Session, estimate: RepairEstimate) -> RepairEstimate:
db.add(estimate)
db.commit()
db.refresh(estimate)
return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate
@staticmethod
def replace_items(db: Session, estimate: RepairEstimate, items: list[RepairEstimateItem]) -> None:
estimate.items.clear()
db.flush()
for item in items:
estimate.items.append(item)
@staticmethod
def delete(db: Session, estimate: RepairEstimate) -> None:
db.delete(estimate)
db.commit()
@staticmethod
def add_event(
db: Session,
*,
estimate_id: int,
event_type: str,
actor_type: str,
actor_user_id: int | None = None,
note: str | None = None,
commit: bool = True,
) -> RepairEstimateEvent:
event = RepairEstimateEvent(
estimate_id=estimate_id,
event_type=event_type,
actor_type=actor_type,
actor_user_id=actor_user_id,
note=note,
)
db.add(event)
if commit:
db.commit()
db.refresh(event)
return event
@staticmethod
def mark_sent(db: Session, estimate: RepairEstimate) -> RepairEstimate:
estimate.status = "sent"
estimate.sent_at = datetime.now(UTC)
db.commit()
db.refresh(estimate)
return RepairEstimateRepository.get(db, repair_id=estimate.repair_id, estimate_id=estimate.id) or estimate
@staticmethod
def count_open(db: Session) -> int:
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status.in_(["draft", "sent"]))) or 0
@staticmethod
def count_waiting(db: Session) -> int:
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "sent")) or 0
@staticmethod
def count_approved_today(db: Session) -> int:
today = datetime.now(UTC).date()
return db.scalar(
select(func.count(RepairEstimate.id))
.where(RepairEstimate.status == "approved")
.where(func.date(RepairEstimate.approved_at) == today)
) or 0
@staticmethod
def count_declined(db: Session) -> int:
return db.scalar(select(func.count(RepairEstimate.id)).where(RepairEstimate.status == "declined")) or 0

View file

@ -3,6 +3,8 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, EmailStr, Field, field_validator, model_validator
from app.schemas.repair_estimate import PublicEstimateResponse
RepairStatus = Literal[
"new",
"accepted",
@ -202,6 +204,7 @@ class RepairPublicStatusResponse(BaseModel):
device_model: str
status_history_public: list[RepairPublicStatusHistoryItem]
updated_at: datetime
estimate: PublicEstimateResponse | None = None
class RepairNotificationTemplateResponse(BaseModel):

View file

@ -0,0 +1,168 @@
from datetime import date, datetime
from decimal import Decimal, ROUND_HALF_UP
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
EstimateStatus = Literal["draft", "sent", "approved", "declined", "expired", "cancelled"]
EstimateItemType = Literal["labor", "part", "flat_rate", "shipping", "other"]
EstimateActorType = Literal["user", "customer", "system"]
EstimateEventType = Literal["created", "updated", "sent", "approved", "declined", "cancelled", "expired", "reminder_sent", "question"]
def normalize_text(value: object) -> str:
if value is None:
return ""
return str(value).strip()
class RepairEstimateItemPayload(BaseModel):
item_type: EstimateItemType = "other"
title: str = Field(min_length=1, max_length=255)
description: str | None = None
quantity: Decimal = Field(gt=Decimal("0"))
unit: str = Field(default="Stk.", max_length=40)
unit_price_cents: int = Field(ge=0)
@field_validator("title", "description", "unit", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
class RepairEstimatePayload(BaseModel):
title: str = Field(min_length=1, max_length=255)
customer_message: str = ""
internal_note: str | None = None
tax_rate_percent: Decimal = Field(default=Decimal("19.00"), ge=Decimal("0"))
currency: str = Field(default="EUR", max_length=3)
valid_until: date | None = None
items: list[RepairEstimateItemPayload]
@field_validator("title", "customer_message", "internal_note", "currency", mode="before")
@classmethod
def normalize_strings(cls, value: object) -> str | None:
if value is None:
return None
return normalize_text(value)
@field_validator("currency")
@classmethod
def normalize_currency(cls, value: str) -> str:
return value.upper() or "EUR"
@model_validator(mode="after")
def validate_items(self):
if not self.items:
raise ValueError("Mindestens eine Position ist erforderlich")
return self
class RepairEstimateCreate(RepairEstimatePayload):
pass
class RepairEstimateUpdate(RepairEstimatePayload):
pass
class RepairEstimateItemResponse(BaseModel):
id: int
estimate_id: int
position: int
item_type: EstimateItemType
title: str
description: str | None
quantity: Decimal
unit: str
unit_price_cents: int
total_cents: int
created_at: datetime
updated_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairEstimateEventResponse(BaseModel):
id: int
estimate_id: int
event_type: str
actor_type: str
actor_user_id: int | None
note: str | None
created_at: datetime
model_config = ConfigDict(from_attributes=True)
class RepairEstimateResponse(BaseModel):
id: int
repair_id: int
estimate_number: str
status: EstimateStatus
title: str
customer_message: str
internal_note: str | None
subtotal_cents: int
tax_rate_percent: Decimal
tax_cents: int
total_cents: int
currency: str
valid_until: date | None
sent_at: datetime | None
approved_at: datetime | None
declined_at: datetime | None
customer_response_message: str | None
created_by_user_id: int | None
created_at: datetime
updated_at: datetime
items: list[RepairEstimateItemResponse]
model_config = ConfigDict(from_attributes=True)
class PublicEstimateItemResponse(BaseModel):
item_type: EstimateItemType
title: str
description: str | None
quantity: Decimal
unit: str
unit_price_cents: int
total_cents: int
class PublicEstimateResponse(BaseModel):
estimate_number: str
status: EstimateStatus
title: str
customer_message: str
subtotal_cents: int
tax_cents: int
total_cents: int
currency: str
valid_until: date | None
items: list[PublicEstimateItemResponse]
class PublicEstimateDecisionRequest(BaseModel):
message: str | None = Field(default=None, max_length=2000)
@field_validator("message", mode="before")
@classmethod
def normalize_message(cls, value: object) -> str | None:
if value is None:
return None
text = normalize_text(value)
return text or None
def calculate_item_total(quantity: Decimal, unit_price_cents: int) -> int:
total = (quantity * Decimal(unit_price_cents)).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return int(total)
def calculate_tax(subtotal_cents: int, tax_rate_percent: Decimal) -> int:
tax = (Decimal(subtotal_cents) * tax_rate_percent / Decimal("100")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
return int(tax)

View file

@ -172,6 +172,14 @@ def action_title(action: str) -> str:
"repairs.documents.upload": "Reparaturdokument hochgeladen",
"repairs.documents.update": "Reparaturdokument geändert",
"repairs.documents.delete": "Reparaturdokument gelöscht",
"repair_estimates.create": "Kostenvoranschlag erstellt",
"repair_estimates.update": "Kostenvoranschlag geändert",
"repair_estimates.send": "Kostenvoranschlag gesendet",
"repair_estimates.approve": "Kostenvoranschlag freigegeben",
"repair_estimates.decline": "Kostenvoranschlag abgelehnt",
"repair_estimates.question": "Rückfrage zum Kostenvoranschlag",
"repair_estimates.cancel": "Kostenvoranschlag storniert",
"repair_estimates.delete": "Kostenvoranschlag gelöscht",
"system_settings.smtp.update": "SMTP-Konfiguration geändert",
"system_settings.smtp.test_sent": "SMTP-Testmail versendet",
"system_settings.smtp.test_failed": "SMTP-Testmail fehlgeschlagen",

View file

@ -0,0 +1,357 @@
from datetime import UTC, datetime
from decimal import Decimal
from html import escape
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.repair_estimate import RepairEstimate, RepairEstimateItem
from app.models.user import User
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.repositories.repair_repository import RepairRepository
from app.schemas.repair import RepairStatusUpdate
from app.schemas.repair_estimate import (
PublicEstimateDecisionRequest,
PublicEstimateItemResponse,
PublicEstimateResponse,
RepairEstimateCreate,
RepairEstimateItemPayload,
RepairEstimateUpdate,
calculate_item_total,
calculate_tax,
)
from app.services.audit_service import write_audit_log
from app.services.repair_public_link_service import RepairPublicLinkService
from app.services.system_settings_service import SystemSettingsService
def _repair_label(repair: Repair) -> str:
return f"{repair.repair_number} · {repair.customer_name}"
def _estimate_label(estimate: RepairEstimate) -> str:
return f"{estimate.estimate_number} · {estimate.title}"
def _money(cents: int, currency: str = "EUR") -> str:
return f"{cents / 100:,.2f} {currency}".replace(",", "X").replace(".", ",").replace("X", ".")
def _audit_estimate_data(estimate: RepairEstimate) -> dict:
return {
"id": estimate.id,
"repair_id": estimate.repair_id,
"estimate_number": estimate.estimate_number,
"status": estimate.status,
"title": estimate.title,
"currency": estimate.currency,
"valid_until": estimate.valid_until,
"sent_at": estimate.sent_at,
"approved_at": estimate.approved_at,
"declined_at": estimate.declined_at,
"created_by_user_id": estimate.created_by_user_id,
}
class RepairEstimateService:
@staticmethod
def create(db: Session, repair: Repair, payload: RepairEstimateCreate, *, actor: User, request: Request) -> RepairEstimate:
estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
estimate = RepairEstimate(
repair_id=repair.id,
estimate_number=estimate_number,
status="draft",
created_by_user_id=actor.id,
)
RepairEstimateService._apply_payload(estimate, payload)
try:
db.add(estimate)
db.flush()
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
except IntegrityError:
db.rollback()
estimate.estimate_number = RepairEstimateRepository.get_next_number(db, datetime.now(UTC).year)
db.add(estimate)
db.flush()
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="created", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
estimate = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.create",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
after_data=_audit_estimate_data(estimate),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
return estimate
@staticmethod
def update(db: Session, repair: Repair, estimate: RepairEstimate, payload: RepairEstimateUpdate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status not in {"draft", "sent"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht mehr bearbeitet werden")
before_data = _audit_estimate_data(estimate)
RepairEstimateService._apply_payload(estimate, payload)
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="updated", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
updated = RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
write_audit_log(
db,
action="repair_estimates.update",
entity_type="repair_estimates",
entity_id=updated.id,
entity_label=_estimate_label(updated),
actor=actor,
request=request,
before_data=before_data,
after_data=_audit_estimate_data(updated),
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
return updated
@staticmethod
def delete(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> None:
if estimate.status not in {"draft", "cancelled"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Nur Entwürfe oder stornierte Kostenvoranschläge können gelöscht werden")
before_data = _audit_estimate_data(estimate)
label = _estimate_label(estimate)
RepairEstimateRepository.delete(db, estimate)
write_audit_log(
db,
action="repair_estimates.delete",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=label,
actor=actor,
request=request,
before_data=before_data,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
@staticmethod
def send(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status not in {"draft", "sent"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht gesendet werden")
if not repair.customer_email:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Für diese Reparatur ist keine Kunden-E-Mail hinterlegt")
link = RepairPublicLinkService.create_with_audit(
db,
repair,
actor=actor,
request=request,
audit_action="repairs.public_link.regenerate",
)
public_status_url = link.public_status_path
estimate = RepairEstimateRepository.mark_sent(db, estimate)
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="sent", actor_type="user", actor_user_id=actor.id)
smtp_config = SystemSettingsService.get_smtp_runtime_config(db)
mail_sent = False
if smtp_config.is_configured:
try:
SystemSettingsService.send_email(
smtp_config,
recipient=repair.customer_email,
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
text=RepairEstimateService._estimate_mail_text(repair, estimate, public_status_url),
html=RepairEstimateService._estimate_mail_html(repair, estimate, public_status_url),
)
mail_sent = True
except Exception:
mail_sent = False
RepairRepository.create_notification_event(
db,
repair_id=repair.id,
event_type="repair_estimate_mail",
channel="email",
recipient=repair.customer_email,
subject=f"Kostenvoranschlag {estimate.estimate_number} zu Reparatur {repair.repair_number}",
template="repair_estimate",
status="sent" if mail_sent else "failed",
success=mail_sent,
error_message=None if mail_sent else "Kostenvoranschlag-Mail konnte nicht versendet werden",
sent_at=datetime.now(UTC) if mail_sent else None,
)
RepairRepository.update_status(db, repair, RepairStatusUpdate(status="waiting_for_customer", note="Kostenvoranschlag gesendet"), actor_user_id=actor.id)
write_audit_log(
db,
action="repair_estimates.send",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "mail_sent": mail_sent},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def cancel(db: Session, repair: Repair, estimate: RepairEstimate, *, actor: User, request: Request) -> RepairEstimate:
if estimate.status in {"approved", "declined", "cancelled"}:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag kann nicht storniert werden")
estimate.status = "cancelled"
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type="cancelled", actor_type="user", actor_user_id=actor.id, commit=False)
db.commit()
db.refresh(estimate)
write_audit_log(
db,
action="repair_estimates.cancel",
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
actor=actor,
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def public_response(estimate: RepairEstimate | None) -> PublicEstimateResponse | None:
if estimate is None:
return None
return PublicEstimateResponse(
estimate_number=estimate.estimate_number,
status=estimate.status,
title=estimate.title,
customer_message=estimate.customer_message,
subtotal_cents=estimate.subtotal_cents,
tax_cents=estimate.tax_cents,
total_cents=estimate.total_cents,
currency=estimate.currency,
valid_until=estimate.valid_until,
items=[
PublicEstimateItemResponse(
item_type=item.item_type,
title=item.title,
description=item.description,
quantity=item.quantity,
unit=item.unit,
unit_price_cents=item.unit_price_cents,
total_cents=item.total_cents,
)
for item in estimate.items
],
)
@staticmethod
def customer_decision(
db: Session,
repair: Repair,
estimate: RepairEstimate,
decision: str,
payload: PublicEstimateDecisionRequest,
*,
request: Request | None,
) -> RepairEstimate:
if estimate.status != "sent":
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Dieser Kostenvoranschlag ist nicht zur Entscheidung offen")
now = datetime.now(UTC)
estimate.customer_response_message = payload.message
if decision == "approve":
estimate.status = "approved"
estimate.approved_at = now
event_type = "approved"
repair_status = "approved"
note = "Kunde hat den Kostenvoranschlag freigegeben"
audit_action = "repair_estimates.approve"
elif decision == "decline":
estimate.status = "declined"
estimate.declined_at = now
event_type = "declined"
repair_status = "waiting_for_customer"
note = "Kunde hat den Kostenvoranschlag abgelehnt"
audit_action = "repair_estimates.decline"
else:
event_type = "question"
repair_status = "waiting_for_customer"
note = "Kunde hat eine Rückfrage zum Kostenvoranschlag gestellt"
audit_action = "repair_estimates.question"
RepairEstimateRepository.add_event(db, estimate_id=estimate.id, event_type=event_type, actor_type="customer", note=payload.message, commit=False)
RepairRepository.update_status(db, repair, RepairStatusUpdate(status=repair_status, note=note), actor_user_id=None)
db.commit()
db.refresh(estimate)
write_audit_log(
db,
action=audit_action,
entity_type="repair_estimates",
entity_id=estimate.id,
entity_label=_estimate_label(estimate),
request=request,
metadata={"repair_id": repair.id, "repair_number": repair.repair_number, "actor": "customer"},
)
return RepairEstimateRepository.get(db, repair_id=repair.id, estimate_id=estimate.id) or estimate
@staticmethod
def _apply_payload(estimate: RepairEstimate, payload: RepairEstimateCreate | RepairEstimateUpdate) -> None:
estimate.title = payload.title
estimate.customer_message = payload.customer_message
estimate.internal_note = payload.internal_note
estimate.tax_rate_percent = payload.tax_rate_percent
estimate.currency = payload.currency
estimate.valid_until = payload.valid_until
items, subtotal = RepairEstimateService._build_items(payload.items)
tax_cents = calculate_tax(subtotal, payload.tax_rate_percent)
estimate.subtotal_cents = subtotal
estimate.tax_cents = tax_cents
estimate.total_cents = subtotal + tax_cents
estimate.items = items
@staticmethod
def _build_items(payload_items: list[RepairEstimateItemPayload]) -> tuple[list[RepairEstimateItem], int]:
items: list[RepairEstimateItem] = []
subtotal = 0
for index, payload in enumerate(payload_items, start=1):
total = calculate_item_total(payload.quantity, payload.unit_price_cents)
subtotal += total
items.append(
RepairEstimateItem(
position=index,
item_type=payload.item_type,
title=payload.title,
description=payload.description,
quantity=payload.quantity,
unit=payload.unit,
unit_price_cents=payload.unit_price_cents,
total_cents=total,
)
)
return items, subtotal
@staticmethod
def _estimate_mail_text(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return (
f"Hallo {repair.customer_name},\n\n"
f"zu Ihrer Reparatur {repair.repair_number} liegt ein Kostenvoranschlag vor.\n\n"
f"Gerät: {repair.device_manufacturer} {repair.device_model}\n"
f"Kostenvoranschlag: {estimate.estimate_number}\n"
f"Gesamtbetrag: {_money(estimate.total_cents, estimate.currency)}\n\n"
f"Kostenvoranschlag ansehen und entscheiden:\n{public_status_url}\n\n"
"Funktechnik Schubert"
)
@staticmethod
def _estimate_mail_html(repair: Repair, estimate: RepairEstimate, public_status_url: str) -> str:
return f"""<!doctype html>
<html lang="de"><body style="font-family:Arial,Helvetica,sans-serif;background:#f4f7fb;color:#172033;padding:24px;">
<table role="presentation" style="max-width:640px;width:100%;margin:auto;background:#fff;border:1px solid #dce5ef;border-radius:8px;">
<tr><td style="background:#082a60;color:#fff;padding:24px 28px;font-size:22px;font-weight:800;">Funktechnik Schubert</td></tr>
<tr><td style="padding:28px;">
<p>Hallo {escape(repair.customer_name)},</p>
<p>zu Ihrer Reparatur <strong>{escape(repair.repair_number)}</strong> liegt ein Kostenvoranschlag vor.</p>
<p><strong>Gerät:</strong> {escape((repair.device_manufacturer + " " + repair.device_model).strip())}<br>
<strong>Kostenvoranschlag:</strong> {escape(estimate.estimate_number)}<br>
<strong>Gesamtbetrag:</strong> {escape(_money(estimate.total_cents, estimate.currency))}</p>
<p><a href="{escape(public_status_url)}" style="display:inline-block;background:#082a60;color:#fff;text-decoration:none;font-weight:700;border-radius:8px;padding:12px 16px;">Kostenvoranschlag ansehen</a></p>
</td></tr></table></body></html>"""

View file

@ -11,6 +11,7 @@ from app.core.config import settings
from app.models.repair import Repair, RepairPublicAccessToken
from app.models.user import User
from app.repositories.repair_repository import RepairRepository
from app.repositories.repair_estimate_repository import RepairEstimateRepository
from app.schemas.repair import (
RepairPublicLinkCreateResponse,
RepairPublicLinkResponse,
@ -131,16 +132,12 @@ class RepairPublicLinkService:
@staticmethod
def public_status(db: Session, token: str) -> RepairPublicStatusResponse:
normalized_token = normalize_token(token)
if not normalized_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token))
if public_link is None or public_link.repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
public_link = RepairPublicLinkService.get_public_link_or_404(db, token)
repair = public_link.repair
history = RepairRepository.get_history_public(db, repair.id)
estimate = RepairEstimateRepository.get_latest_public(db, repair.id)
from app.services.repair_estimate_service import RepairEstimateService
response = RepairPublicStatusResponse(
repair_number=repair.repair_number,
public_status_label=STATUS_LABELS.get(repair.status, repair.status),
@ -155,6 +152,18 @@ class RepairPublicLinkService:
for item in history
],
updated_at=repair.updated_at,
estimate=RepairEstimateService.public_response(estimate),
)
RepairRepository.mark_public_link_used(db, public_link)
return response
@staticmethod
def get_public_link_or_404(db: Session, token: str) -> RepairPublicAccessToken:
normalized_token = normalize_token(token)
if not normalized_token:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
public_link = RepairRepository.get_public_link_by_hash(db, RepairPublicLinkService.hash_token(normalized_token))
if public_link is None or public_link.repair is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Reparaturstatus nicht gefunden")
return public_link