Files
foodlinkk-command-center/tools-api/app/retail.py
T
2026-06-09 10:41:13 +00:00

655 lines
26 KiB
Python

"""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, 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
class SupermarketCreateIn(BaseModel):
name: str
chain: str
address: str
postcode: str = "0000AA"
city: str
province: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
manager_name: Optional[str] = None
employee_count: Optional[int] = None
store_type: Optional[str] = None
partnership_status: str = "none"
halal_certified: bool = False
has_halal_section: bool = False
halal_certifier: Optional[str] = None
data_source: str = "manual"
class SupermarketPatchIn(BaseModel):
name: Optional[str] = None
chain: Optional[str] = None
address: Optional[str] = None
postcode: Optional[str] = None
city: Optional[str] = None
province: Optional[str] = None
phone: Optional[str] = None
email: Optional[str] = None
website: Optional[str] = None
manager_name: Optional[str] = None
employee_count: Optional[int] = None
store_type: Optional[str] = None
size_m2: Optional[int] = None
partnership_status: Optional[str] = None
halal_certified: Optional[bool] = None
has_halal_section: Optional[bool] = None
halal_certifier: Optional[str] = None
halal_certificate_number: Optional[str] = None
organic_section: Optional[bool] = None
alcohol_section: Optional[bool] = 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.post("/supermarkets")
def create_supermarket(payload: SupermarketCreateIn) -> dict[str, Any]:
row = execute_returning(
"""INSERT INTO supermarkets (
name, chain, address, postcode, city, province, phone, email, website,
manager_name, employee_count, store_type, partnership_status,
halal_certified, has_halal_section, halal_certifier, data_source, last_updated
) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,NOW())
RETURNING id""",
(
payload.name.strip(), payload.chain.strip(), payload.address.strip(),
payload.postcode.strip().upper(), payload.city.strip(), payload.province,
payload.phone, payload.email, payload.website, payload.manager_name,
payload.employee_count, payload.store_type, payload.partnership_status,
payload.halal_certified, payload.has_halal_section, payload.halal_certifier,
payload.data_source,
),
)
store_id = int(row["id"])
log_agent_event(
agent_name="retail_crm",
event_type="create",
title=f"Handmatig filiaal toegevoegd: {payload.name} ({payload.chain})",
)
return {"ok": True, "item": _row(fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)))}
@router.patch("/supermarkets/{store_id}")
def patch_supermarket(store_id: int, payload: SupermarketPatchIn) -> dict[str, Any]:
existing = fetch_one("SELECT id FROM supermarkets WHERE id = %s", (store_id,))
if not existing:
raise HTTPException(404, "Supermarket not found")
data = payload.model_dump(exclude_unset=True)
if not data:
raise HTTPException(400, "Geen velden om bij te werken")
allowed = set(SupermarketPatchIn.model_fields.keys())
sets: list[str] = []
params: list[Any] = []
for key, val in data.items():
if key not in allowed:
continue
sets.append(f"{key} = %s")
params.append(val)
sets.append("last_updated = NOW()")
params.append(store_id)
execute(f"UPDATE supermarkets SET {', '.join(sets)} WHERE id = %s", tuple(params))
log_agent_event(
agent_name="retail_crm",
event_type="update",
title=f"Supermarkt #{store_id} data aangevuld",
)
return {"ok": True, "item": _row(fetch_one(f"{STORE_SELECT} WHERE s.id = %s", (store_id,)))}
@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"})