449 lines
16 KiB
Python
449 lines
16 KiB
Python
from datetime import UTC, datetime
|
|
|
|
from sqlalchemy import Select, func, or_, select
|
|
from sqlalchemy.orm import Session, selectinload
|
|
|
|
from app.models.repair import Repair, RepairDocument, RepairIntakeEvent, RepairNotificationEvent, RepairPublicAccessToken, RepairStatusHistory
|
|
from app.schemas.repair import RepairCreate, RepairDocumentUpdate, 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)
|
|
.options(selectinload(RepairStatusHistory.actor))
|
|
.where(RepairStatusHistory.repair_id == repair_id)
|
|
.order_by(RepairStatusHistory.created_at.desc(), RepairStatusHistory.id.desc())
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def get_history_public(db: Session, repair_id: int) -> list[RepairStatusHistory]:
|
|
return list(
|
|
db.scalars(
|
|
select(RepairStatusHistory)
|
|
.where(RepairStatusHistory.repair_id == repair_id)
|
|
.order_by(RepairStatusHistory.created_at.asc(), RepairStatusHistory.id.asc())
|
|
)
|
|
)
|
|
|
|
@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 get_active_public_link(db: Session, repair_id: int) -> RepairPublicAccessToken | None:
|
|
now = datetime.now(UTC)
|
|
return db.scalar(
|
|
select(RepairPublicAccessToken)
|
|
.where(RepairPublicAccessToken.repair_id == repair_id)
|
|
.where(RepairPublicAccessToken.is_active.is_(True))
|
|
.where(RepairPublicAccessToken.revoked_at.is_(None))
|
|
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
|
|
.order_by(RepairPublicAccessToken.created_at.desc(), RepairPublicAccessToken.id.desc())
|
|
.limit(1)
|
|
)
|
|
|
|
@staticmethod
|
|
def get_public_link_by_hash(db: Session, token_hash: str) -> RepairPublicAccessToken | None:
|
|
now = datetime.now(UTC)
|
|
return db.scalar(
|
|
select(RepairPublicAccessToken)
|
|
.options(selectinload(RepairPublicAccessToken.repair))
|
|
.where(RepairPublicAccessToken.token_hash == token_hash)
|
|
.where(RepairPublicAccessToken.is_active.is_(True))
|
|
.where(RepairPublicAccessToken.revoked_at.is_(None))
|
|
.where(or_(RepairPublicAccessToken.expires_at.is_(None), RepairPublicAccessToken.expires_at > now))
|
|
.limit(1)
|
|
)
|
|
|
|
@staticmethod
|
|
def create_public_link(
|
|
db: Session,
|
|
*,
|
|
repair_id: int,
|
|
token_hash: str,
|
|
token_hint: str,
|
|
expires_at: datetime | None = None,
|
|
) -> RepairPublicAccessToken:
|
|
RepairRepository.revoke_public_links(db, repair_id=repair_id, commit=False)
|
|
public_link = RepairPublicAccessToken(
|
|
repair_id=repair_id,
|
|
token_hash=token_hash,
|
|
token_hint=token_hint,
|
|
expires_at=expires_at,
|
|
)
|
|
db.add(public_link)
|
|
db.commit()
|
|
db.refresh(public_link)
|
|
return public_link
|
|
|
|
@staticmethod
|
|
def revoke_public_links(db: Session, *, repair_id: int, commit: bool = True) -> None:
|
|
now = datetime.now(UTC)
|
|
links = list(
|
|
db.scalars(
|
|
select(RepairPublicAccessToken)
|
|
.where(RepairPublicAccessToken.repair_id == repair_id)
|
|
.where(RepairPublicAccessToken.is_active.is_(True))
|
|
)
|
|
)
|
|
for link in links:
|
|
link.is_active = False
|
|
link.revoked_at = now
|
|
if commit:
|
|
db.commit()
|
|
|
|
@staticmethod
|
|
def mark_public_link_used(db: Session, public_link: RepairPublicAccessToken) -> None:
|
|
public_link.last_used_at = datetime.now(UTC)
|
|
db.commit()
|
|
|
|
@staticmethod
|
|
def list_notification_events(db: Session, repair_id: int) -> list[RepairNotificationEvent]:
|
|
return list(
|
|
db.scalars(
|
|
select(RepairNotificationEvent)
|
|
.where(RepairNotificationEvent.repair_id == repair_id)
|
|
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def create_notification_event(
|
|
db: Session,
|
|
*,
|
|
repair_id: int,
|
|
event_type: str,
|
|
channel: str,
|
|
recipient: str,
|
|
subject: str,
|
|
template: str,
|
|
status: str,
|
|
success: bool,
|
|
error_message: str | None = None,
|
|
sent_at: datetime | None = None,
|
|
) -> RepairNotificationEvent:
|
|
event = RepairNotificationEvent(
|
|
repair_id=repair_id,
|
|
event_type=event_type,
|
|
channel=channel,
|
|
recipient=recipient,
|
|
subject=subject,
|
|
template=template,
|
|
status=status,
|
|
success=success,
|
|
error_message=error_message,
|
|
sent_at=sent_at,
|
|
)
|
|
db.add(event)
|
|
db.commit()
|
|
db.refresh(event)
|
|
return event
|
|
|
|
@staticmethod
|
|
def count_status_mails_sent_today(db: Session) -> int:
|
|
today = datetime.now(UTC).date()
|
|
return db.scalar(
|
|
select(func.count(RepairNotificationEvent.id))
|
|
.where(RepairNotificationEvent.event_type == "repair_status_mail")
|
|
.where(RepairNotificationEvent.success.is_(True))
|
|
.where(func.date(RepairNotificationEvent.created_at) == today)
|
|
) or 0
|
|
|
|
@staticmethod
|
|
def count_failed_status_mails(db: Session) -> int:
|
|
return db.scalar(
|
|
select(func.count(RepairNotificationEvent.id))
|
|
.where(RepairNotificationEvent.event_type == "repair_status_mail")
|
|
.where(RepairNotificationEvent.success.is_(False))
|
|
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
|
|
) or 0
|
|
|
|
@staticmethod
|
|
def latest_failed_status_mail(db: Session) -> RepairNotificationEvent | None:
|
|
return db.scalar(
|
|
select(RepairNotificationEvent)
|
|
.where(RepairNotificationEvent.event_type == "repair_status_mail")
|
|
.where(RepairNotificationEvent.success.is_(False))
|
|
.where(RepairNotificationEvent.status.in_(["failed", "skipped"]))
|
|
.order_by(RepairNotificationEvent.created_at.desc(), RepairNotificationEvent.id.desc())
|
|
.limit(1)
|
|
)
|
|
|
|
@staticmethod
|
|
def count_open_repairs_without_customer_email(db: Session) -> int:
|
|
return db.scalar(
|
|
select(func.count(Repair.id))
|
|
.where(~Repair.status.in_(["completed", "cancelled"]))
|
|
.where((Repair.customer_email == "") | Repair.customer_email.is_(None))
|
|
) or 0
|
|
|
|
@staticmethod
|
|
def list_documents(db: Session, repair_id: int) -> list[RepairDocument]:
|
|
return list(
|
|
db.scalars(
|
|
select(RepairDocument)
|
|
.where(RepairDocument.repair_id == repair_id)
|
|
.order_by(RepairDocument.created_at.desc(), RepairDocument.id.desc())
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def get_document(db: Session, *, repair_id: int, document_id: int) -> RepairDocument | None:
|
|
return db.scalar(
|
|
select(RepairDocument)
|
|
.where(RepairDocument.repair_id == repair_id)
|
|
.where(RepairDocument.id == document_id)
|
|
)
|
|
|
|
@staticmethod
|
|
def create_document(
|
|
db: Session,
|
|
*,
|
|
repair_id: int,
|
|
title: str,
|
|
document_type: str,
|
|
original_filename: str,
|
|
stored_filename: str,
|
|
storage_path: str,
|
|
mime_type: str,
|
|
size_bytes: int,
|
|
checksum_sha256: str,
|
|
visibility: str,
|
|
note: str | None,
|
|
uploaded_by_user_id: int | None,
|
|
) -> RepairDocument:
|
|
document = RepairDocument(
|
|
repair_id=repair_id,
|
|
title=title,
|
|
document_type=document_type,
|
|
original_filename=original_filename,
|
|
stored_filename=stored_filename,
|
|
storage_path=storage_path,
|
|
mime_type=mime_type,
|
|
size_bytes=size_bytes,
|
|
checksum_sha256=checksum_sha256,
|
|
visibility=visibility,
|
|
note=note,
|
|
uploaded_by_user_id=uploaded_by_user_id,
|
|
)
|
|
db.add(document)
|
|
db.commit()
|
|
db.refresh(document)
|
|
return RepairRepository.get_document(db, repair_id=repair_id, document_id=document.id) or document
|
|
|
|
@staticmethod
|
|
def update_document(db: Session, document: RepairDocument, payload: RepairDocumentUpdate) -> RepairDocument:
|
|
for key, value in payload.model_dump().items():
|
|
setattr(document, key, value)
|
|
db.commit()
|
|
db.refresh(document)
|
|
return RepairRepository.get_document(db, repair_id=document.repair_id, document_id=document.id) or document
|
|
|
|
@staticmethod
|
|
def delete_document(db: Session, document: RepairDocument) -> None:
|
|
db.delete(document)
|
|
db.commit()
|
|
|
|
@staticmethod
|
|
def count_documents(db: Session) -> int:
|
|
return db.scalar(select(func.count(RepairDocument.id))) or 0
|
|
|
|
@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)
|