from __future__ import annotations from decimal import Decimal from typing import Any from uuid import UUID, uuid4 from sqlalchemy import func, select from sqlalchemy.orm import Session from app.clock import now_utc from app.discovery.criteria import criterion_payload, parse_project_criteria from app.discovery.definitions import get_definition, get_definition_by_key, upsert_definition from app.discovery.enums import CandidateStatus, DiffStatus, ProjectStatus, RankingStatus, SearchStatus from app.domain.serialize import jsonable from app.domain.validation import DomainValidationError from app.errors import AppError from app.persistence.discovery_models import ( BuyingProjectCriteriaRecord, BuyingProjectRecord, DiscoverySearchRecord, ProductCategoryRecord, ProjectCandidateRecord, ProjectCriterionRecord, SearchRunItemRecord, SearchRunRecord, ) from app.persistence.repositories import CatalogRepository from app.services.catalog_service import CatalogService, ensure_product_category INACTIVE_SEARCH = {SearchStatus.BLOCKED.value, SearchStatus.DISABLED.value, SearchStatus.PAUSED.value} def _uuid(value: UUID | None) -> UUID: return value or uuid4() class ProjectService: def __init__(self, session: Session) -> None: self.session = session self.catalog = CatalogRepository(session) self.categories = CatalogService(session) def create(self, payload: dict[str, Any]) -> dict[str, Any]: template_id = payload.get("template_id") or payload.get("profile_id") template_version = payload.get("template_version") or payload.get("profile_version") profile = None if template_id is not None: if template_version is None: raise AppError(422, "VALIDATION_ERROR", "template_version is required when template_id is set") profile = self.catalog.get_profile(str(template_id), int(template_version)) if profile is None: raise AppError(404, "NOT_FOUND", "template version not found") product_category_id = payload.get("product_category_id") or (None if profile is None else profile.category) if not product_category_id: raise AppError(422, "VALIDATION_ERROR", "product_category_id is required") if self.session.get(ProductCategoryRecord, product_category_id) is None: ensure_product_category(self.session, str(product_category_id)) market = payload.get("market") or (None if profile is None else profile.market) currency = payload.get("currency") or (None if profile is None else profile.currency) if not market or not currency: raise AppError(422, "VALIDATION_ERROR", "market and currency are required") target_budget = payload.get("target_budget") if target_budget is None and profile is not None and getattr(profile, "budget", None) is not None: target_budget = getattr(profile.budget, "target", None) criteria_payload = payload.get("criteria") if not criteria_payload: raise AppError(422, "CRITERIA_REQUIRED", "criteria are required") try: parsed = parse_project_criteria(criteria_payload) except DomainValidationError as exc: raise AppError(422, "CRITERIA_INVALID", str(exc), exc.details) from exc shortlist_size = int(payload.get("shortlist_size") or 5) if shortlist_size < 1: raise AppError(422, "VALIDATION_ERROR", "shortlist_size must be > 0") now = now_utc() project = BuyingProjectRecord( id=_uuid(payload.get("id")), name=str(payload["name"]), status=ProjectStatus.ACTIVE.value, workspace_id=payload.get("workspace_id"), product_category_id=str(product_category_id), market=str(market), currency=str(currency), target_budget=None if target_budget is None else Decimal(str(target_budget)), template_id=None if profile is None else profile.id, template_version=None if profile is None else profile.version, shortlist_size=shortlist_size, criteria_version=1, created_at=now, updated_at=now, archived_at=None, ) self.session.add(project) self.session.flush() self._store_criteria(project.id, 1, parsed, str(product_category_id), now) return self.get(project.id) def replace_criteria(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: project = self._require_project(project_id) try: parsed = parse_project_criteria(payload) except DomainValidationError as exc: raise AppError(422, "CRITERIA_INVALID", str(exc), exc.details) from exc current = self.latest_criteria(project_id) version = (current.version if current is not None else 0) + 1 now = now_utc() self._store_criteria(project.id, version, parsed, project.product_category_id, now) project.criteria_version = version project.updated_at = now for search in self.session.scalars( select(DiscoverySearchRecord).where(DiscoverySearchRecord.buying_project_id == project.id) ): if search.status not in INACTIVE_SEARCH: search.status = SearchStatus.OUT_OF_SYNC.value search.updated_at = now self.session.flush() return self.get(project.id) def add_criteria(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: return self.replace_criteria(project_id, payload) def patch(self, project_id: UUID, payload: dict[str, Any]) -> dict[str, Any]: project = self._require_project(project_id) allowed = {"name", "target_budget", "shortlist_size", "status"} unknown = set(payload) - allowed if unknown: raise AppError(422, "VALIDATION_ERROR", "field cannot be patched", {"fields": sorted(unknown)}) if "name" in payload: project.name = str(payload["name"]) if "target_budget" in payload: project.target_budget = None if payload["target_budget"] is None else Decimal(str(payload["target_budget"])) if "shortlist_size" in payload: size = int(payload["shortlist_size"]) if size < 1: raise AppError(422, "VALIDATION_ERROR", "shortlist_size must be > 0") project.shortlist_size = size if "status" in payload: status = str(payload["status"]) if status not in {item.value for item in ProjectStatus}: raise AppError(422, "VALIDATION_ERROR", "invalid project status") project.status = status if status == ProjectStatus.ARCHIVED.value: project.archived_at = now_utc() project.updated_at = now_utc() self.session.flush() return self.get(project.id) def get(self, project_id: UUID) -> dict[str, Any]: project = self._require_project(project_id) criteria = self.latest_criteria(project_id) return self._out(project, criteria) def get_criteria(self, project_id: UUID) -> dict[str, Any]: project = self._require_project(project_id) criteria = self.latest_criteria(project_id) items = [] if criteria is None else self.criteria_items(project.id, criteria.version) return jsonable( { "project_id": project.id, "criteria_version": None if criteria is None else criteria.version, "items": items, } ) def list(self, limit: int, offset: int) -> tuple[list[dict[str, Any]], int]: total = self.session.scalar(select(func.count()).select_from(BuyingProjectRecord)) or 0 rows = self.session.scalars( select(BuyingProjectRecord).order_by(BuyingProjectRecord.created_at).limit(limit).offset(offset) ).all() return [self._summary(row) for row in rows], total def latest_criteria(self, project_id: UUID) -> BuyingProjectCriteriaRecord | None: return self.session.scalars( select(BuyingProjectCriteriaRecord) .where(BuyingProjectCriteriaRecord.project_id == project_id) .order_by(BuyingProjectCriteriaRecord.version.desc()) ).first() def criteria_version(self, project_id: UUID, version: int) -> BuyingProjectCriteriaRecord | None: return self.session.get(BuyingProjectCriteriaRecord, (project_id, version)) def criteria_items(self, project_id: UUID, version: int) -> list[dict[str, Any]]: rows = self.session.scalars( select(ProjectCriterionRecord) .where(ProjectCriterionRecord.project_id == project_id, ProjectCriterionRecord.version == version) .order_by(ProjectCriterionRecord.item_id) ).all() return [criterion_payload(row) for row in rows] def available_criteria(self, project_id: UUID) -> dict[str, Any]: project = self._require_project(project_id) sources = self.categories.sources_for_category(project.product_category_id) source = sources[0]["source"] if sources else "geizhals" return self.categories.available_criteria(project.product_category_id, source) def _store_criteria( self, project_id: UUID, version: int, parsed: dict[str, Any], category: str | None, created_at, ) -> None: self.session.add( BuyingProjectCriteriaRecord( project_id=project_id, version=version, definition_hash=parsed["definition_hash"], created_at=created_at, ) ) self.session.flush() for item in parsed["items"]: definition = self._resolve_definition(item, category) self.session.add( ProjectCriterionRecord( id=uuid4(), project_id=project_id, version=version, item_id=item["id"], criterion_definition_id=definition.id, criterion_key=definition.key, operator=item["operator"], value=item["value"], enabled_for_discovery=item["enabled_for_discovery"], created_at=created_at, ) ) self.session.flush() def _resolve_definition(self, item: dict[str, Any], category: str | None): if item.get("criterion_definition_id") is not None: definition = get_definition(self.session, item["criterion_definition_id"]) if definition is None: raise AppError(422, "UNKNOWN_CRITERION", "criterion definition not found") return definition existing = get_definition_by_key(self.session, str(item["criterion"])) if existing is not None: return existing if not item.get("value_type"): raise AppError( 422, "UNKNOWN_CRITERION", "criterion definition missing; provide value_type or create it first", {"criterion": item["criterion"]}, ) return upsert_definition( self.session, { "key": item["criterion"], "origin": "INTERNAL", "product_category_id": item.get("category") or category, "label": item["criterion"], "value_type": item["value_type"], "unit": item.get("unit"), "semantic": item.get("semantic"), "category": item.get("category") or category, "scoring_attribute_key": item["criterion"], }, ) def _require_project(self, project_id: UUID) -> BuyingProjectRecord: project = self.session.get(BuyingProjectRecord, project_id) if project is None: raise AppError(404, "NOT_FOUND", "buying project not found") return project def _out(self, project: BuyingProjectRecord, criteria: BuyingProjectCriteriaRecord | None) -> dict[str, Any]: items = [] if criteria is None else self.criteria_items(project.id, criteria.version) return jsonable( { "id": project.id, "name": project.name, "status": project.status, "workspace_id": project.workspace_id, "product_category_id": project.product_category_id, "market": project.market, "currency": project.currency, "target_budget": project.target_budget, "template_id": project.template_id, "template_version": project.template_version, "profile_id": project.template_id, "profile_version": project.template_version, "shortlist_size": project.shortlist_size, "created_at": project.created_at, "updated_at": project.updated_at, "archived_at": project.archived_at, "criteria_version": project.criteria_version if criteria is None else criteria.version, "criteria": None if criteria is None else {"items": items}, "criteria_hash": None if criteria is None else criteria.definition_hash, "ranking_status": RankingStatus.NOT_CONFIGURED.value if project.template_id is None else RankingStatus.CONFIGURED.value, } ) def _summary(self, project: BuyingProjectRecord) -> dict[str, Any]: category = self.session.get(ProductCategoryRecord, project.product_category_id) searches = list( self.session.scalars( select(DiscoverySearchRecord).where(DiscoverySearchRecord.buying_project_id == project.id) ) ) active = next((row for row in searches if row.status not in INACTIVE_SEARCH), searches[0] if searches else None) last_run = None if active is not None: last_run = self.session.scalars( select(SearchRunRecord) .where(SearchRunRecord.discovery_search_id == active.id) .order_by(SearchRunRecord.started_at.desc()) ).first() candidates = list( self.session.scalars( select(ProjectCandidateRecord).where(ProjectCandidateRecord.buying_project_id == project.id) ) ) counts = {"NEW": 0, "CHANGED": 0, "MISSING": 0} if last_run is not None: for item in self.session.scalars( select(SearchRunItemRecord).where(SearchRunItemRecord.search_run_id == last_run.id) ): if item.diff_status in counts: counts[item.diff_status] += 1 return jsonable( { "id": project.id, "name": project.name, "product_category": None if category is None else {"id": category.id, "name": category.name, "status": category.status}, "status": project.status, "last_successful_run_at": None if active is None else active.last_successful_run_at, "next_run_at": None if active is None else active.next_run_at, "discovery_status": None if active is None else active.status, "candidate_count": len(candidates), "shortlist_count": sum(1 for row in candidates if row.status == CandidateStatus.SHORTLISTED.value), "new_count": last_run.new_count if last_run is not None else counts[DiffStatus.NEW.value], "changed_count": last_run.changed_count if last_run is not None else counts[DiffStatus.CHANGED.value], "missing_count": last_run.missing_count if last_run is not None else counts[DiffStatus.MISSING.value], "current_search_result_count": None if last_run is None else last_run.result_count, "ranking_status": RankingStatus.NOT_CONFIGURED.value if project.template_id is None else RankingStatus.CONFIGURED.value, } )