BuyingAgent/backend/app/services/catalog_service.py

225 lines
9.4 KiB
Python
Raw Normal View History

from __future__ import annotations
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.discovery.enums import FilterType, MappingStatus
from app.domain.serialize import jsonable
from app.errors import AppError
from app.persistence.discovery_models import (
CriterionDefinitionRecord,
CriterionMappingRecord,
ProductCategoryRecord,
SourceCategoryRecord,
SourceFilterDefinitionRecord,
SourceFilterOptionRecord,
SourceFilterRangeRecord,
)
from app.discovery.definitions import upsert_definition
from app.persistence.repositories import CatalogRepository
FILTER_OPERATORS = {
FilterType.BOOLEAN.value: ["=", "!="],
FilterType.ENUM.value: ["=", "!="],
FilterType.MULTI_ENUM.value: ["in", "not_in"],
FilterType.NUMERIC_RANGE.value: [">=", "<=", "between"],
FilterType.NUMERIC_THRESHOLD.value: [">=", ">", "<=", "<"],
FilterType.TEXT.value: ["=", "contains"],
}
class CatalogService:
def __init__(self, session: Session) -> None:
self.session = session
self.catalog = CatalogRepository(session)
def list_product_categories(self) -> list[dict[str, Any]]:
rows = self.session.scalars(select(ProductCategoryRecord).order_by(ProductCategoryRecord.name)).all()
return [{"id": row.id, "name": row.name, "status": row.status} for row in rows]
def get_product_category(self, category_id: str) -> ProductCategoryRecord:
row = self.session.get(ProductCategoryRecord, category_id)
if row is None:
raise AppError(404, "NOT_FOUND", "product category not found", {"id": category_id})
return row
def sources_for_category(self, product_category_id: str) -> list[dict[str, Any]]:
self.get_product_category(product_category_id)
rows = self.session.scalars(
select(SourceCategoryRecord)
.where(SourceCategoryRecord.product_category_id == product_category_id)
.order_by(SourceCategoryRecord.source_id)
).all()
sources = {item.id: item for item in self.catalog.list_sources()}
return [
{
"source": row.source_id,
"source_category_id": row.id,
"display_name": sources[row.source_id].name if row.source_id in sources else row.name,
"external_category_key": row.external_category_key,
"status": row.status,
}
for row in rows
]
def source_categories(self, source_id: str) -> list[dict[str, Any]]:
if self.catalog.get_source(source_id) is None:
raise AppError(404, "NOT_FOUND", "source not found", {"source_id": source_id})
rows = self.session.scalars(
select(SourceCategoryRecord)
.where(SourceCategoryRecord.source_id == source_id)
.order_by(SourceCategoryRecord.external_category_key)
).all()
return [
{
"id": row.id,
"source": row.source_id,
"source_category_id": row.id,
"external_category_key": row.external_category_key,
"name": row.name,
"product_category_id": row.product_category_id,
"status": row.status,
}
for row in rows
]
def available_criteria(self, product_category_id: str, source_id: str) -> dict[str, Any]:
self.get_product_category(product_category_id)
if self.catalog.get_source(source_id) is None:
raise AppError(404, "NOT_FOUND", "source not found", {"source_id": source_id})
source_category = self.session.scalars(
select(SourceCategoryRecord).where(
SourceCategoryRecord.source_id == source_id,
SourceCategoryRecord.product_category_id == product_category_id,
)
).first()
criteria: list[dict[str, Any]] = []
if source_category is not None:
filters = self.session.scalars(
select(SourceFilterDefinitionRecord)
.where(SourceFilterDefinitionRecord.source_category_id == source_category.id)
.order_by(SourceFilterDefinitionRecord.label)
).all()
mappings = {
row.external_filter_key: row
for row in self.session.scalars(
select(CriterionMappingRecord).where(
CriterionMappingRecord.source_category_id == source_category.id
)
)
}
for filt in filters:
mapping = mappings.get(filt.external_filter_key)
definition = None if mapping is None else self.session.get(CriterionDefinitionRecord, mapping.criterion_definition_id)
if definition is None:
definition = upsert_definition(
self.session,
{
"key": f"{source_id}:{source_category.external_category_key}:{filt.external_filter_key}",
"origin": "SOURCE",
"source": source_id,
"product_category_id": product_category_id,
"label": filt.label,
"value_type": "number"
if filt.filter_type in {FilterType.NUMERIC_RANGE.value, FilterType.NUMERIC_THRESHOLD.value}
else "string",
"unit": filt.canonical_unit,
"category": product_category_id,
},
)
self.session.flush()
options = self.session.scalars(
select(SourceFilterOptionRecord).where(SourceFilterOptionRecord.filter_id == filt.id)
).all()
rng = self.session.get(SourceFilterRangeRecord, filt.id)
criteria.append(
self._criterion_out(
definition,
filt,
mapping,
options,
rng,
)
)
internals = self.session.scalars(
select(CriterionDefinitionRecord)
.where(
CriterionDefinitionRecord.origin == "INTERNAL",
(CriterionDefinitionRecord.product_category_id == product_category_id)
| (CriterionDefinitionRecord.category == product_category_id),
)
.order_by(CriterionDefinitionRecord.label)
).all()
seen = {item["criterion_definition_id"] for item in criteria}
for definition in internals:
if definition.id in seen:
continue
criteria.append(self._criterion_out(definition, None, None, [], None))
return jsonable({"product_category_id": product_category_id, "source": source_id, "criteria": criteria})
def list_templates(self) -> list[dict[str, Any]]:
rows, _total = self.catalog.list_profiles(200, 0)
return [
{
"id": row.id,
"version": row.version,
"name": row.definition.get("name") or row.id,
"product_category_id": row.definition.get("category"),
"market": row.definition.get("market"),
"currency": row.definition.get("currency"),
"description": row.definition.get("description"),
}
for row in rows
]
def _criterion_out(
self,
definition: CriterionDefinitionRecord,
filt: SourceFilterDefinitionRecord | None,
mapping: CriterionMappingRecord | None,
options: list[SourceFilterOptionRecord],
rng: SourceFilterRangeRecord | None,
) -> dict[str, Any]:
filter_type = None if filt is None else filt.filter_type
return {
"criterion_definition_id": definition.id,
"label": definition.label,
"description": definition.description,
"origin": definition.origin,
"source": definition.source,
"product_category_id": definition.product_category_id,
"filter_type": filter_type,
"unit": definition.unit or (None if filt is None else filt.canonical_unit),
"operators": FILTER_OPERATORS.get(filter_type or "", ["=", ">=", "<="]),
"options": [
{
"source_value": option.source_value,
"normalized_value": option.normalized_value,
"operator": option.operator,
"unit": option.unit,
}
for option in options
],
"range": None
if rng is None
else {"minimum": rng.minimum, "maximum": rng.maximum, "step": rng.step, "unit": rng.unit},
"mapping_status": None if mapping is None else mapping.status,
"supports_discovery": mapping is not None
and mapping.status == MappingStatus.VERIFIED.value
and filt is not None,
"scoring_attribute_key": definition.scoring_attribute_key,
}
def ensure_product_category(session: Session, category_id: str, name: str | None = None) -> ProductCategoryRecord:
row = session.get(ProductCategoryRecord, category_id)
if row is not None:
return row
row = ProductCategoryRecord(id=category_id, name=name or category_id.title(), status="ACTIVE")
session.add(row)
session.flush()
return row