"""Name → ERP id resolution with fuzzy matching (Persian-aware) + tiny caches. The ERP search endpoints are ilike-based; we fetch candidates with `q=` and rank them with difflib so "میلگرد A3 14" still matches "میلگرد A3 سایز ۱۴". """ import difflib import time from dataclasses import dataclass, field from sqlalchemy import text from app.api.client import ApiError, ErpClient from app.db import SessionLocal from app.nlu import normalize_text @dataclass class Candidate: id: str name: str extra: dict = field(default_factory=dict) @dataclass class Resolution: status: str # "one" | "many" | "none" candidates: list[Candidate] @property def resolved(self) -> bool: return self.status == "one" def _rank(query: str, items: list[Candidate], limit: int = 6) -> list[Candidate]: q = normalize_text(query) if not q: return items[:limit] scored = [] for item in items: name = normalize_text(item.name) ratio = difflib.SequenceMatcher(None, q, name).ratio() if q == name: ratio = 1.0 elif q in name: ratio += 0.35 scored.append((ratio, item)) scored.sort(key=lambda pair: (-pair[0], pair[1].name)) return [item for ratio, item in scored[:limit] if ratio >= 0.40] async def _db_units() -> list[Candidate]: try: async with SessionLocal() as s: rows = (await s.execute(text("SELECT id, code, name_fa FROM units"))).mappings().all() return [ Candidate( id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]), extra={"code": u.get("code")}, ) for u in rows ] except Exception: return [] async def _db_categories() -> list[Candidate]: try: async with SessionLocal() as s: rows = (await s.execute(text("SELECT id, name, slug FROM material_categories"))).mappings().all() return [ Candidate( id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"]), ) for c in rows ] except Exception: return [] async def _db_projects() -> list[Candidate]: try: async with SessionLocal() as s: rows = (await s.execute(text("SELECT id, name FROM projects"))).mappings().all() return [ Candidate( id=str(p["id"]), name=p.get("name") or str(p["id"]), ) for p in rows ] except Exception: return [] async def _db_suppliers(q: str | None = None) -> list[Candidate]: try: async with SessionLocal() as s: if q: rows = (await s.execute( text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name " "FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id " "WHERE s.name ILIKE :q LIMIT 20"), {"q": f"%{q}%"} )).mappings().all() else: rows = (await s.execute( text("SELECT s.id, s.name, s.location, s.phone, s.contact_person, c.name as category_name " "FROM suppliers s LEFT JOIN material_categories c ON s.category_id = c.id LIMIT 20") )).mappings().all() return [ Candidate( id=str(r["id"]), name=r.get("name") or str(r["id"]), extra={ "location": r.get("location"), "phone": r.get("phone"), "contact_person": r.get("contact_person"), "category_name": r.get("category_name"), }, ) for r in rows ] except Exception: return [] async def _db_materials(q: str | None = None) -> list[Candidate]: try: async with SessionLocal() as s: if q: rows = (await s.execute( text("SELECT id, name, topic, canonical_unit_id, status FROM materials WHERE name ILIKE :q LIMIT 20"), {"q": f"%{q}%"} )).mappings().all() else: rows = (await s.execute( text("SELECT id, name, topic, canonical_unit_id, status FROM materials LIMIT 20") )).mappings().all() return [ Candidate( id=str(r["id"]), name=r.get("name") or str(r["id"]), extra=dict(r), ) for r in rows ] except Exception: return [] class Resolver: """Caches catalog lists (units/categories/projects are stable; suppliers/materials queried on demand).""" def __init__(self, api: ErpClient, ttl_seconds: float = 1800): self._api = api self._ttl = ttl_seconds self._cache: dict[str, tuple[float, list[Candidate]]] = {} async def _cached(self, key: str, fetch) -> list[Candidate]: entry = self._cache.get(key) now = time.monotonic() if entry and now - entry[0] < self._ttl: return entry[1] items = await fetch() self._cache[key] = (now, items) return items def invalidate(self, key: str | None = None) -> None: if key: self._cache.pop(key, None) else: self._cache.clear() # ---------------------------------------------------------------- sources async def units(self) -> list[Candidate]: async def fetch(): try: rows = await self._api.list_units() return [Candidate(id=str(u["id"]), name=u.get("name_fa") or u.get("code") or str(u["id"]), extra={"code": u.get("code")}) for u in rows] except Exception: return await _db_units() return await self._cached("units", fetch) async def categories(self) -> list[Candidate]: async def fetch(): try: rows = await self._api.list_categories() return [Candidate(id=str(c["id"]), name=c.get("name") or c.get("slug") or str(c["id"])) for c in rows] except Exception: return await _db_categories() return await self._cached("categories", fetch) async def projects(self) -> list[Candidate]: async def fetch(): try: rows = await self._api.list_projects() return [Candidate(id=str(p["id"]), name=p.get("name") or str(p["id"])) for p in rows] except Exception: return await _db_projects() return await self._cached("projects", fetch) async def suppliers(self, q: str | None = None) -> list[Candidate]: try: rows = await self._api.list_suppliers(q=q, page_size=20) items = [ Candidate( id=str(s["id"]), name=s.get("name") or str(s["id"]), extra={ "location": s.get("location"), "phone": s.get("phone"), "contact_person": s.get("contact_person"), "category_name": (s.get("category") or {}).get("name") if isinstance(s.get("category"), dict) else None, }, ) for s in rows ] except Exception: items = await _db_suppliers(q=q) return _rank(q, items) if q else items async def materials(self, q: str | None = None) -> list[Candidate]: try: rows = await self._api.list_materials(q=q, page_size=20) extra = {str(m["id"]): m for m in rows} items = [Candidate(id=str(m["id"]), name=m.get("name") or str(m["id"]), extra=extra.get(str(m["id"]), {})) for m in rows] except Exception: items = await _db_materials(q=q) return _rank(q, items) if q else items # ------------------------------------------------------------ resolutions async def resolve(self, kind: str, name: str | None) -> Resolution: """Resolve by name; exact hit → "one", several close → "many", none → "none".""" if not name or not name.strip(): return Resolution("none", []) if kind == "supplier": candidates = await self.suppliers(name) elif kind == "material": candidates = await self.materials(name) elif kind == "project": candidates = _rank(name, await self.projects()) elif kind == "unit": candidates = _rank(name, await self.units()) elif kind == "category": candidates = _rank(name, await self.categories()) else: raise ValueError(f"unknown kind: {kind}") if not candidates: return Resolution("none", []) norm_name = normalize_text(name) exact = [c for c in candidates if normalize_text(c.name) == norm_name] if len(exact) == 1: return Resolution("one", exact) top = candidates[0] top_norm = normalize_text(top.name) ratio = difflib.SequenceMatcher(None, norm_name, top_norm).ratio() if ratio >= 0.88 and (len(candidates) == 1 or ratio > 0.95): return Resolution("one", [top]) return Resolution("many", candidates) async def unit_for_material(self, unit_name: str | None, material: dict | None) -> tuple[str | None, bool]: """Return (unit_id, explicitly_resolved). Falls back to the material's canonical unit when the spoken unit isn't found.""" if unit_name: res = await self.resolve("unit", unit_name) if res.resolved: return res.candidates[0].id, True if material and material.get("canonical_unit_id"): return str(material["canonical_unit_id"]), False return None, False async def safe_resolve(resolver: Resolver, kind: str, name: str | None) -> Resolution: try: return await resolver.resolve(kind, name) except Exception: return Resolution("none", [])