240 lines
8.7 KiB
Python
240 lines
8.7 KiB
Python
|
|
"""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(),
|
||
|
|
}
|