uzoagro-api / logistics_api.py
Gini-Eddie's picture
Deploy authenticated logistics and crop trace backend
ffdf96a
Raw History Blame Contribute Delete
14.6 kB
"""Authenticated farmer-to-driver requests, capacity reservations and personal analytics."""
from collections import Counter
from datetime import date, datetime, timezone
import math
import secrets
import pandas as pd
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, Field
from typing import Literal
from psycopg2.errors import UniqueViolation
from auth_api import current_user, farmer
from database import get_db_connection
from geography import CITIES
from matching_engine import run_matching_engine, extract_affinity_score
router = APIRouter(prefix='/api/logistics', tags=['Logistics'])
CROPS = ['Cassava', 'Maize', 'Tomatoes', 'Yam', 'Rice', 'Plantain', 'Beans', 'Onions']
STATUSES = ['requested', 'accepted', 'picked_up', 'delivered', 'declined', 'cancelled']
def now(): return datetime.now(timezone.utc).isoformat()
def driver(account=Depends(current_user)):
if account['role'] != 'driver': raise HTTPException(403, 'Only driver accounts can manage return routes.')
return account
class Cargo(BaseModel):
pickup_city: str
dropoff_city: str
crop: str
tonnes: float = Field(gt=0, le=100)
requested_date: date
batch_id: str | None = None
class NewRoute(BaseModel):
origin: str
destination: str
available_date: date
capacity: float = Field(gt=0, le=100)
crops: list[str] = Field(min_length=1, max_length=8)
class Booking(Cargo):
route_id: str
class JobAction(BaseModel):
action: Literal['accept', 'decline', 'pickup', 'deliver', 'cancel']
class RouteAction(BaseModel):
status: Literal['available', 'closed']
def validate_cargo(data):
if data.pickup_city not in CITIES or data.dropoff_city not in CITIES or data.pickup_city == data.dropoff_city:
raise HTTPException(422, 'Choose different pickup and destination cities.')
if data.crop not in CROPS: raise HTTPException(422, 'Choose a supported crop.')
if data.requested_date < date.today(): raise HTTPException(422, 'Pickup date cannot be in the past.')
def matches_for(data, routes):
"""Keep the existing five-factor engine, with hard safety filters before ranking."""
validate_cargo(data)
def fits_corridor(r):
a,b,p,d=[CITIES[c] for c in (r['origin'],r['destination'],data.pickup_city,data.dropoff_city)]
vx,vy=b[0]-a[0],b[1]-a[1]; length=vx*vx+vy*vy
if length==0: return False
def projection(point): return ((point[0]-a[0])*vx+(point[1]-a[1])*vy)/length
def deviation(point): return abs(vy*(point[0]-a[0])-vx*(point[1]-a[1]))/math.sqrt(length)
# Approximate city-scale bounds: pickup within ~165 km, corridor detour ~110 km.
return math.dist(a,p)<=1.5 and deviation(p)<=1 and deviation(d)<=1 and -.1<=projection(p)<=1.1 and -.1<=projection(d)<=1.1 and projection(d)>projection(p)
viable = [r for r in routes if r['status'] == 'available' and float(r['capacity']) >= data.tonnes
and date.fromisoformat(str(r['available_date'])) >= date.today() and fits_corridor(r)
and abs((date.fromisoformat(str(r['available_date'])) - data.requested_date).days) <= 2
and extract_affinity_score(pd.Series([r['crops']]), data.crop)[0] > 0]
# City coordinates are used internally, never sent as precise vehicle positions.
if not viable: return []
rows = []
for r in viable:
a, b = CITIES[r['origin']], CITIES[r['destination']]
rows.append({'driver_id':r['id'], 'name':r['driver_name'], 'current_lat':a[0], 'current_lon':a[1],
'home_base_lat':b[0], 'home_base_lon':b[1], 'available_capacity':float(r['capacity']),
'available_date':pd.Timestamp(r['available_date']), 'allowed_crops':r['crops']})
p, d = CITIES[data.pickup_city], CITIES[data.dropoff_city]
req = pd.DataFrame([{'request_id':'preview','pickup_lat':p[0],'pickup_lon':p[1], 'dropoff_lat':d[0],
'dropoff_lon':d[1],'required_capacity':data.tonnes,'requested_date':pd.Timestamp(data.requested_date), 'crop_type':data.crop}])
scores = run_matching_engine(pd.DataFrame(rows), req, top_k=5).to_dict(orient='records')
by_id = {r['id']:r for r in viable}
result=[]
for score in scores:
r=by_id[score['driver_id']]
result.append({k:r[k] for k in ('id','driver_name','origin','destination','available_date','capacity','crops')} |
{'fit':round(float(score['final_score'])*100), 'factors':{k:round(float(score[k])*100) for k in ('capacity_score','time_score','affinity_score','deadhead_score','corridor_score')}})
return result
def routes_query(cur):
cur.execute("SELECT r.*, u.name AS driver_name FROM logistics_routes r JOIN users u ON u.phone=r.driver_phone WHERE u.role='driver' AND r.status='available' AND r.available_date>=%s", (date.today().isoformat(),))
return cur.fetchall()
def safe_job(row, account):
item={k:v for k,v in row.items() if k not in ('farmer_phone','driver_phone')}
if row['status'] in ('accepted','picked_up','delivered'):
item['contact']={'name':row['driver_name'] if account['role']=='farmer' else row['farmer_name'],
'phone':row['driver_phone'] if account['role']=='farmer' else row['farmer_phone']}
return item
def job_rows(cur, account):
column='farmer_phone' if account['role']=='farmer' else 'driver_phone'
cur.execute(f'''SELECT j.*, f.name AS farmer_name, d.name AS driver_name
FROM logistics_jobs j JOIN users f ON f.phone=j.farmer_phone JOIN users d ON d.phone=j.driver_phone
WHERE j.{column}=%s ORDER BY j.created_at DESC''', (account['phone'],))
return cur.fetchall()
@router.get('/config')
def config(account=Depends(current_user)):
return {'cities':list(CITIES), 'crops':CROPS}
@router.post('/matches')
def find_matches(data: Cargo, account=Depends(farmer)):
conn=get_db_connection()
try:
with conn.cursor() as cur: rows=routes_query(cur)
return {'matches':matches_for(data,rows)}
finally: conn.close()
@router.post('/routes')
def publish_route(data: NewRoute, account=Depends(driver)):
if data.origin not in CITIES or data.destination not in CITIES or data.origin==data.destination:
raise HTTPException(422,'Choose different start and return cities.')
if data.available_date < date.today(): raise HTTPException(422,'Departure cannot be in the past.')
if any(c not in CROPS for c in data.crops): raise HTTPException(422,'Choose supported cargo types.')
route={'id':'RTE-'+secrets.token_hex(5).upper(), 'driver_phone':account['phone'], **data.model_dump(mode='json'),
'crops':'|'.join(dict.fromkeys(data.crops)), 'status':'available','created_at':now()}
conn=get_db_connection()
try:
with conn.cursor() as cur:
columns=list(route)
cur.execute('INSERT INTO logistics_routes ('+','.join(columns)+') VALUES ('+','.join(['%s']*len(columns))+')', tuple(route.values()))
conn.commit()
return {k:v for k,v in route.items() if k!='driver_phone'}
finally: conn.close()
@router.get('/routes')
def my_routes(account=Depends(driver)):
conn=get_db_connection()
try:
with conn.cursor() as cur:
cur.execute('SELECT id,origin,destination,available_date,capacity,crops,status,created_at FROM logistics_routes WHERE driver_phone=%s ORDER BY created_at DESC',(account['phone'],))
return cur.fetchall()
finally: conn.close()
@router.patch('/routes/{route_id}')
def update_route(route_id: str, data: RouteAction, account=Depends(driver)):
conn=get_db_connection()
try:
with conn.cursor() as cur:
cur.execute('SELECT * FROM logistics_routes WHERE id=%s AND driver_phone=%s FOR UPDATE',(route_id,account['phone']))
route=cur.fetchone()
if not route: raise HTTPException(404,'Route not found.')
if route['status'] not in ('available','closed'): raise HTTPException(409,'A reserved or completed route cannot be changed.')
if data.status=='available' and date.fromisoformat(str(route['available_date'])) < date.today(): raise HTTPException(409,'Publish a new route with a current departure date.')
cur.execute('UPDATE logistics_routes SET status=%s WHERE id=%s',(data.status,route_id))
conn.commit()
return {'status':data.status}
finally: conn.close()
@router.post('/jobs')
def request_pickup(data: Booking, account=Depends(farmer)):
validate_cargo(data)
if data.batch_id:
from trace_api import batch_or_404
batch=batch_or_404(data.batch_id)
if batch['owner']!=account['phone']: raise HTTPException(403,'Attach only your own produce batches.')
if batch['metadata']['crop']!=data.crop: raise HTTPException(422,'The crop must match the attached batch.')
if data.tonnes*1000 > batch['metadata']['quantity']+0.001: raise HTTPException(422,'Cargo exceeds the attached batch quantity.')
conn=get_db_connection()
try:
with conn.cursor() as cur:
cur.execute("SELECT r.*,u.name AS driver_name FROM logistics_routes r JOIN users u ON u.phone=r.driver_phone WHERE r.id=%s AND u.role='driver' FOR UPDATE",(data.route_id,))
route=cur.fetchone()
if not route or not matches_for(data,[route]): raise HTTPException(409,'This route is no longer a suitable available match. Search again.')
if route['driver_phone']==account['phone']: raise HTTPException(409,'You cannot request your own return trip.')
cur.execute("SELECT id FROM logistics_jobs WHERE farmer_phone=%s AND route_id=%s AND status IN ('requested','accepted','picked_up')",(account['phone'],data.route_id))
if cur.fetchone(): raise HTTPException(409,'You already have an active request for this route.')
job={'id':'JOB-'+secrets.token_hex(5).upper(), 'farmer_phone':account['phone'], 'driver_phone':route['driver_phone'],
**data.model_dump(mode='json'), 'status':'requested','created_at':now(),'updated_at':now()}
cur.execute('INSERT INTO logistics_jobs ('+','.join(job)+') VALUES ('+','.join(['%s']*len(job))+')',tuple(job.values()))
conn.commit()
return {'id':job['id'],'status':'requested'}
except UniqueViolation:
conn.rollback(); raise HTTPException(409,'This pickup request already exists.')
finally: conn.close()
@router.get('/jobs')
def jobs(account=Depends(current_user)):
conn=get_db_connection()
try:
with conn.cursor() as cur: rows=job_rows(cur,account)
return [safe_job(r,account) for r in rows]
finally: conn.close()
@router.patch('/jobs/{job_id}')
def move_job(job_id: str, data: JobAction, account=Depends(current_user)):
conn=get_db_connection()
try:
with conn.cursor() as cur:
cur.execute('SELECT * FROM logistics_jobs WHERE id=%s',(job_id,)); initial=cur.fetchone()
if not initial or account['phone'] not in (initial['farmer_phone'],initial['driver_phone']): raise HTTPException(404,'Shipment not found.')
# Consistent lock order: route first, then request.
cur.execute('SELECT * FROM logistics_routes WHERE id=%s FOR UPDATE',(initial['route_id'],)); route=cur.fetchone()
cur.execute('SELECT * FROM logistics_jobs WHERE id=%s FOR UPDATE',(job_id,)); job=cur.fetchone()
rules={'accept':('driver','requested','accepted'), 'decline':('driver','requested','declined'),
'pickup':('driver','accepted','picked_up'), 'deliver':('driver','picked_up','delivered')}
if data.action=='cancel':
if account['role']!='farmer' or account['phone']!=job['farmer_phone'] or job['status'] not in ('requested','accepted'): raise HTTPException(409,'Only the farmer can cancel their request before pickup.')
target='cancelled'
if job['status']=='accepted': cur.execute("UPDATE logistics_routes SET status='available' WHERE id=%s",(job['route_id'],))
else:
role, expected, target=rules[data.action]
if account['role']!=role or account['phone']!=job['driver_phone']: raise HTTPException(403,'Only the assigned driver can update this journey.')
if job['status']!=expected: raise HTTPException(409,'This shipment has changed. Refresh and try again.')
if data.action=='accept':
if route['status']!='available': raise HTTPException(409,'This return trip is already reserved or closed.')
cur.execute("UPDATE logistics_routes SET status='booked' WHERE id=%s",(job['route_id'],))
cur.execute("UPDATE logistics_jobs SET status='declined',updated_at=%s WHERE route_id=%s AND id<>%s AND status='requested'",(now(),job['route_id'],job_id))
if data.action=='deliver': cur.execute("UPDATE logistics_routes SET status='completed' WHERE id=%s",(job['route_id'],))
cur.execute('UPDATE logistics_jobs SET status=%s,updated_at=%s WHERE id=%s',(target,now(),job_id))
conn.commit(); return {'id':job_id,'status':target}
finally: conn.close()
@router.get('/dashboard')
def dashboard(account=Depends(current_user)):
conn=get_db_connection()
try:
with conn.cursor() as cur:
rows=job_rows(cur,account)
cur.execute("SELECT COUNT(*) AS count FROM logistics_routes WHERE driver_phone=%s AND status='available' AND available_date>=%s",(account['phone'],date.today().isoformat()))
published=cur.fetchone()['count']
counts=Counter(j['status'] for j in rows)
months=[]
for back in range(5,-1,-1):
absolute=date.today().year*12+date.today().month-1-back
year,month=divmod(absolute,12); key=f'{year:04d}-{month+1:02d}'
months.append({'month':key,'label':date(year,month+1,1).strftime('%b'),
'requests':sum(str(j['created_at']).startswith(key) for j in rows),
'deliveries':sum(j['status']=='delivered' and str(j['updated_at']).startswith(key) for j in rows)})
return {'total':len(rows),'active':counts['accepted']+counts['picked_up'],'pending':counts['requested'],
'delivered':counts['delivered'],'tonnes_delivered':round(sum(float(j['tonnes']) for j in rows if j['status']=='delivered'),3),
'published_routes':published,'statuses':{s:counts[s] for s in STATUSES},'months':months,
'recent':[safe_job(j,account) for j in rows[:5]]}
finally: conn.close()