Platform bundle: marketing publish, IT ops, packaging, agents mesh.
Volledige Foodlinkk Command Center uitbreiding met social automatisering, reclamefolder filters, Proxmox monitoring en documentatie.
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
"""Retail intelligence API routes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.db import execute_returning, fetch_all, fetch_one
|
||||
from app.middleware import log_agent_event
|
||||
from app import retail_scrapers
|
||||
from app import retail_enrichment
|
||||
from app import retail_crm
|
||||
from app import retail_opportunities
|
||||
from app.connectors import halal_registry, trends_feed
|
||||
|
||||
router = APIRouter(prefix="/retail", tags=["retail"])
|
||||
|
||||
FIELD_SCHEMA = {
|
||||
"locatie": ["id", "name", "chain", "address", "postcode", "city", "province", "store_type", "latitude", "longitude"],
|
||||
"contact": ["phone", "email", "website", "manager_name", "employee_count"],
|
||||
"halal": ["halal_certified", "halal_certifier", "has_halal_section", "halal_certificate_number", "halal_expiry_date"],
|
||||
"crm": ["partnership_status", "client_id", "deal_id", "halal_opportunity_score"],
|
||||
"cbs": ["area_population", "area_avg_income", "area_households", "muslim_proxy_pct", "area_data_source"],
|
||||
"meta": ["data_source", "external_id", "last_updated", "enrichment_score"],
|
||||
}
|
||||
|
||||
|
||||
class CrmLinkIn(BaseModel):
|
||||
client_id: int
|
||||
deal_id: Optional[int] = None
|
||||
relationship_type: str = "prospect"
|
||||
partnership_status: Optional[str] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
STORE_SELECT = """
|
||||
SELECT s.*,
|
||||
a.population AS area_population,
|
||||
a.avg_income AS area_avg_income,
|
||||
a.households AS area_households,
|
||||
a.religious_composition AS area_religious,
|
||||
a.ethnic_composition AS area_ethnic,
|
||||
a.data_source AS area_data_source,
|
||||
ros.halal_opportunity_score AS opp_halal_score,
|
||||
ros.market_potential_score AS opp_market_score,
|
||||
sp.manager_name AS profile_manager,
|
||||
sp.manager_phone AS profile_manager_phone,
|
||||
sp.manager_email AS profile_manager_email,
|
||||
sp.staff_count_estimate,
|
||||
sp.data_completeness AS profile_completeness
|
||||
FROM supermarkets s
|
||||
LEFT JOIN area_analysis a ON a.postcode = s.postcode
|
||||
LEFT JOIN retail_opportunity_scores ros ON ros.supermarket_id = s.id
|
||||
LEFT JOIN supermarket_profiles sp ON sp.supermarket_id = s.id
|
||||
"""
|
||||
|
||||
|
||||
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
|
||||
if out.get("area_religious") and isinstance(out["area_religious"], dict):
|
||||
out["muslim_proxy_pct"] = out["area_religious"].get("muslim_proxy_pct")
|
||||
return out
|
||||
|
||||
|
||||
def _build_filters(
|
||||
chain: Optional[str] = None,
|
||||
province: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
partnership: Optional[str] = None,
|
||||
halal_certified: Optional[bool] = None,
|
||||
has_halal_section: Optional[bool] = None,
|
||||
store_type: Optional[str] = None,
|
||||
postcode_prefix: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
min_population: Optional[int] = None,
|
||||
max_population: Optional[int] = None,
|
||||
min_avg_income: Optional[float] = None,
|
||||
max_avg_income: Optional[float] = None,
|
||||
min_muslim_pct: Optional[float] = None,
|
||||
has_area_data: Optional[bool] = None,
|
||||
min_halal_opportunity: Optional[float] = None,
|
||||
max_halal_opportunity: Optional[float] = None,
|
||||
has_phone: Optional[bool] = None,
|
||||
has_email: Optional[bool] = None,
|
||||
has_manager: Optional[bool] = None,
|
||||
halal_gap_only: Optional[bool] = None,
|
||||
linked_to_crm: Optional[bool] = None,
|
||||
has_halal_cert_registry: Optional[bool] = None,
|
||||
) -> tuple[list[str], list[Any]]:
|
||||
clauses: list[str] = ["s.postcode <> '0000AA'"]
|
||||
params: list[Any] = []
|
||||
|
||||
if chain:
|
||||
clauses.append("s.chain ILIKE %s")
|
||||
params.append(f"%{chain}%")
|
||||
if province:
|
||||
clauses.append("s.province ILIKE %s")
|
||||
params.append(f"%{province}%")
|
||||
if city:
|
||||
clauses.append("s.city ILIKE %s")
|
||||
params.append(f"%{city}%")
|
||||
if partnership:
|
||||
clauses.append("s.partnership_status = %s")
|
||||
params.append(partnership)
|
||||
if halal_certified is not None:
|
||||
clauses.append("s.halal_certified = %s")
|
||||
params.append(halal_certified)
|
||||
if has_halal_section is not None:
|
||||
clauses.append("s.has_halal_section = %s")
|
||||
params.append(has_halal_section)
|
||||
if store_type:
|
||||
clauses.append("s.store_type ILIKE %s")
|
||||
params.append(f"%{store_type}%")
|
||||
if postcode_prefix:
|
||||
clauses.append("s.postcode LIKE %s")
|
||||
params.append(f"{postcode_prefix.upper()}%")
|
||||
if q:
|
||||
clauses.append("(s.name ILIKE %s OR s.address ILIKE %s OR s.city ILIKE %s)")
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like, like])
|
||||
if min_population is not None:
|
||||
clauses.append("a.population >= %s")
|
||||
params.append(min_population)
|
||||
if max_population is not None:
|
||||
clauses.append("a.population <= %s")
|
||||
params.append(max_population)
|
||||
if min_avg_income is not None:
|
||||
clauses.append("a.avg_income >= %s")
|
||||
params.append(min_avg_income)
|
||||
if max_avg_income is not None:
|
||||
clauses.append("a.avg_income <= %s")
|
||||
params.append(max_avg_income)
|
||||
if min_muslim_pct is not None:
|
||||
clauses.append("(a.religious_composition->>'muslim_proxy_pct')::float >= %s")
|
||||
params.append(min_muslim_pct)
|
||||
if has_area_data is True:
|
||||
clauses.append("a.id IS NOT NULL")
|
||||
elif has_area_data is False:
|
||||
clauses.append("a.id IS NULL")
|
||||
if min_halal_opportunity is not None:
|
||||
clauses.append("COALESCE(ros.halal_opportunity_score, s.halal_opportunity_score, 0) >= %s")
|
||||
params.append(min_halal_opportunity)
|
||||
if max_halal_opportunity is not None:
|
||||
clauses.append("COALESCE(ros.halal_opportunity_score, s.halal_opportunity_score, 0) <= %s")
|
||||
params.append(max_halal_opportunity)
|
||||
if has_phone is True:
|
||||
clauses.append("(s.phone IS NOT NULL AND s.phone <> '')")
|
||||
elif has_phone is False:
|
||||
clauses.append("(s.phone IS NULL OR s.phone = '')")
|
||||
if has_email is True:
|
||||
clauses.append("(s.email IS NOT NULL AND s.email <> '')")
|
||||
elif has_email is False:
|
||||
clauses.append("(s.email IS NULL OR s.email = '')")
|
||||
if has_manager is True:
|
||||
clauses.append("(s.manager_name IS NOT NULL OR sp.manager_name IS NOT NULL)")
|
||||
elif has_manager is False:
|
||||
clauses.append("(s.manager_name IS NULL AND sp.manager_name IS NULL)")
|
||||
if halal_gap_only:
|
||||
clauses.append("s.halal_certified = FALSE AND s.has_halal_section = FALSE")
|
||||
clauses.append("(a.religious_composition->>'muslim_proxy_pct')::float >= 5")
|
||||
if linked_to_crm is True:
|
||||
clauses.append("s.client_id IS NOT NULL")
|
||||
elif linked_to_crm is False:
|
||||
clauses.append("s.client_id IS NULL")
|
||||
if has_halal_cert_registry:
|
||||
clauses.append(
|
||||
"EXISTS (SELECT 1 FROM halal_certifications h WHERE h.supermarket_id = s.id AND h.status = 'active')"
|
||||
)
|
||||
|
||||
return clauses, params
|
||||
|
||||
|
||||
@router.get("/filters")
|
||||
def retail_filters() -> dict[str, Any]:
|
||||
chains = fetch_all(
|
||||
"SELECT chain, COUNT(*) AS n FROM supermarkets GROUP BY chain ORDER BY n DESC"
|
||||
)
|
||||
provinces = fetch_all(
|
||||
"""SELECT COALESCE(province, 'Onbekend') AS province, COUNT(*) AS n
|
||||
FROM supermarkets GROUP BY province ORDER BY n DESC"""
|
||||
)
|
||||
partnerships = fetch_all(
|
||||
"SELECT partnership_status, COUNT(*) AS n FROM supermarkets GROUP BY partnership_status"
|
||||
)
|
||||
status = retail_enrichment.enrichment_status()
|
||||
income = fetch_one(
|
||||
"""SELECT MIN(avg_income) AS min_income, MAX(avg_income) AS max_income,
|
||||
MIN(population) AS min_pop, MAX(population) AS max_pop
|
||||
FROM area_analysis WHERE avg_income IS NOT NULL"""
|
||||
)
|
||||
return {
|
||||
"chains": [dict(r) for r in chains],
|
||||
"provinces": [dict(r) for r in provinces],
|
||||
"partnerships": [dict(r) for r in partnerships],
|
||||
"enrichment": status,
|
||||
"ranges": {
|
||||
"min_income": float(income["min_income"]) if income and income.get("min_income") else None,
|
||||
"max_income": float(income["max_income"]) if income and income.get("max_income") else None,
|
||||
"min_population": int(income["min_pop"]) if income and income.get("min_pop") else None,
|
||||
"max_population": int(income["max_pop"]) if income and income.get("max_pop") else None,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/supermarkets")
|
||||
def list_supermarkets(
|
||||
chain: Optional[str] = None,
|
||||
province: Optional[str] = None,
|
||||
city: Optional[str] = None,
|
||||
partnership: Optional[str] = None,
|
||||
halal_certified: Optional[bool] = None,
|
||||
has_halal_section: Optional[bool] = None,
|
||||
store_type: Optional[str] = None,
|
||||
postcode_prefix: Optional[str] = None,
|
||||
q: Optional[str] = None,
|
||||
min_population: Optional[int] = Query(None, ge=0),
|
||||
max_population: Optional[int] = Query(None, ge=0),
|
||||
min_avg_income: Optional[float] = Query(None, ge=0),
|
||||
max_avg_income: Optional[float] = Query(None, ge=0),
|
||||
min_muslim_pct: Optional[float] = Query(None, ge=0, le=100),
|
||||
has_area_data: Optional[bool] = None,
|
||||
min_halal_opportunity: Optional[float] = Query(None, ge=0, le=100),
|
||||
max_halal_opportunity: Optional[float] = Query(None, ge=0, le=100),
|
||||
has_phone: Optional[bool] = None,
|
||||
has_email: Optional[bool] = None,
|
||||
has_manager: Optional[bool] = None,
|
||||
halal_gap_only: Optional[bool] = None,
|
||||
linked_to_crm: Optional[bool] = None,
|
||||
has_halal_cert_registry: Optional[bool] = None,
|
||||
sort: Optional[str] = Query("name", pattern="^(name|halal_opportunity|population|chain)$"),
|
||||
limit: int = Query(2000, ge=1, le=5000),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
clauses, params = _build_filters(
|
||||
chain, province, city, partnership, halal_certified, has_halal_section,
|
||||
store_type, postcode_prefix, q, min_population, max_population,
|
||||
min_avg_income, max_avg_income, min_muslim_pct, has_area_data,
|
||||
min_halal_opportunity, max_halal_opportunity, has_phone, has_email,
|
||||
has_manager, halal_gap_only, linked_to_crm, has_halal_cert_registry,
|
||||
)
|
||||
where = " WHERE " + " AND ".join(clauses)
|
||||
order = {
|
||||
"halal_opportunity": "COALESCE(ros.halal_opportunity_score,0) DESC, s.name",
|
||||
"population": "COALESCE(a.population,0) DESC, s.name",
|
||||
"chain": "s.chain, s.name",
|
||||
"name": "s.chain, s.name",
|
||||
}.get(sort or "name", "s.chain, s.name")
|
||||
rows = fetch_all(
|
||||
f"{STORE_SELECT}{where} ORDER BY {order} LIMIT %s OFFSET %s",
|
||||
tuple(params + [limit, offset]),
|
||||
)
|
||||
total = fetch_one(
|
||||
f"""SELECT COUNT(*) AS n FROM supermarkets s
|
||||
LEFT JOIN area_analysis a ON a.postcode = s.postcode
|
||||
LEFT JOIN retail_opportunity_scores ros ON ros.supermarket_id = s.id
|
||||
LEFT JOIN supermarket_profiles sp ON sp.supermarket_id = s.id
|
||||
{where}""",
|
||||
tuple(params),
|
||||
)
|
||||
return {
|
||||
"items": [_row(r) for r in rows],
|
||||
"count": len(rows),
|
||||
"total": int((total or {}).get("n") or 0),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/map")
|
||||
def map_points(
|
||||
chain: Optional[str] = None,
|
||||
province: Optional[str] = None,
|
||||
partnership: Optional[str] = None,
|
||||
halal_certified: Optional[bool] = None,
|
||||
has_halal_section: Optional[bool] = None,
|
||||
min_muslim_pct: Optional[float] = None,
|
||||
min_population: Optional[int] = None,
|
||||
min_halal_opportunity: Optional[float] = None,
|
||||
halal_gap_only: Optional[bool] = None,
|
||||
linked_to_crm: Optional[bool] = None,
|
||||
q: Optional[str] = None,
|
||||
limit: int = Query(5000, ge=1, le=5000),
|
||||
) -> dict[str, Any]:
|
||||
clauses, params = _build_filters(
|
||||
chain, province, None, partnership, halal_certified, has_halal_section,
|
||||
None, None, q, min_population, None, None, None, min_muslim_pct, None,
|
||||
min_halal_opportunity, None, None, None, None, halal_gap_only, linked_to_crm, None,
|
||||
)
|
||||
where = " WHERE " + " AND ".join(clauses) + " AND s.latitude IS NOT NULL AND s.longitude IS NOT NULL"
|
||||
rows = fetch_all(
|
||||
f"""SELECT s.id, s.name, s.chain, s.city, s.province, s.postcode,
|
||||
s.latitude, s.longitude, s.partnership_status, s.halal_certified,
|
||||
s.has_halal_section, s.phone, s.manager_name,
|
||||
a.population AS area_population,
|
||||
(a.religious_composition->>'muslim_proxy_pct')::float AS muslim_proxy_pct,
|
||||
COALESCE(ros.halal_opportunity_score, s.halal_opportunity_score) AS halal_opportunity_score
|
||||
FROM supermarkets s
|
||||
LEFT JOIN area_analysis a ON a.postcode = s.postcode
|
||||
LEFT JOIN retail_opportunity_scores ros ON ros.supermarket_id = s.id
|
||||
LEFT JOIN supermarket_profiles sp ON sp.supermarket_id = s.id
|
||||
{where} LIMIT %s""",
|
||||
tuple(params + [limit]),
|
||||
)
|
||||
return {"items": [_row(r) for r in rows], "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/supermarkets/{store_id}")
|
||||
def get_supermarket(store_id: int) -> dict[str, Any]:
|
||||
row = fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,))
|
||||
data = _row(row)
|
||||
area = fetch_one("SELECT * FROM area_analysis WHERE postcode = %s", (data.get("postcode"),))
|
||||
if area:
|
||||
data["area_analysis"] = _row(area)
|
||||
weather = fetch_all(
|
||||
"SELECT * FROM weather_data WHERE city ILIKE %s ORDER BY date DESC LIMIT 3",
|
||||
(f"%{data.get('city', '')}%",),
|
||||
)
|
||||
data["weather"] = [_row(w) for w in weather]
|
||||
recs = fetch_all(
|
||||
"""SELECT * FROM ai_recommendations
|
||||
WHERE related_entity_type = 'supermarket' AND related_entity_id = %s
|
||||
ORDER BY created_at DESC LIMIT 3""",
|
||||
(store_id,),
|
||||
)
|
||||
data["recommendations"] = [_row(r) for r in recs]
|
||||
nearby = fetch_all(
|
||||
"""
|
||||
SELECT id, name, chain, partnership_status, distance_km FROM (
|
||||
SELECT id, name, chain, partnership_status,
|
||||
(6371 * acos(
|
||||
LEAST(1.0, cos(radians(%s)) * cos(radians(latitude))
|
||||
* cos(radians(longitude) - radians(%s))
|
||||
+ sin(radians(%s)) * sin(radians(latitude)))
|
||||
)) AS distance_km
|
||||
FROM supermarkets
|
||||
WHERE id <> %s AND latitude IS NOT NULL AND longitude IS NOT NULL
|
||||
) nearby_q
|
||||
WHERE distance_km < 3
|
||||
ORDER BY distance_km LIMIT 8
|
||||
""",
|
||||
(
|
||||
data.get("latitude"), data.get("longitude"), data.get("latitude"),
|
||||
store_id,
|
||||
),
|
||||
)
|
||||
data["nearby_stores"] = [_row(n) for n in nearby]
|
||||
data["crm"] = retail_crm.get_store_crm_context(store_id)
|
||||
opp = fetch_one("SELECT * FROM retail_opportunity_scores WHERE supermarket_id = %s", (store_id,))
|
||||
if opp:
|
||||
data["opportunity"] = _row(opp)
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/scrape/chains")
|
||||
def list_scrape_chains() -> dict[str, Any]:
|
||||
return {"chains": retail_scrapers.list_chains()}
|
||||
|
||||
|
||||
@router.post("/scrape/all")
|
||||
def scrape_all_chains() -> dict[str, Any]:
|
||||
log_agent_event(agent_name="retail_scraper", event_type="scrape", title="OSM import all chains")
|
||||
return retail_scrapers.import_all_chains()
|
||||
|
||||
|
||||
@router.post("/scrape/{chain_key}")
|
||||
def scrape_chain(chain_key: str) -> dict[str, Any]:
|
||||
log_agent_event(
|
||||
agent_name="retail_scraper",
|
||||
event_type="scrape",
|
||||
title=f"OSM import {chain_key}",
|
||||
)
|
||||
try:
|
||||
return retail_scrapers.import_chain(chain_key.lower())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(502, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/enrich")
|
||||
def enrich_areas(
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
log_agent_event(
|
||||
agent_name="retail_enrichment",
|
||||
event_type="enrich",
|
||||
title=f"CBS/PDOK enrichment batch limit={limit}",
|
||||
)
|
||||
return retail_enrichment.enrich_batch(limit=limit, offset=offset)
|
||||
|
||||
|
||||
@router.get("/enrich/status")
|
||||
def enrich_status() -> dict[str, Any]:
|
||||
return retail_enrichment.enrichment_status()
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def retail_stats(
|
||||
chain: Optional[str] = None,
|
||||
province: Optional[str] = None,
|
||||
partnership: Optional[str] = None,
|
||||
min_muslim_pct: Optional[float] = None,
|
||||
) -> dict[str, Any]:
|
||||
clauses, params = _build_filters(
|
||||
chain, province, None, partnership, None, None, None, None, None,
|
||||
None, None, None, None, min_muslim_pct, None,
|
||||
)
|
||||
where = " WHERE " + " AND ".join(clauses)
|
||||
row = fetch_one(
|
||||
f"""
|
||||
SELECT COUNT(*) AS total,
|
||||
COUNT(*) FILTER (WHERE s.partnership_status = 'active') AS active_partnerships,
|
||||
COUNT(*) FILTER (WHERE s.halal_certified) AS halal_certified,
|
||||
COUNT(*) FILTER (WHERE a.id IS NOT NULL) AS with_area_data,
|
||||
ROUND(AVG(a.avg_income)::numeric, 0) AS avg_area_income,
|
||||
ROUND(AVG((a.religious_composition->>'muslim_proxy_pct')::float)::numeric, 1) AS avg_muslim_proxy_pct
|
||||
FROM supermarkets s
|
||||
LEFT JOIN area_analysis a ON a.postcode = s.postcode
|
||||
{where}
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
out = {k: int(v or 0) if k in ("total", "active_partnerships", "halal_certified", "with_area_data") else v
|
||||
for k, v in (row or {}).items()}
|
||||
if out.get("avg_area_income") is not None:
|
||||
out["avg_area_income"] = float(out["avg_area_income"])
|
||||
if out.get("avg_muslim_proxy_pct") is not None:
|
||||
out["avg_muslim_proxy_pct"] = float(out["avg_muslim_proxy_pct"])
|
||||
halal_n = fetch_one("SELECT COUNT(*) AS n FROM supermarkets WHERE halal_certified = TRUE")
|
||||
out["halal_certified_count"] = int((halal_n or {}).get("n") or 0)
|
||||
return out
|
||||
|
||||
|
||||
@router.get("/schema")
|
||||
def retail_schema() -> dict[str, Any]:
|
||||
return {"groups": FIELD_SCHEMA, "all_fields": [f for fields in FIELD_SCHEMA.values() for f in fields]}
|
||||
|
||||
|
||||
@router.get("/crm/options")
|
||||
def crm_options() -> dict[str, Any]:
|
||||
return retail_crm.list_crm_options()
|
||||
|
||||
|
||||
@router.post("/supermarkets/{store_id}/link")
|
||||
def link_store_crm(store_id: int, payload: CrmLinkIn) -> dict[str, Any]:
|
||||
try:
|
||||
row = retail_crm.link_client_to_store(
|
||||
store_id, payload.client_id, payload.deal_id,
|
||||
payload.relationship_type, payload.partnership_status, payload.notes,
|
||||
)
|
||||
log_agent_event(agent_name="retail_crm", event_type="link", title=f"Linked store {store_id} to client {payload.client_id}")
|
||||
return {"link": row}
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
|
||||
|
||||
@router.delete("/supermarkets/{store_id}/link/{client_id}")
|
||||
def unlink_store_crm(store_id: int, client_id: int) -> dict[str, Any]:
|
||||
ok = retail_crm.unlink_client_from_store(store_id, client_id)
|
||||
return {"unlinked": ok}
|
||||
|
||||
|
||||
@router.get("/halal")
|
||||
def list_halal_stores(limit: int = Query(500, ge=1, le=2000)) -> dict[str, Any]:
|
||||
rows = halal_registry.list_halal_certified(limit)
|
||||
return {"items": [_row(r) for r in rows], "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/opportunities")
|
||||
def list_opportunities(
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
min_score: float = Query(30, ge=0, le=100),
|
||||
chain: Optional[str] = None,
|
||||
province: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
rows = retail_opportunities.top_opportunities(limit, min_score, chain, province)
|
||||
return {"items": [_row(r) for r in rows], "count": len(rows)}
|
||||
|
||||
|
||||
@router.get("/trends")
|
||||
def list_trends(limit: int = Query(20, ge=1, le=100)) -> dict[str, Any]:
|
||||
rows = trends_feed.list_live_trends(limit)
|
||||
return {"items": [_row(r) for r in rows], "count": len(rows)}
|
||||
|
||||
|
||||
@router.post("/sync/halal")
|
||||
def sync_halal() -> dict[str, Any]:
|
||||
log_agent_event(agent_name="halal_registry", event_type="sync", title="Halal OSM sync")
|
||||
return halal_registry.sync_osm_halal_tags()
|
||||
|
||||
|
||||
@router.post("/sync/contacts")
|
||||
def sync_contacts(limit: int = Query(100, ge=1, le=300)) -> dict[str, Any]:
|
||||
log_agent_event(agent_name="branch_scraper", event_type="sync", title=f"OSM contacts limit={limit}")
|
||||
return halal_registry.sync_osm_contact_tags(limit)
|
||||
|
||||
|
||||
@router.post("/sync/trends")
|
||||
def sync_trends() -> dict[str, Any]:
|
||||
return trends_feed.refresh_trends_from_social()
|
||||
|
||||
|
||||
@router.post("/compute-opportunities")
|
||||
def compute_opportunities(limit: int = Query(5000, ge=100, le=10000)) -> dict[str, Any]:
|
||||
log_agent_event(agent_name="retail_intel", event_type="score", title="Compute halal opportunity scores")
|
||||
return retail_opportunities.compute_all_scores(limit)
|
||||
|
||||
|
||||
@router.get("/export")
|
||||
def export_csv(
|
||||
chain: Optional[str] = None,
|
||||
province: Optional[str] = None,
|
||||
halal_certified: Optional[bool] = None,
|
||||
min_halal_opportunity: Optional[float] = None,
|
||||
limit: int = Query(5000, ge=1, le=5000),
|
||||
):
|
||||
clauses, params = _build_filters(
|
||||
chain, province, None, None, halal_certified, None, None, None, None,
|
||||
None, None, None, None, None, None, min_halal_opportunity, None,
|
||||
None, None, None, None, None, None,
|
||||
)
|
||||
where = " WHERE " + " AND ".join(clauses)
|
||||
rows = fetch_all(
|
||||
f"{STORE_SELECT}{where} ORDER BY s.chain, s.name LIMIT %s",
|
||||
tuple(params + [limit]),
|
||||
)
|
||||
|
||||
def generate():
|
||||
headers = ["id", "name", "chain", "city", "province", "postcode", "phone", "email",
|
||||
"manager_name", "halal_certified", "has_halal_section", "partnership_status",
|
||||
"muslim_proxy_pct", "area_population", "area_avg_income", "halal_opportunity_score"]
|
||||
yield ",".join(headers) + "\n"
|
||||
for r in rows:
|
||||
rel = r.get("area_religious") or {}
|
||||
if isinstance(rel, str):
|
||||
rel = {}
|
||||
vals = [
|
||||
r.get("id"), r.get("name"), r.get("chain"), r.get("city"), r.get("province"),
|
||||
r.get("postcode"), r.get("phone"), r.get("email"), r.get("manager_name"),
|
||||
r.get("halal_certified"), r.get("has_halal_section"), r.get("partnership_status"),
|
||||
rel.get("muslim_proxy_pct") if isinstance(rel, dict) else None,
|
||||
r.get("area_population"), r.get("area_avg_income"),
|
||||
r.get("opp_halal_score") or r.get("halal_opportunity_score"),
|
||||
]
|
||||
yield ",".join('"' + str(v or "").replace('"', '""') + '"' for v in vals) + "\n"
|
||||
|
||||
return StreamingResponse(generate(), media_type="text/csv", headers={"Content-Disposition": "attachment; filename=retail_export.csv"})
|
||||
Reference in New Issue
Block a user