4dd1b8265f
Ship export intel with contact filters and CRM push, Herman live digest with Telegram briefing, halal recommendation engine, revenue cockpit, and dashboard polling/WebSocket fixes.
861 lines
30 KiB
Python
861 lines
30 KiB
Python
"""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),
|
|
region: Optional[str] = Query(None),
|
|
entity_type: Optional[str] = Query(None),
|
|
has_email: Optional[bool] = Query(None),
|
|
crm_linked: 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]:
|
|
join = ""
|
|
if region:
|
|
join = "JOIN export_territories t ON e.territory_code = t.code"
|
|
where_sql, params = _entity_filters(country, region, entity_type, None, False, crm_linked)
|
|
if has_email is True:
|
|
where_sql += " AND c.email IS NOT NULL AND c.email <> ''"
|
|
elif has_email is False:
|
|
where_sql += " AND (c.email IS NULL OR c.email = '')"
|
|
if q:
|
|
like = f"%{q}%"
|
|
where_sql += (
|
|
" AND (e.name ILIKE %s OR e.city ILIKE %s OR c.email ILIKE %s"
|
|
" OR c.name ILIKE %s OR c.phone ILIKE %s OR c.mobile ILIKE %s)"
|
|
)
|
|
params.extend([like, like, like, like, like, like])
|
|
|
|
rows = fetch_all(
|
|
f"""
|
|
SELECT c.*, e.name AS entity_name, e.entity_type, e.country_iso2,
|
|
e.city AS entity_city, e.client_id, e.deal_id,
|
|
cl.name AS crm_client_name
|
|
FROM export_entity_contacts c
|
|
JOIN export_market_entities e ON e.id = c.entity_id
|
|
LEFT JOIN clients cl ON cl.id = e.client_id
|
|
{join}
|
|
WHERE {where_sql}
|
|
ORDER BY e.name, e.city NULLS LAST, c.is_primary DESC, c.email NULLS LAST
|
|
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
|
|
{join}
|
|
WHERE {where_sql}
|
|
""",
|
|
tuple(params),
|
|
)
|
|
return {"items": rows, "total": int(total["n"] or 0), "limit": limit, "offset": offset}
|
|
|
|
|
|
@router.get("/contacts/export.csv")
|
|
def export_contacts_csv(
|
|
country: Optional[str] = Query(None),
|
|
region: Optional[str] = Query(None),
|
|
entity_type: Optional[str] = Query(None),
|
|
has_email: Optional[bool] = Query(None),
|
|
crm_linked: Optional[bool] = Query(None),
|
|
q: Optional[str] = Query(None),
|
|
) -> StreamingResponse:
|
|
data = list_contacts(
|
|
country=country,
|
|
region=region,
|
|
entity_type=entity_type,
|
|
has_email=has_email,
|
|
crm_linked=crm_linked,
|
|
q=q,
|
|
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
|