"""Retail 360 workspace API — notes, media, milestones, RSS, wholesalers.""" from __future__ import annotations import json import urllib.request from datetime import datetime from typing import Any, Optional from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, Field from app.db import fetch_all, fetch_one from app.middleware import log_agent_event from app import retail_360 from app import wholesaler_scrapers from app.connectors import market_stocks, rss_feeds router = APIRouter(prefix="/retail", tags=["retail-360"]) class NoteIn(BaseModel): body: str = Field(..., min_length=1) title: Optional[str] = None note_type: str = "general" class MilestoneIn(BaseModel): title: str milestone_type: str = "custom" client_id: Optional[int] = None deal_id: Optional[int] = None target_date: Optional[str] = None value_eur: Optional[float] = None notes: Optional[str] = None class OwnershipIn(BaseModel): new_owner: str previous_owner: Optional[str] = None change_type: str = "acquisition" effective_date: Optional[str] = None source: Optional[str] = None notes: Optional[str] = None class CalendarIn(BaseModel): title: str starts_at: str description: Optional[str] = None ends_at: Optional[str] = None client_id: Optional[int] = None deal_id: Optional[int] = None location: Optional[str] = None class MediaIn(BaseModel): filename: str storage_path: str content_type: str = "image/jpeg" caption: Optional[str] = None def _row(row: dict | None) -> dict[str, Any]: if not row: raise HTTPException(404, "Not found") out: dict[str, Any] = {} for k, v in row.items(): if hasattr(v, "isoformat"): out[k] = v.isoformat() elif v is not None and hasattr(v, "__float__") and type(v).__name__ == "Decimal": out[k] = float(v) else: out[k] = v return out def _fetch_weather_forecast(lat: float, lon: float) -> list[dict[str, Any]]: url = ( f"https://api.open-meteo.com/v1/forecast?latitude={lat}&longitude={lon}" f"&daily=temperature_2m_max,precipitation_sum,weathercode" f"&timezone=Europe%2FAmsterdam&forecast_days=7" ) try: with urllib.request.urlopen(url, timeout=15) as resp: data = json.loads(resp.read().decode()) days = data.get("daily", {}).get("time", []) temps = data.get("daily", {}).get("temperature_2m_max", []) prec = data.get("daily", {}).get("precipitation_sum", []) return [ {"date": days[i], "temperature_c": temps[i] if i < len(temps) else None, "precipitation_mm": prec[i] if i < len(prec) else None, "source": "open-meteo-live"} for i in range(len(days)) ] except Exception: return [] @router.get("/360/{store_id}") def get_360_view(store_id: int) -> dict[str, Any]: try: data = retail_360.get_store_360(store_id) except ValueError as exc: raise HTTPException(404, str(exc)) from exc store = data["store"] if store.get("latitude") and store.get("longitude"): live = _fetch_weather_forecast(float(store["latitude"]), float(store["longitude"])) if live: data["weather_forecast"] = live for key in ("notes", "media", "milestones", "ownership_changes", "calendar", "weather"): data[key] = [_row(x) for x in data.get(key, [])] if data.get("area_analysis"): data["area_analysis"] = _row(data["area_analysis"]) return data @router.post("/360/{store_id}/notes") def add_store_note(store_id: int, payload: NoteIn) -> dict[str, Any]: note = retail_360.add_note("supermarket", store_id, payload.body, payload.title, payload.note_type) log_agent_event(agent_name="retail_360", event_type="note", title=f"Note on store {store_id}") return {"note": _row(note)} @router.post("/360/{store_id}/milestones") def add_store_milestone(store_id: int, payload: MilestoneIn) -> dict[str, Any]: ms = retail_360.add_milestone(store_id, payload.title, payload.milestone_type, **payload.model_dump(exclude={"title", "milestone_type"})) return {"milestone": _row(ms)} @router.post("/360/{store_id}/ownership") def add_store_ownership(store_id: int, payload: OwnershipIn) -> dict[str, Any]: store = fetch_one("SELECT chain FROM supermarkets WHERE id = %s", (store_id,)) row = retail_360.add_ownership(entity_id=store_id, chain=store.get("chain") if store else None, **payload.model_dump()) return {"ownership": _row(row)} @router.post("/360/{store_id}/calendar") def add_store_calendar(store_id: int, payload: CalendarIn) -> dict[str, Any]: ev = retail_360.add_calendar_event(store_id, payload.title, payload.starts_at, **payload.model_dump(exclude={"title", "starts_at"})) return {"event": _row(ev)} @router.post("/360/{store_id}/media") def register_store_media(store_id: int, payload: MediaIn) -> dict[str, Any]: media = retail_360.register_media("supermarket", store_id, payload.filename, payload.storage_path, payload.content_type, payload.caption) return {"media": _row(media)} @router.get("/cities") def list_cities(limit: int = Query(200, ge=1, le=1000)) -> dict[str, Any]: rows = fetch_all( """SELECT c.*, (SELECT COUNT(*) FROM supermarkets s WHERE s.city ILIKE c.city) AS store_count FROM city_demographics c ORDER BY c.population DESC NULLS LAST LIMIT %s""", (limit,), ) return {"items": [_row(r) for r in rows], "count": len(rows)} @router.post("/cities/sync") def sync_cities(limit: int = Query(50, ge=1, le=200)) -> dict[str, Any]: return retail_360.sync_city_demographics(limit) @router.get("/wholesalers") def list_wholesalers(limit: int = Query(500, ge=1, le=2000), q: Optional[str] = None) -> dict[str, Any]: clauses, params = [], [] if q: clauses.append("(name ILIKE %s OR city ILIKE %s OR address ILIKE %s)") like = f"%{q}%" params.extend([like, like, like]) where = (" WHERE " + " AND ".join(clauses)) if clauses else "" rows = fetch_all(f"SELECT * FROM wholesalers{where} ORDER BY name LIMIT %s", tuple(params + [limit])) return {"items": [_row(r) for r in rows], "count": len(rows)} @router.post("/wholesalers/import") def import_wholesalers(background: bool = Query(False)) -> dict[str, Any]: log_agent_event(agent_name="wholesale_scraper", event_type="import", title="OSM wholesalers import") if background: import threading threading.Thread(target=wholesaler_scrapers.import_wholesalers, daemon=True).start() return {"status": "started", "message": "Wholesaler import running in background"} return wholesaler_scrapers.import_wholesalers() @router.get("/rss/live") def rss_live(limit: int = Query(30, ge=1, le=100), category: Optional[str] = None) -> dict[str, Any]: rows = rss_feeds.list_live_feed(limit, category) return {"items": [_row(r) for r in rows], "count": len(rows)} @router.post("/rss/refresh") def rss_refresh() -> dict[str, Any]: log_agent_event(agent_name="rss_feeds", event_type="refresh", title="RSS feeds refresh") return rss_feeds.refresh_all_feeds() @router.get("/market/stocks") def retail_market_stocks() -> dict[str, Any]: quotes = market_stocks.fetch_retail_quotes() return { "items": quotes, "summary": market_stocks.market_summary(quotes), "updated_at": datetime.utcnow().isoformat(), } @router.get("/regulations") def retail_regulations(limit: int = Query(30, ge=1, le=100)) -> dict[str, Any]: reg = rss_feeds.list_live_feed(limit, "regelgeving") cbs = rss_feeds.list_live_feed(limit, "cbs") markt = rss_feeds.list_live_feed(min(limit, 15), "markt") return { "regelgeving": [_row(r) for r in reg], "cbs": [_row(r) for r in cbs], "markt": [_row(r) for r in markt], "updated_at": datetime.utcnow().isoformat(), } @router.get("/live-dashboard") def live_dashboard() -> dict[str, Any]: trends = fetch_all( "SELECT * FROM market_trends ORDER BY updated_at DESC NULLS LAST LIMIT 8" ) rss = rss_feeds.list_live_feed(12) opportunities = fetch_all( """SELECT s.name, s.chain, s.city, ros.halal_opportunity_score FROM retail_opportunity_scores ros JOIN supermarkets s ON s.id = ros.supermarket_id ORDER BY ros.halal_opportunity_score DESC LIMIT 5""" ) quotes = market_stocks.fetch_retail_quotes() return { "trends": [_row(t) for t in trends], "rss": [_row(r) for r in rss], "top_opportunities": [_row(o) for o in opportunities], "market_stocks": quotes, "market_summary": market_stocks.market_summary(quotes), "updated_at": datetime.utcnow().isoformat(), }