SysOps: voice-agy-webbuilder-backup — 2026-06-23 10:04 UTC
This commit is contained in:
@@ -0,0 +1,842 @@
|
||||
"""Export Intel API — Foodlinkk B2B global export intelligence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.db import execute, execute_returning, fetch_all, fetch_one
|
||||
from app.export_halal_scoring import COUNTRY_HALAL_MARKET, halal_pin_tier, score_entity
|
||||
from app.export_intel_crm import push_entities_to_crm
|
||||
|
||||
HALAL_REASON_NL: dict[str, str] = {
|
||||
"sterke_halal_markt": "Sterke halal-markt in dit land",
|
||||
"halal_in_naam": "Halal expliciet in bedrijfsnaam",
|
||||
"halal_signaal": "Kebab/döner/vlees-signaal in naam",
|
||||
"direct_vlees_kanaal": "Direct vlees-verkoopkanaal",
|
||||
"B2B_kanaal": "B2B inkoop / distributie",
|
||||
"heeft_contact": "Contactpersoon in registry",
|
||||
}
|
||||
|
||||
|
||||
def _halal_reason_labels(reasons: list[str]) -> list[str]:
|
||||
out: list[str] = []
|
||||
for r in reasons:
|
||||
if r in HALAL_REASON_NL:
|
||||
out.append(HALAL_REASON_NL[r])
|
||||
elif r.startswith("type:"):
|
||||
out.append("Type: " + r.split(":", 1)[1].replace("_", " "))
|
||||
else:
|
||||
out.append(r.replace("_", " "))
|
||||
return out
|
||||
|
||||
|
||||
def _enrich_entity(row: dict[str, Any], contacts: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
row["contacts"] = contacts
|
||||
row["contact_count"] = len(contacts)
|
||||
for c in contacts:
|
||||
if not row.get("email") and c.get("email"):
|
||||
row["email"] = c["email"]
|
||||
if not row.get("phone"):
|
||||
phone = c.get("phone") or c.get("mobile")
|
||||
if phone:
|
||||
row["phone"] = phone
|
||||
primary = contacts[0] if contacts else None
|
||||
row["primary_contact"] = primary
|
||||
h_score, h_reasons = score_entity(row)
|
||||
row["halal_score"] = h_score
|
||||
row["halal_reasons"] = h_reasons
|
||||
row["halal_reason_labels"] = _halal_reason_labels(h_reasons)
|
||||
if row.get("lat") and row.get("lon"):
|
||||
row["maps_url"] = f"https://www.google.com/maps?q={row['lat']},{row['lon']}"
|
||||
return row
|
||||
from app.export_intel_sync import (
|
||||
enrich_contacts_country,
|
||||
materialize_caterer_presence,
|
||||
sync_all_priority,
|
||||
sync_caterers,
|
||||
sync_contacts,
|
||||
sync_customers,
|
||||
sync_distributors,
|
||||
sync_osm_country,
|
||||
sync_ted_tenders,
|
||||
sync_world_regions,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/export-intel", tags=["export-intel"])
|
||||
|
||||
DISTRIBUTOR_TYPES = ("distributor", "wholesaler", "importer", "logistics", "cold_storage", "port_agent")
|
||||
CUSTOMER_TYPES = ("restaurant", "doner_shoarma", "butcher", "foodservice")
|
||||
CATERER_TYPES = ("contract_caterer")
|
||||
|
||||
|
||||
class ContactCreateIn(BaseModel):
|
||||
role: str = "general"
|
||||
name: Optional[str] = None
|
||||
email: Optional[str] = None
|
||||
phone: Optional[str] = None
|
||||
mobile: Optional[str] = None
|
||||
is_primary: bool = False
|
||||
|
||||
|
||||
class SyncRequestIn(BaseModel):
|
||||
country_iso2: Optional[str] = None
|
||||
region_code: Optional[str] = None
|
||||
max_priority: int = 2
|
||||
entity_types: Optional[list[str]] = None
|
||||
|
||||
|
||||
class EntityIdsIn(BaseModel):
|
||||
entity_ids: list[int]
|
||||
|
||||
|
||||
class FavoritesIn(BaseModel):
|
||||
entity_ids: list[int]
|
||||
favorite: bool = True
|
||||
|
||||
|
||||
class CrmPushIn(BaseModel):
|
||||
entity_ids: list[int]
|
||||
create_deals: bool = True
|
||||
client_stage: str = "intake"
|
||||
deal_stage: str = "lead"
|
||||
|
||||
|
||||
def _entity_filters(
|
||||
country: Optional[str],
|
||||
region: Optional[str],
|
||||
entity_type: Optional[str],
|
||||
entity_types: Optional[str],
|
||||
favorite_only: bool = False,
|
||||
crm_linked: Optional[bool] = None,
|
||||
) -> tuple[str, list[Any]]:
|
||||
clauses: list[str] = ["1=1"]
|
||||
params: list[Any] = []
|
||||
if country:
|
||||
clauses.append("e.country_iso2 = %s")
|
||||
params.append(country.upper())
|
||||
if region:
|
||||
clauses.append("t.region_code = %s")
|
||||
params.append(region)
|
||||
clauses.append("e.territory_code = t.code")
|
||||
if entity_type:
|
||||
clauses.append("e.entity_type = %s")
|
||||
params.append(entity_type)
|
||||
if entity_types:
|
||||
types = [t.strip() for t in entity_types.split(",") if t.strip()]
|
||||
if types:
|
||||
clauses.append("e.entity_type = ANY(%s)")
|
||||
params.append(types)
|
||||
if favorite_only:
|
||||
clauses.append("e.is_favorite = TRUE")
|
||||
if crm_linked is True:
|
||||
clauses.append("e.client_id IS NOT NULL")
|
||||
elif crm_linked is False:
|
||||
clauses.append("e.client_id IS NULL")
|
||||
return " AND ".join(clauses), params
|
||||
|
||||
|
||||
@router.get("/stats")
|
||||
def export_stats(
|
||||
country: Optional[str] = Query(None),
|
||||
region: Optional[str] = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
entity_where = ""
|
||||
params: list[Any] = []
|
||||
if country:
|
||||
entity_where = " WHERE country_iso2 = %s"
|
||||
params = [country.upper()]
|
||||
elif region:
|
||||
entity_where = " WHERE territory_code IN (SELECT code FROM export_territories WHERE region_code = %s)"
|
||||
params = [region]
|
||||
|
||||
def count(sql_suffix: str, extra_params: tuple[Any, ...] = ()) -> int:
|
||||
w = entity_where
|
||||
if sql_suffix:
|
||||
w = f"{entity_where} AND {sql_suffix}" if entity_where else f" WHERE {sql_suffix}"
|
||||
row = fetch_one(
|
||||
f"SELECT COUNT(*) AS n FROM export_market_entities{w}",
|
||||
tuple(params) + extra_params if params or extra_params else None,
|
||||
)
|
||||
return int(row["n"] or 0)
|
||||
|
||||
entities_n = count("")
|
||||
distributors_n = count("entity_type IN ('distributor','wholesaler','importer','logistics')")
|
||||
caterers_n = count("entity_type = 'contract_caterer'")
|
||||
contacts = fetch_one("SELECT COUNT(*) AS n FROM export_entity_contacts")
|
||||
with_email = fetch_one(
|
||||
"SELECT COUNT(DISTINCT entity_id) AS n FROM export_entity_contacts WHERE email IS NOT NULL AND email <> ''"
|
||||
)
|
||||
territories = fetch_one("SELECT COUNT(*) AS n FROM export_territories WHERE is_active = TRUE")
|
||||
presence = fetch_one("SELECT COUNT(*) AS n FROM export_caterer_presence WHERE is_active = TRUE")
|
||||
tenders_open = 0
|
||||
try:
|
||||
row = fetch_one("SELECT COUNT(*) AS n FROM export_tenders WHERE status = 'open'")
|
||||
tenders_open = int(row["n"] or 0) if row else 0
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
fav_row = fetch_one("SELECT COUNT(*) AS n FROM export_market_entities WHERE is_favorite = TRUE")
|
||||
crm_row = fetch_one("SELECT COUNT(*) AS n FROM export_market_entities WHERE client_id IS NOT NULL")
|
||||
|
||||
return {
|
||||
"entities": entities_n,
|
||||
"distributors": distributors_n,
|
||||
"caterers": caterers_n,
|
||||
"contacts": int(contacts["n"] or 0),
|
||||
"entities_with_email": int(with_email["n"] or 0),
|
||||
"territories": int(territories["n"] or 0),
|
||||
"caterer_presence": int(presence["n"] or 0),
|
||||
"tenders_open": tenders_open,
|
||||
"favorites": int(fav_row["n"] or 0) if fav_row else 0,
|
||||
"crm_linked": int(crm_row["n"] or 0) if crm_row else 0,
|
||||
"phase": "B",
|
||||
"enrichment_status": "active",
|
||||
}
|
||||
|
||||
|
||||
@router.get("/regions")
|
||||
def list_regions() -> list[dict[str, Any]]:
|
||||
return fetch_all("SELECT * FROM export_regions ORDER BY sort_order, code")
|
||||
|
||||
|
||||
@router.get("/territories")
|
||||
def list_territories(region: Optional[str] = Query(None)) -> list[dict[str, Any]]:
|
||||
if region:
|
||||
return fetch_all(
|
||||
"SELECT * FROM export_territories WHERE is_active = TRUE AND region_code = %s ORDER BY sync_priority, name_nl",
|
||||
(region,),
|
||||
)
|
||||
return fetch_all(
|
||||
"SELECT * FROM export_territories WHERE is_active = TRUE ORDER BY sync_priority, name_nl"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/entities")
|
||||
def list_entities(
|
||||
country: Optional[str] = Query(None),
|
||||
region: Optional[str] = Query(None),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_types: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
favorite_only: bool = Query(False),
|
||||
crm_linked: Optional[bool] = Query(None),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
join = ""
|
||||
if region:
|
||||
join = "JOIN export_territories t ON e.territory_code = t.code"
|
||||
where_sql, params = _entity_filters(
|
||||
country, region, entity_type, entity_types, favorite_only, crm_linked
|
||||
)
|
||||
if q:
|
||||
where_sql += " AND (e.name ILIKE %s OR e.city ILIKE %s)"
|
||||
params.extend([f"%{q}%", f"%{q}%"])
|
||||
|
||||
rows = fetch_all(
|
||||
f"""
|
||||
SELECT e.*,
|
||||
cl.name AS crm_client_name,
|
||||
(SELECT COUNT(*) FROM export_entity_contacts ec WHERE ec.entity_id = e.id) AS contact_count,
|
||||
(SELECT ec.email FROM export_entity_contacts ec WHERE ec.entity_id = e.id AND ec.is_primary = TRUE LIMIT 1) AS primary_email,
|
||||
(SELECT ec.phone FROM export_entity_contacts ec WHERE ec.entity_id = e.id AND ec.is_primary = TRUE LIMIT 1) AS primary_phone
|
||||
FROM export_market_entities e
|
||||
LEFT JOIN clients cl ON cl.id = e.client_id
|
||||
{join}
|
||||
WHERE {where_sql}
|
||||
ORDER BY e.confidence DESC, e.name
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params) + (limit, offset),
|
||||
)
|
||||
total = fetch_one(
|
||||
f"SELECT COUNT(*) AS n FROM export_market_entities e LEFT JOIN clients cl ON cl.id = e.client_id {join} WHERE {where_sql}",
|
||||
tuple(params),
|
||||
)
|
||||
return {"items": rows, "total": int(total["n"] or 0), "limit": limit, "offset": offset}
|
||||
|
||||
|
||||
@router.get("/entities/{entity_id}")
|
||||
def get_entity(entity_id: int) -> dict[str, Any]:
|
||||
row = fetch_one(
|
||||
"""
|
||||
SELECT e.*, cl.name AS crm_client_name, d.title AS crm_deal_title, d.stage AS crm_deal_stage
|
||||
FROM export_market_entities e
|
||||
LEFT JOIN clients cl ON cl.id = e.client_id
|
||||
LEFT JOIN deals d ON d.id = e.deal_id
|
||||
WHERE e.id = %s
|
||||
""",
|
||||
(entity_id,),
|
||||
)
|
||||
if not row:
|
||||
raise HTTPException(404, "Entity not found")
|
||||
contacts = fetch_all(
|
||||
"SELECT * FROM export_entity_contacts WHERE entity_id = %s ORDER BY is_primary DESC, confidence DESC",
|
||||
(entity_id,),
|
||||
)
|
||||
return _enrich_entity(row, contacts)
|
||||
|
||||
|
||||
@router.get("/contacts")
|
||||
def list_contacts(
|
||||
country: Optional[str] = Query(None),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
has_email: Optional[bool] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
) -> dict[str, Any]:
|
||||
clauses = ["1=1"]
|
||||
params: list[Any] = []
|
||||
if country:
|
||||
clauses.append("e.country_iso2 = %s")
|
||||
params.append(country.upper())
|
||||
if entity_type:
|
||||
clauses.append("e.entity_type = %s")
|
||||
params.append(entity_type)
|
||||
if has_email:
|
||||
clauses.append("c.email IS NOT NULL AND c.email <> ''")
|
||||
if q:
|
||||
clauses.append(
|
||||
"(e.name ILIKE %s OR c.email ILIKE %s OR c.name ILIKE %s OR e.city ILIKE %s)"
|
||||
)
|
||||
like = f"%{q}%"
|
||||
params.extend([like, like, like, like])
|
||||
|
||||
where = " AND ".join(clauses)
|
||||
rows = fetch_all(
|
||||
f"""
|
||||
SELECT c.*, e.name AS entity_name, e.entity_type, e.country_iso2, e.city AS entity_city
|
||||
FROM export_entity_contacts c
|
||||
JOIN export_market_entities e ON e.id = c.entity_id
|
||||
WHERE {where}
|
||||
ORDER BY e.name, c.is_primary DESC
|
||||
LIMIT %s OFFSET %s
|
||||
""",
|
||||
tuple(params) + (limit, offset),
|
||||
)
|
||||
total = fetch_one(
|
||||
f"""
|
||||
SELECT COUNT(*) AS n FROM export_entity_contacts c
|
||||
JOIN export_market_entities e ON e.id = c.entity_id
|
||||
WHERE {where}
|
||||
""",
|
||||
tuple(params),
|
||||
)
|
||||
return {"items": rows, "total": int(total["n"] or 0)}
|
||||
|
||||
|
||||
@router.get("/contacts/export.csv")
|
||||
def export_contacts_csv(
|
||||
country: Optional[str] = Query(None),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
) -> StreamingResponse:
|
||||
data = list_contacts(country=country, entity_type=entity_type, limit=5000, offset=0)
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(
|
||||
["entity_name", "entity_type", "country", "role", "name", "email", "phone", "mobile", "source", "confidence"]
|
||||
)
|
||||
for r in data["items"]:
|
||||
writer.writerow(
|
||||
[
|
||||
r.get("entity_name"),
|
||||
r.get("entity_type"),
|
||||
r.get("country_iso2"),
|
||||
r.get("role"),
|
||||
r.get("name"),
|
||||
r.get("email"),
|
||||
r.get("phone"),
|
||||
r.get("mobile"),
|
||||
r.get("source"),
|
||||
r.get("confidence"),
|
||||
]
|
||||
)
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([buf.getvalue()]),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=export-intel-contacts.csv"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/entities/{entity_id}/contacts")
|
||||
def add_contact(entity_id: int, payload: ContactCreateIn) -> dict[str, Any]:
|
||||
ent = fetch_one("SELECT id FROM export_market_entities WHERE id = %s", (entity_id,))
|
||||
if not ent:
|
||||
raise HTTPException(404, "Entity not found")
|
||||
row = execute_returning(
|
||||
"""
|
||||
INSERT INTO export_entity_contacts (entity_id, role, name, email, phone, mobile, is_primary, source, confidence)
|
||||
VALUES (%s, %s, %s, %s, %s, %s, %s, 'manual', 80)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
entity_id,
|
||||
payload.role,
|
||||
payload.name,
|
||||
payload.email,
|
||||
payload.phone,
|
||||
payload.mobile,
|
||||
payload.is_primary,
|
||||
),
|
||||
)
|
||||
return dict(row)
|
||||
|
||||
|
||||
@router.get("/caterers/brands")
|
||||
def list_caterer_brands() -> list[dict[str, Any]]:
|
||||
return fetch_all(
|
||||
"""
|
||||
SELECT b.*,
|
||||
(SELECT COUNT(*) FROM export_caterer_presence p WHERE p.brand_code = b.code AND p.is_active) AS country_count
|
||||
FROM export_caterer_brands b
|
||||
ORDER BY b.tier, b.name
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@router.get("/caterers/presence")
|
||||
def list_caterer_presence(country: Optional[str] = Query(None)) -> list[dict[str, Any]]:
|
||||
if country:
|
||||
return fetch_all(
|
||||
"""
|
||||
SELECT p.*, b.name AS brand_name, b.tier, b.website AS brand_website
|
||||
FROM export_caterer_presence p
|
||||
JOIN export_caterer_brands b ON b.code = p.brand_code
|
||||
WHERE p.country_iso2 = %s AND p.is_active = TRUE
|
||||
ORDER BY b.tier, b.name
|
||||
""",
|
||||
(country.upper(),),
|
||||
)
|
||||
return fetch_all(
|
||||
"""
|
||||
SELECT p.*, b.name AS brand_name, b.tier, b.website AS brand_website
|
||||
FROM export_caterer_presence p
|
||||
JOIN export_caterer_brands b ON b.code = p.brand_code
|
||||
WHERE p.is_active = TRUE
|
||||
ORDER BY p.country_iso2, b.tier, b.name
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
@router.get("/gov-sources")
|
||||
def list_gov_sources(country: Optional[str] = Query(None)) -> list[dict[str, Any]]:
|
||||
if country:
|
||||
return fetch_all(
|
||||
"SELECT * FROM export_gov_data_sources WHERE is_active = TRUE AND country_iso2 = %s ORDER BY category, name",
|
||||
(country.upper(),),
|
||||
)
|
||||
return fetch_all(
|
||||
"SELECT * FROM export_gov_data_sources WHERE is_active = TRUE ORDER BY country_iso2, category, name"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tenders")
|
||||
def list_tenders(
|
||||
country: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query("open"),
|
||||
limit: int = Query(100, ge=1, le=200),
|
||||
) -> dict[str, Any]:
|
||||
clauses = ["1=1"]
|
||||
params: list[Any] = []
|
||||
if country:
|
||||
clauses.append("country_iso2 = %s")
|
||||
params.append(country.upper())
|
||||
if status:
|
||||
clauses.append("status = %s")
|
||||
params.append(status)
|
||||
where = " AND ".join(clauses)
|
||||
rows = fetch_all(
|
||||
f"SELECT * FROM export_tenders WHERE {where} ORDER BY created_at DESC LIMIT %s",
|
||||
tuple(params) + (limit,),
|
||||
)
|
||||
total = fetch_one(f"SELECT COUNT(*) AS n FROM export_tenders WHERE {where}", tuple(params))
|
||||
return {"items": rows, "total": int(total["n"] or 0)}
|
||||
|
||||
|
||||
@router.get("/map/bundle")
|
||||
def map_bundle(
|
||||
country: Optional[str] = Query(None),
|
||||
region: Optional[str] = Query(None),
|
||||
entity_type: Optional[str] = Query(None),
|
||||
entity_types: Optional[str] = Query(None),
|
||||
q: Optional[str] = Query(None),
|
||||
halal_min: Optional[float] = Query(None, ge=0, le=100),
|
||||
favorite_only: bool = Query(False),
|
||||
crm_linked: Optional[bool] = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
join = ""
|
||||
if region:
|
||||
join = "JOIN export_territories t ON e.territory_code = t.code"
|
||||
filter_sql, params = _entity_filters(
|
||||
country, region, entity_type, entity_types, favorite_only, crm_linked
|
||||
)
|
||||
where = f"e.lat IS NOT NULL AND e.lon IS NOT NULL AND {filter_sql}"
|
||||
if q:
|
||||
where += " AND (e.name ILIKE %s OR e.city ILIKE %s)"
|
||||
params.extend([f"%{q}%", f"%{q}%"])
|
||||
|
||||
entities = fetch_all(
|
||||
f"""
|
||||
SELECT e.id, e.name, e.entity_type, e.country_iso2, e.lat, e.lon,
|
||||
e.confidence, e.pipeline_stage, e.city, e.address_line,
|
||||
e.email, e.phone, e.website, e.volume_band, e.source,
|
||||
e.is_favorite, e.client_id, e.deal_id, e.crm_pushed_at,
|
||||
e.product_interest, e.halal_cert_notes,
|
||||
(SELECT c.email FROM export_entity_contacts c
|
||||
WHERE c.entity_id = e.id AND c.email IS NOT NULL AND c.email <> ''
|
||||
ORDER BY c.is_primary DESC NULLS LAST LIMIT 1) AS contact_email,
|
||||
(SELECT c.phone FROM export_entity_contacts c
|
||||
WHERE c.entity_id = e.id AND c.phone IS NOT NULL AND c.phone <> ''
|
||||
ORDER BY c.is_primary DESC NULLS LAST LIMIT 1) AS contact_phone,
|
||||
(SELECT COUNT(*) FROM export_entity_contacts c WHERE c.entity_id = e.id) AS contact_count
|
||||
FROM export_market_entities e
|
||||
{join}
|
||||
WHERE {where}
|
||||
LIMIT 20000
|
||||
""",
|
||||
tuple(params) if params else None,
|
||||
)
|
||||
|
||||
scored: list[tuple[dict[str, Any], float, list[str]]] = []
|
||||
halal_by_country: dict[str, dict[str, Any]] = {}
|
||||
for row in entities:
|
||||
if row.get("lat") is None or row.get("lon") is None:
|
||||
continue
|
||||
h_score, h_reasons = score_entity(row)
|
||||
iso = row.get("country_iso2") or ""
|
||||
bucket = halal_by_country.setdefault(
|
||||
iso,
|
||||
{"country_iso2": iso, "market_score": COUNTRY_HALAL_MARKET.get(iso, 40), "entity_count": 0, "avg_halal_score": 0.0, "hot_count": 0},
|
||||
)
|
||||
bucket["entity_count"] += 1
|
||||
bucket["avg_halal_score"] += h_score
|
||||
if h_score >= 75:
|
||||
bucket["hot_count"] += 1
|
||||
scored.append((row, h_score, h_reasons))
|
||||
|
||||
for bucket in halal_by_country.values():
|
||||
n = bucket["entity_count"] or 1
|
||||
bucket["avg_halal_score"] = round(bucket["avg_halal_score"] / n, 1)
|
||||
bucket["combined_score"] = round(bucket["market_score"] * 0.4 + bucket["avg_halal_score"] * 0.6, 1)
|
||||
|
||||
if halal_min is not None:
|
||||
scored = [s for s in scored if s[1] >= halal_min]
|
||||
|
||||
scored.sort(key=lambda x: x[1], reverse=True)
|
||||
|
||||
features = [
|
||||
{
|
||||
"type": "Feature",
|
||||
"geometry": {"type": "Point", "coordinates": [row["lon"], row["lat"]]},
|
||||
"properties": {
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"entity_type": row["entity_type"],
|
||||
"country_iso2": row["country_iso2"],
|
||||
"confidence": row["confidence"],
|
||||
"pipeline_stage": row["pipeline_stage"],
|
||||
"city": row.get("city"),
|
||||
"address_line": row.get("address_line"),
|
||||
"email": row.get("email") or row.get("contact_email"),
|
||||
"phone": row.get("phone") or row.get("contact_phone"),
|
||||
"website": row.get("website"),
|
||||
"volume_band": row.get("volume_band"),
|
||||
"source": row.get("source"),
|
||||
"contact_count": int(row.get("contact_count") or 0),
|
||||
"halal_score": h_score,
|
||||
"halal_tier": halal_pin_tier(h_score),
|
||||
"halal_reasons": h_reasons[:4],
|
||||
"is_favorite": bool(row.get("is_favorite")),
|
||||
"client_id": row.get("client_id"),
|
||||
"deal_id": row.get("deal_id"),
|
||||
},
|
||||
}
|
||||
for row, h_score, h_reasons in scored
|
||||
]
|
||||
|
||||
top_markets = sorted(halal_by_country.values(), key=lambda x: x["combined_score"], reverse=True)[:15]
|
||||
top_entities = [
|
||||
{
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"entity_type": row["entity_type"],
|
||||
"country_iso2": row["country_iso2"],
|
||||
"city": row.get("city"),
|
||||
"address_line": row.get("address_line"),
|
||||
"email": row.get("email") or row.get("contact_email"),
|
||||
"phone": row.get("phone") or row.get("contact_phone"),
|
||||
"website": row.get("website"),
|
||||
"halal_score": h_score,
|
||||
"halal_reason_labels": _halal_reason_labels(h_reasons),
|
||||
"is_favorite": bool(row.get("is_favorite")),
|
||||
"client_id": row.get("client_id"),
|
||||
"has_contact": bool(
|
||||
row.get("email") or row.get("contact_email")
|
||||
or row.get("phone") or row.get("contact_phone")
|
||||
or int(row.get("contact_count") or 0) > 0
|
||||
),
|
||||
}
|
||||
for row, h_score, h_reasons in scored[:25]
|
||||
]
|
||||
|
||||
density_rows = fetch_all(
|
||||
"""
|
||||
SELECT country_iso2, COUNT(*) AS n
|
||||
FROM export_market_entities
|
||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||
GROUP BY country_iso2
|
||||
"""
|
||||
)
|
||||
trade_choropleth = {r["country_iso2"]: r["n"] for r in density_rows}
|
||||
|
||||
stats = export_stats(country=country, region=region)
|
||||
return {
|
||||
"entities": {"type": "FeatureCollection", "features": features},
|
||||
"trade_choropleth": trade_choropleth,
|
||||
"halal_by_country": halal_by_country,
|
||||
"meta": {
|
||||
"entity_count": len(features),
|
||||
"countries_with_trade": len(trade_choropleth),
|
||||
"halal_min": halal_min,
|
||||
"top_markets": top_markets,
|
||||
"top_halal_entities": top_entities,
|
||||
"phase": "B",
|
||||
"note": "Halal-score: type + land + naam/signaal + contact",
|
||||
},
|
||||
"stats": stats,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/halal/markets")
|
||||
def halal_markets(
|
||||
region: Optional[str] = Query(None),
|
||||
country: Optional[str] = Query(None),
|
||||
limit: int = Query(30, ge=1, le=100),
|
||||
) -> dict[str, Any]:
|
||||
join = ""
|
||||
where = "e.lat IS NOT NULL"
|
||||
params: list[Any] = []
|
||||
if region:
|
||||
join = "JOIN export_territories t ON e.territory_code = t.code"
|
||||
where += " AND t.region_code = %s"
|
||||
params.append(region)
|
||||
if country:
|
||||
where += " AND e.country_iso2 = %s"
|
||||
params.append(country.upper())
|
||||
|
||||
rows = fetch_all(
|
||||
f"""
|
||||
SELECT e.id, e.name, e.entity_type, e.country_iso2, e.city,
|
||||
e.confidence, e.product_interest, e.halal_cert_notes, e.email,
|
||||
(SELECT COUNT(*) FROM export_entity_contacts c WHERE c.entity_id = e.id) AS contact_count
|
||||
FROM export_market_entities e
|
||||
{join}
|
||||
WHERE {where}
|
||||
LIMIT 15000
|
||||
""",
|
||||
tuple(params) if params else None,
|
||||
)
|
||||
by_country: dict[str, dict[str, Any]] = {}
|
||||
top_entities: list[dict[str, Any]] = []
|
||||
for row in rows:
|
||||
h_score, reasons = score_entity(row)
|
||||
iso = row.get("country_iso2") or ""
|
||||
bucket = by_country.setdefault(
|
||||
iso,
|
||||
{"country_iso2": iso, "market_score": COUNTRY_HALAL_MARKET.get(iso, 40), "entities": 0, "score_sum": 0.0, "hot": 0},
|
||||
)
|
||||
bucket["entities"] += 1
|
||||
bucket["score_sum"] += h_score
|
||||
if h_score >= 75:
|
||||
bucket["hot"] += 1
|
||||
top_entities.append({
|
||||
"id": row["id"],
|
||||
"name": row["name"],
|
||||
"entity_type": row["entity_type"],
|
||||
"country_iso2": iso,
|
||||
"city": row.get("city"),
|
||||
"halal_score": h_score,
|
||||
"halal_reasons": reasons[:3],
|
||||
})
|
||||
|
||||
markets = []
|
||||
for iso, b in by_country.items():
|
||||
n = b["entities"] or 1
|
||||
avg = round(b["score_sum"] / n, 1)
|
||||
markets.append({
|
||||
"country_iso2": iso,
|
||||
"market_score": b["market_score"],
|
||||
"entity_count": b["entities"],
|
||||
"avg_halal_score": avg,
|
||||
"hot_entities": b["hot"],
|
||||
"combined_score": round(b["market_score"] * 0.4 + avg * 0.6, 1),
|
||||
})
|
||||
markets.sort(key=lambda x: x["combined_score"], reverse=True)
|
||||
top_entities.sort(key=lambda x: x["halal_score"], reverse=True)
|
||||
|
||||
return {
|
||||
"markets": markets[:limit],
|
||||
"top_entities": top_entities[:50],
|
||||
"total_entities": len(rows),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/entities/favorites")
|
||||
def set_favorites(payload: FavoritesIn) -> dict[str, Any]:
|
||||
if not payload.entity_ids:
|
||||
raise HTTPException(400, "entity_ids required")
|
||||
fav_val = payload.favorite
|
||||
execute(
|
||||
"""
|
||||
UPDATE export_market_entities
|
||||
SET is_favorite = %s,
|
||||
favorited_at = CASE WHEN %s THEN NOW() ELSE NULL END,
|
||||
updated_at = NOW()
|
||||
WHERE id = ANY(%s)
|
||||
""",
|
||||
(fav_val, fav_val, payload.entity_ids),
|
||||
)
|
||||
return {"ok": True, "updated": len(payload.entity_ids), "favorite": fav_val}
|
||||
|
||||
|
||||
@router.get("/entities/favorites")
|
||||
def list_favorite_entities(
|
||||
limit: int = Query(200, ge=1, le=500),
|
||||
country: Optional[str] = Query(None),
|
||||
region: Optional[str] = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
return list_entities(
|
||||
country=country,
|
||||
region=region,
|
||||
favorite_only=True,
|
||||
limit=limit,
|
||||
offset=0,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/crm/push")
|
||||
def crm_push(payload: CrmPushIn) -> dict[str, Any]:
|
||||
if not payload.entity_ids:
|
||||
raise HTTPException(400, "entity_ids required")
|
||||
if len(payload.entity_ids) > 100:
|
||||
raise HTTPException(400, "max 100 entities per request")
|
||||
return push_entities_to_crm(
|
||||
payload.entity_ids,
|
||||
create_deals=payload.create_deals,
|
||||
client_stage=payload.client_stage,
|
||||
deal_stage=payload.deal_stage,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/crm/pipeline")
|
||||
def crm_pipeline(
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
country: Optional[str] = Query(None),
|
||||
region: Optional[str] = Query(None),
|
||||
) -> dict[str, Any]:
|
||||
join = ""
|
||||
if region:
|
||||
join = "JOIN export_territories t ON e.territory_code = t.code"
|
||||
where_sql, params = _entity_filters(country, region, None, None, False, True)
|
||||
rows = fetch_all(
|
||||
f"""
|
||||
SELECT e.id, e.name, e.entity_type, e.country_iso2, e.city,
|
||||
e.pipeline_stage, e.crm_pushed_at, e.is_favorite,
|
||||
e.client_id, e.deal_id, e.email, e.phone,
|
||||
cl.name AS crm_client_name, cl.stage AS crm_client_stage,
|
||||
d.title AS crm_deal_title, d.stage AS crm_deal_stage
|
||||
FROM export_market_entities e
|
||||
LEFT JOIN clients cl ON cl.id = e.client_id
|
||||
LEFT JOIN deals d ON d.id = e.deal_id
|
||||
{join}
|
||||
WHERE {where_sql}
|
||||
ORDER BY e.crm_pushed_at DESC NULLS LAST, e.updated_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
tuple(params) + (limit,),
|
||||
)
|
||||
return {"items": rows, "total": len(rows)}
|
||||
|
||||
|
||||
@router.post("/sync/contacts")
|
||||
def api_sync_contacts(payload: SyncRequestIn) -> dict[str, Any]:
|
||||
try:
|
||||
result = sync_contacts(payload.country_iso2, payload.region_code)
|
||||
return {"status": "completed", "phase": "B", "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync/caterers")
|
||||
def api_sync_caterers(payload: SyncRequestIn) -> dict[str, Any]:
|
||||
try:
|
||||
result = sync_caterers(payload.country_iso2, payload.region_code)
|
||||
return {"status": "completed", "phase": "B", "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync/distributors")
|
||||
def api_sync_distributors(payload: SyncRequestIn) -> dict[str, Any]:
|
||||
try:
|
||||
result = sync_distributors(payload.country_iso2, payload.region_code)
|
||||
return {"status": "completed", "phase": "B", "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync/customers")
|
||||
def api_sync_customers(payload: SyncRequestIn) -> dict[str, Any]:
|
||||
if not payload.country_iso2 and not payload.region_code:
|
||||
raise HTTPException(400, "country_iso2 or region_code required")
|
||||
try:
|
||||
result = sync_customers(payload.country_iso2, payload.region_code)
|
||||
return {"status": "completed", "phase": "B", "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync/tenders")
|
||||
def api_sync_tenders() -> dict[str, Any]:
|
||||
try:
|
||||
result = sync_ted_tenders()
|
||||
return {"status": "completed", "phase": "B", "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync/all")
|
||||
def api_sync_all(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]:
|
||||
try:
|
||||
result = sync_all_priority(max_priority=payload.max_priority, region_code=payload.region_code)
|
||||
return {"status": "completed", "phase": "B", "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync/world")
|
||||
def api_sync_world(payload: SyncRequestIn = SyncRequestIn()) -> dict[str, Any]:
|
||||
"""Sync Europe, Middle East, Africa, Americas — long running."""
|
||||
try:
|
||||
result = sync_world_regions(max_priority=payload.max_priority)
|
||||
return {"status": "completed", "phase": "B", "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
|
||||
|
||||
@router.post("/sync/region")
|
||||
def api_sync_region(payload: SyncRequestIn) -> dict[str, Any]:
|
||||
if not payload.region_code:
|
||||
raise HTTPException(400, "region_code required")
|
||||
try:
|
||||
result = sync_all_priority(max_priority=payload.max_priority, region_code=payload.region_code)
|
||||
return {"status": "completed", "phase": "B", "result": result}
|
||||
except Exception as exc:
|
||||
raise HTTPException(500, str(exc)) from exc
|
||||
Reference in New Issue
Block a user