"""Firmen und Zahlungsempfänger.""" from fastapi import APIRouter, BackgroundTasks, File, Query, UploadFile, status from sqlalchemy import func, select from sqlalchemy.exc import IntegrityError from app.api.deps import DbSession from app.core.config import settings from app.core.errors import ConflictError from app.models import Merchant, Recurrence, Transaction from app.models.enums import LogoStatus from app.schemas.common import ErrorResponse, MessageResponse, Page from app.schemas.merchant import ( LogoCandidateOut, LogoSearchOut, LogoSelectRequest, MerchantCreate, MerchantOut, MerchantUpdate, ) from app.services.crud import apply_updates, get_or_404 from app.services.logos import ( apply_upload, resolve_merchant_logo, resolve_merchant_logo_task, search_candidates, select_candidate, store_candidate, ) from app.services.merchants import normalize_name, search_statement router = APIRouter(prefix="/merchants", tags=["merchants"]) NOT_FOUND = {status.HTTP_404_NOT_FOUND: {"model": ErrorResponse}} @router.get( "", response_model=Page[MerchantOut], summary="Firmen suchen", description="Volltextsuche über Name, normalisierten Namen und Domain.", ) async def list_merchants( session: DbSession, q: str | None = Query(default=None, description="Suchbegriff."), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), ) -> Page[MerchantOut]: stmt = search_statement(q) total = ( await session.execute(select(func.count()).select_from(stmt.order_by(None).subquery())) ).scalar_one() items = (await session.execute(stmt.limit(limit).offset(offset))).scalars().all() return Page[MerchantOut]( items=[MerchantOut.model_validate(item) for item in items], total=total, limit=limit, offset=offset, ) @router.post( "", response_model=MerchantOut, status_code=status.HTTP_201_CREATED, summary="Firma anlegen", description="Antwortet sofort. Die Logosuche läuft anschließend im Hintergrund; " "der Status wechselt dabei von `pending` auf `resolved`.", ) async def create_merchant( payload: MerchantCreate, background: BackgroundTasks, session: DbSession ) -> Merchant: merchant = Merchant( name=payload.name, normalized_name=normalize_name(payload.name), domain=payload.domain, aliases=payload.aliases, brand_color=payload.brand_color, brand_color_dark=payload.brand_color_dark, logo_status=LogoStatus.PENDING, ) session.add(merchant) try: await session.commit() except IntegrityError as exc: await session.rollback() raise ConflictError(f"Die Firma '{payload.name}' ist bereits angelegt.") from exc await session.refresh(merchant) if settings.logo_auto_resolve: # Läuft erst nach der Antwort – der Aufrufer wartet nicht auf die Suche. background.add_task(resolve_merchant_logo_task, merchant.id) return merchant @router.get( "/{merchant_id}", response_model=MerchantOut, responses=NOT_FOUND, summary="Firma lesen" ) async def read_merchant(merchant_id: int, session: DbSession) -> Merchant: return await get_or_404(session, Merchant, merchant_id) @router.patch( "/{merchant_id}", response_model=MerchantOut, responses=NOT_FOUND, summary="Firma ändern" ) async def update_merchant( merchant_id: int, payload: MerchantUpdate, session: DbSession ) -> Merchant: merchant = await get_or_404(session, Merchant, merchant_id) apply_updates(merchant, payload) if payload.name is not None: merchant.normalized_name = normalize_name(payload.name) try: await session.commit() except IntegrityError as exc: await session.rollback() raise ConflictError("Eine Firma mit diesem Namen ist bereits angelegt.") from exc await session.refresh(merchant) return merchant @router.delete( "/{merchant_id}", response_model=MessageResponse, responses=NOT_FOUND, summary="Firma löschen", description="Verweise aus Buchungen und Posten werden dabei auf 'keine Firma' gesetzt.", ) async def delete_merchant(merchant_id: int, session: DbSession) -> MessageResponse: merchant = await get_or_404(session, Merchant, merchant_id) # ON DELETE SET NULL greift erst in der Datenbank; die geladenen Objekte # müssen daher explizit nachgezogen werden. for model in (Recurrence, Transaction): stmt = select(model).where(model.merchant_id == merchant_id) for row in (await session.execute(stmt)).scalars(): row.merchant_id = None await session.delete(merchant) await session.commit() return MessageResponse(detail="Firma gelöscht.") # --- Logos --------------------------------------------------------------------- def _to_candidate_out(asset_id: int, kandidat, mime: str, is_best: bool) -> LogoCandidateOut: return LogoCandidateOut( candidate_id=asset_id, source=kandidat.source, title=kandidat.title, score=kandidat.score, mime=mime, width=kandidat.width, height=kandidat.height, brand_color=kandidat.brand_color, is_preselected=is_best, ) @router.post( "/{merchant_id}/logo/search", response_model=LogoSearchOut, responses=NOT_FOUND, summary="Logos suchen", description="Arbeitet die gesamte Provider-Kette ab und liefert bis zu fünf " "Kandidaten. Alle werden im lokalen Cache abgelegt und sind sofort über " "`/api/logos/{id}` abrufbar. Der beste Treffer ist vorausgewählt. Die Auswahl " "der Firma wird dabei noch nicht verändert.", ) async def search_logos( merchant_id: int, session: DbSession, domain: str | None = Query( default=None, description="Überschreibt die hinterlegte Domain für diese Suche." ), ) -> LogoSearchOut: merchant = await get_or_404(session, Merchant, merchant_id) if domain is not None: merchant.domain = domain or None kandidaten = await search_candidates(merchant.name, merchant.domain) ausgabe: list[LogoCandidateOut] = [] for index, kandidat in enumerate(kandidaten): asset = await store_candidate(session, kandidat) ausgabe.append(_to_candidate_out(asset.id, kandidat, asset.mime, index == 0)) await session.commit() return LogoSearchOut(merchant_id=merchant.id, candidates=ausgabe) @router.post( "/{merchant_id}/logo/select", response_model=MerchantOut, responses=NOT_FOUND, summary="Logo auswählen", description="Übernimmt einen Kandidaten aus der Suche. Der Status wechselt auf " "`manual`; die automatische Suche überschreibt die Auswahl danach nicht mehr.", ) async def select_logo(merchant_id: int, payload: LogoSelectRequest, session: DbSession) -> Merchant: merchant = await get_or_404(session, Merchant, merchant_id) await select_candidate(session, merchant, payload.candidate_id) await session.commit() await session.refresh(merchant) return merchant @router.post( "/{merchant_id}/logo/upload", response_model=MerchantOut, responses=NOT_FOUND, summary="Logo hochladen", description="Nimmt SVG, PNG oder JPEG bis 1 MB entgegen. Der Status wechselt auf " "`manual`, die Markenfarbe wird aus der Datei ermittelt.", ) async def upload_logo( merchant_id: int, session: DbSession, file: UploadFile = File(description="Bilddatei.") ) -> Merchant: merchant = await get_or_404(session, Merchant, merchant_id) inhalt = await file.read() await apply_upload(session, merchant, inhalt, file.content_type) await session.commit() await session.refresh(merchant) return merchant @router.post( "/{merchant_id}/logo/resolve", response_model=MerchantOut, responses=NOT_FOUND, summary="Logosuche erneut anstoßen", description="Sucht und übernimmt den besten Treffer sofort. Mit `force=true` wird " "auch eine manuelle Auswahl ersetzt.", ) async def resolve_logo( merchant_id: int, session: DbSession, force: bool = Query(default=False, description="Manuelle Auswahl überschreiben."), ) -> Merchant: merchant = await get_or_404(session, Merchant, merchant_id) await resolve_merchant_logo(session, merchant, force=force) await session.commit() await session.refresh(merchant) return merchant @router.delete( "/{merchant_id}/logo", response_model=MerchantOut, responses=NOT_FOUND, summary="Logo entfernen", description="Löst die Zuordnung und setzt den Status zurück auf `pending`.", ) async def remove_logo(merchant_id: int, session: DbSession) -> Merchant: merchant = await get_or_404(session, Merchant, merchant_id) merchant.logo_asset_id = None merchant.logo_source = None merchant.logo_status = LogoStatus.PENDING merchant.brand_color = None merchant.brand_color_dark = None await session.commit() await session.refresh(merchant) return merchant