Co-authored-by: MENDOZA BALLARDO GAEL RICARDO <gael-meb123@users.noreply.github.com>

Co-authored-by: Azareth-Tr <Azareth-Tr@users.noreply.githu

b.com>

correcion de errores en llenado de tablas, primeras vistas frontend
This commit is contained in:
shinra32
2026-05-22 20:17:04 -06:00
parent fc28333e3f
commit 21a73162df
14 changed files with 600 additions and 95 deletions

View File

@@ -1,40 +1,54 @@
from fastapi import APIRouter, HTTPException
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException
from app.services import simulation
from app.core.deps import get_current_user
from app.core.supabase_client import supabase_admin
router = APIRouter()
router = APIRouter(tags=["eta"])
@router.get("/eta")
def get_eta(colonia: Optional[str] = None, routeId: Optional[str] = None):
# Resolver routeId a partir de colonia si es necesario
if routeId is None:
if colonia is None:
raise HTTPException(status_code=400, detail="colonia or routeId required")
mapping = simulation.get_colonias()
match = next((c for c in mapping if c.get("colonia","").lower() == colonia.lower()), None)
if not match:
raise HTTPException(status_code=404, detail="colonia not found")
routeId = match["routeId"]
def get_eta(
address_id: str,
current_user: dict = Depends(get_current_user),
):
"""
ETA para el ciudadano.
- Recibe `address_id` (domicilio del usuario autenticado).
- Valida que el domicilio pertenezca al usuario (túnel de privacidad).
- Devuelve SOLO texto: nunca coordenadas ni routeId completo.
"""
result = (
supabase_admin.table("addresses")
.select("route_id, user_id")
.eq("id", address_id)
.maybe_single()
.execute()
)
if not result.data:
raise HTTPException(status_code=404, detail="Domicilio no encontrado")
address = result.data
# Túnel: ciudadano solo puede consultar sus propios domicilios
if current_user["role"] != "admin" and address["user_id"] != current_user["user_id"]:
raise HTTPException(status_code=403, detail="No tienes acceso a este domicilio")
route_id = address["route_id"]
pos = simulation.get_route_position(route_id)
status = simulation.get_route_status(route_id)
pos = simulation.get_route_position(routeId)
status = simulation.get_route_status(routeId)
if pos is None:
raise HTTPException(status_code=404, detail="route not found")
raise HTTPException(status_code=503, detail="Simulación no disponible")
if pos < 4:
mensaje = "El camión va en camino a tu sector"
elif pos == 4:
mensaje = "Llega en aproximadamente 15 minutos"
mensaje = "Llega en aproximadamente 15 minutos — saca tus bolsas"
elif pos < 8:
mensaje = "Está atendiendo tu zona; saca tus bolsas"
mensaje = "Está atendiendo tu zona; prepara tus bolsas"
else:
mensaje = "Servicio del día finalizado"
mensaje = "Servicio del día finalizado. Mañana continuamos"
return {"mensaje": mensaje, "status": status, "routeId": routeId}
@router.post("/simulate/tick")
def simulate_tick():
events = simulation.tick()
return {"events": events}
# ⚠️ Nunca devolver coordenadas ni el routeId al ciudadano
return {"mensaje": mensaje, "status": status}

View File

@@ -0,0 +1,64 @@
from fastapi import APIRouter, Depends
from app.services import simulation
from app.core.deps import require_role
router = APIRouter(prefix="/simulation", tags=["simulation"])
# Solo admin puede ver el estado real de la simulación (incluye routeId y posición)
_require_admin = require_role("admin")
@router.get("/state")
def get_state(_: dict = Depends(_require_admin)):
"""
Estado actual de la simulación: positionId y status de cada ruta.
⚠️ No devuelve coordenadas — solo el índice (18) y el status de texto.
"""
colonias = simulation.get_colonias()
route_ids = {c["routeId"] for c in colonias}
state = []
for route_id in sorted(route_ids):
pos = simulation.get_route_position(route_id)
status = simulation.get_route_status(route_id)
# Derivar mensaje ETA sin coordenadas
if pos is None:
mensaje = "Ruta no cargada"
elif pos < 4:
mensaje = "En camino al sector"
elif pos == 4:
mensaje = "Llega en ~15 min"
elif pos < 8:
mensaje = "Atendiendo la zona"
else:
mensaje = "Servicio finalizado"
state.append({
"routeId": route_id,
"positionId": pos,
"status": status,
"etaMensaje": mensaje,
})
return {"routes": state, "totalRoutes": len(state)}
@router.post("/tick")
def manual_tick(_: dict = Depends(_require_admin)):
"""Avanza manualmente un tick en todas las rutas (útil para la demo)."""
events = simulation.tick()
return {"events": events, "total": len(events)}
@router.post("/reset")
def reset_simulation(_: dict = Depends(_require_admin)):
"""Reinicia todas las rutas a positionId=1 (para repetir la demo)."""
simulation.start_simulation_state()
return {"message": "Simulación reiniciada. Todas las rutas están en posición 1."}
@router.get("/events")
def get_events(_: dict = Depends(_require_admin)):
"""Últimos 20 eventos disparados por la simulación."""
return {"events": simulation.get_last_events()}

View File

@@ -1,40 +1,28 @@
import json
import os
from pathlib import Path
from dotenv import load_dotenv
from supabase import create_client, Client
# Configuración de directorios base
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
DATA_DIR = os.path.join(BASE_DIR, "data")
ENV_PATH = os.path.join(os.path.dirname(BASE_DIR), ".env")
BASE_DIR = Path(__file__).parent.parent # backend/app/
DATA_DIR = BASE_DIR / "data"
ENV_PATH = BASE_DIR.parent / ".env" # backend/.env
def load_env(path: str):
"""Carga variables de entorno de forma manual sin depender de python-dotenv"""
if not os.path.exists(path):
print(f"Advertencia: No se encontró el archivo {path}")
return
with open(path, "r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if line and not line.startswith('#'):
# Separa por el primer '=' y limpia espacios y comillas
key, val = line.split('=', 1)
os.environ[key.strip()] = val.strip().strip("'").strip('"')
def load_json(filename: str):
path = os.path.join(DATA_DIR, filename)
with open(path, "r", encoding="utf-8") as f:
with open(DATA_DIR / filename, encoding="utf-8") as f:
return json.load(f)
def main():
print("Iniciando proceso de seeding...")
load_env(ENV_PATH)
load_dotenv(ENV_PATH)
# Es crucial usar SUPABASE_SERVICE_ROLE_KEY para saltar el RLS durante el Seed
URL = os.environ.get("SUPABASE_URL")
KEY = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
if not URL or not KEY:
raise ValueError("Error: Asegúrate de tener SUPABASE_URL y SUPABASE_SERVICE_ROLE_KEY en el .env")
raise ValueError("Falta SUPABASE_URL o SUPABASE_SERVICE_ROLE_KEY en el .env")
supabase: Client = create_client(URL, KEY)
@@ -99,7 +87,7 @@ def main():
"turno": c["turno"],
"horario_estimado": c["horario_estimado"]
})
supabase.table("colonias").upsert(colonias_to_insert).execute()
supabase.table("colonias").upsert(colonias_to_insert, on_conflict="nombre").execute()
print("✅ Seed completado con éxito. Base de datos operativa para la app.")

93
backend/app/db/tables.sql Normal file
View File

@@ -0,0 +1,93 @@
-- ============================================================
-- Tablas del dominio — Sistema de Recolección Inteligente
-- Ejecutar ANTES que rls_policies.sql
-- Supabase > SQL Editor
-- ============================================================
-- ------------------------------------------------------------
-- 1. UNITS (camiones recolectores)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.units (
id INT PRIMARY KEY, -- truckId del JSON (101, 103, …)
plate TEXT,
status TEXT DEFAULT 'active'
CHECK (status IN ('active', 'inactive', 'maintenance'))
);
-- ------------------------------------------------------------
-- 2. ROUTES (6 rutas MVP)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.routes (
id TEXT PRIMARY KEY, -- 'RUTA-01' … 'RUTA-13'
name TEXT,
truck_id INT REFERENCES public.units(id),
turno TEXT CHECK (turno IN ('matutino', 'vespertino', 'Matutino', 'Vespertino')),
status TEXT DEFAULT 'pendiente'
CHECK (status IN ('pendiente','en_ruta','completada','diferida','reasignada')),
current_position_id INT DEFAULT 1 CHECK (current_position_id BETWEEN 1 AND 8)
);
-- ------------------------------------------------------------
-- 3. ROUTE_POSITIONS (8 posiciones por ruta — solo admin)
-- ⚠️ Contiene lat/lng. NUNCA se devuelve al ciudadano.
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.route_positions (
route_id TEXT REFERENCES public.routes(id) ON DELETE CASCADE,
position_id INT,
lat DOUBLE PRECISION,
lng DOUBLE PRECISION,
speed INT,
ts TIMESTAMPTZ,
PRIMARY KEY (route_id, position_id)
);
-- ------------------------------------------------------------
-- 4. COLONIAS (mapeo colonia → ruta; sin coordenadas)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.colonias (
id SERIAL PRIMARY KEY,
nombre TEXT NOT NULL,
route_id TEXT REFERENCES public.routes(id),
turno TEXT,
horario_estimado TEXT
);
-- ------------------------------------------------------------
-- 5. DRIVERS (chofer → unidad; 1:1)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.drivers (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID REFERENCES public.users(id) ON DELETE CASCADE,
unit_id INT REFERENCES public.units(id)
);
-- ------------------------------------------------------------
-- 6. COLLECTION_EVENTS (registro de recolección por domicilio)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.collection_events (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
route_id TEXT REFERENCES public.routes(id),
address_id UUID REFERENCES public.addresses(id) ON DELETE CASCADE,
status TEXT,
collected_at TIMESTAMPTZ DEFAULT NOW()
);
-- ------------------------------------------------------------
-- 7. ROUTE_CHANGES (historial de reasignaciones / retrasos)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS public.route_changes (
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
route_id TEXT REFERENCES public.routes(id),
from_unit INT REFERENCES public.units(id),
to_unit INT REFERENCES public.units(id),
reason TEXT,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- ------------------------------------------------------------
-- Índices de rendimiento
-- ------------------------------------------------------------
CREATE INDEX IF NOT EXISTS idx_route_positions_route ON public.route_positions(route_id);
CREATE INDEX IF NOT EXISTS idx_colonias_route ON public.colonias(route_id);
CREATE INDEX IF NOT EXISTS idx_drivers_user ON public.drivers(user_id);
CREATE INDEX IF NOT EXISTS idx_collection_route ON public.collection_events(route_id);

View File

@@ -8,6 +8,7 @@ from app.api.eta import router as eta_router
from app.api.auth import router as auth_router
from app.api.addresses import router as addresses_router
from app.api.colonias import router as colonias_router
from app.api.simulation import router as simulation_router
from app.services import simulation, notifications
scheduler = AsyncIOScheduler()
@@ -57,6 +58,7 @@ app.include_router(auth_router)
app.include_router(addresses_router)
app.include_router(eta_router)
app.include_router(colonias_router)
app.include_router(simulation_router)
@app.get("/")

View File

@@ -0,0 +1,52 @@
class AddressCreateRequest {
const AddressCreateRequest({
required this.label,
required this.street,
required this.colonia,
});
final String label;
final String street;
final String colonia;
factory AddressCreateRequest.fromJson(Map<String, dynamic> json) {
return AddressCreateRequest(
label: _pickString(<dynamic>[json['label'], json['alias']]) ?? '',
street: _pickString(<dynamic>[json['calle'], json['street']]) ?? '',
colonia: _pickString(<dynamic>[json['colonia'], json['colony']]) ?? '',
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'label': label,
'calle': street,
'colonia': colonia,
};
}
AddressCreateRequest copyWith({
String? label,
String? street,
String? colonia,
}) {
return AddressCreateRequest(
label: label ?? this.label,
street: street ?? this.street,
colonia: colonia ?? this.colonia,
);
}
static String? _pickString(Iterable<dynamic> candidates) {
for (final candidate in candidates) {
if (candidate is String && candidate.isNotEmpty) {
return candidate;
}
if (candidate != null && candidate.toString().isNotEmpty) {
return candidate.toString();
}
}
return null;
}
}

View File

@@ -0,0 +1,135 @@
class AddressResponse {
const AddressResponse({
required this.id,
required this.userId,
required this.label,
required this.street,
required this.colonia,
required this.routeId,
required this.verified,
this.verifiedMethod,
this.verifiedAt,
this.createdAt,
});
final String id;
final String userId;
final String label;
final String street;
final String colonia;
final String routeId;
final bool verified;
final String? verifiedMethod;
final String? verifiedAt;
final String? createdAt;
factory AddressResponse.fromJson(Map<String, dynamic> json) {
final payload = json['data'] is Map<String, dynamic>
? json['data'] as Map<String, dynamic>
: json;
return AddressResponse(
id: _pickString(<dynamic>[payload['id'], json['id']]) ?? '',
userId:
_pickString(<dynamic>[
payload['user_id'],
payload['userId'],
json['user_id'],
json['userId'],
]) ??
'',
label: _pickString(<dynamic>[payload['label'], json['label']]) ?? '',
street:
_pickString(<dynamic>[
payload['calle'],
payload['street'],
json['calle'],
json['street'],
]) ??
'',
colonia:
_pickString(<dynamic>[
payload['colonia'],
payload['colony'],
json['colonia'],
json['colony'],
]) ??
'',
routeId:
_pickString(<dynamic>[
payload['route_id'],
payload['routeId'],
json['route_id'],
json['routeId'],
]) ??
'',
verified:
_pickBool(<dynamic>[payload['verified'], json['verified']]) ?? false,
verifiedMethod: _pickString(<dynamic>[
payload['verified_method'],
payload['verifiedMethod'],
json['verified_method'],
json['verifiedMethod'],
]),
verifiedAt: _pickString(<dynamic>[
payload['verified_at'],
payload['verifiedAt'],
json['verified_at'],
json['verifiedAt'],
]),
createdAt: _pickString(<dynamic>[
payload['created_at'],
payload['createdAt'],
json['created_at'],
json['createdAt'],
]),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'user_id': userId,
'label': label,
'calle': street,
'colonia': colonia,
'route_id': routeId,
'verified': verified,
'verified_method': verifiedMethod,
'verified_at': verifiedAt,
'created_at': createdAt,
};
}
static String? _pickString(Iterable<dynamic> candidates) {
for (final candidate in candidates) {
if (candidate is String && candidate.isNotEmpty) {
return candidate;
}
if (candidate != null && candidate.toString().isNotEmpty) {
return candidate.toString();
}
}
return null;
}
static bool? _pickBool(Iterable<dynamic> candidates) {
for (final candidate in candidates) {
if (candidate is bool) {
return candidate;
}
if (candidate is String) {
final normalized = candidate.toLowerCase();
if (normalized == 'true') {
return true;
}
if (normalized == 'false') {
return false;
}
}
}
return null;
}
}

View File

@@ -14,24 +14,50 @@ class Colonia {
final String? turno;
factory Colonia.fromJson(Map<String, dynamic> json) {
final rawId =
json['id'] ??
json['routeId'] ??
json['route_id'] ??
json['nombre'] ??
json['name'];
final rawNombre = json['nombre'] ?? json['name'] ?? rawId;
final rawNombre = _pickString(<dynamic>[
json['nombre'],
json['colonia'],
json['name'],
]);
final rawRouteId = _pickString(<dynamic>[
json['routeId'],
json['route_id'],
]);
final rawId = _pickString(<dynamic>[json['id'], rawRouteId, rawNombre]);
return Colonia(
id: rawId?.toString() ?? rawNombre?.toString() ?? '',
nombre: rawNombre?.toString() ?? '',
routeId: (json['routeId'] ?? json['route_id'])?.toString(),
horarioEstimado:
(json['horario_estimado'] ??
json['horarioEstimado'] ??
json['schedule'])
?.toString(),
turno: (json['turno'] ?? json['shift'])?.toString(),
id: rawId ?? '',
nombre: rawNombre ?? rawId ?? '',
routeId: rawRouteId,
horarioEstimado: _pickString(<dynamic>[
json['horario_estimado'],
json['horarioEstimado'],
json['schedule'],
]),
turno: _pickString(<dynamic>[json['turno'], json['shift']]),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'nombre': nombre,
'routeId': routeId,
'horario_estimado': horarioEstimado,
'turno': turno,
};
}
static String? _pickString(Iterable<dynamic> candidates) {
for (final candidate in candidates) {
if (candidate is String && candidate.isNotEmpty) {
return candidate;
}
if (candidate != null && candidate.toString().isNotEmpty) {
return candidate.toString();
}
}
return null;
}
}

View File

@@ -0,0 +1,101 @@
import 'user.dart';
class LoginResponse {
const LoginResponse({
required this.accessToken,
this.tokenType = 'bearer',
required this.userId,
required this.role,
this.routeId,
this.user,
});
final String accessToken;
final String tokenType;
final String userId;
final String role;
final String? routeId;
final User? user;
factory LoginResponse.fromJson(Map<String, dynamic> json) {
final payload = json['data'] is Map<String, dynamic>
? json['data'] as Map<String, dynamic>
: json;
final userJson = payload['user'] is Map<String, dynamic>
? payload['user'] as Map<String, dynamic>
: null;
return LoginResponse(
accessToken:
_pickString(<dynamic>[
payload['access_token'],
payload['token'],
payload['jwt'],
json['access_token'],
json['token'],
json['jwt'],
]) ??
'',
tokenType:
_pickString(<dynamic>[
payload['token_type'],
payload['tokenType'],
json['token_type'],
json['tokenType'],
]) ??
'bearer',
userId:
_pickString(<dynamic>[
payload['user_id'],
payload['userId'],
payload['id'],
json['user_id'],
json['userId'],
json['id'],
]) ??
'',
role:
_pickString(<dynamic>[
payload['role'],
payload['user_role'],
payload['userRole'],
json['role'],
json['user_role'],
json['userRole'],
]) ??
'citizen',
routeId: _pickString(<dynamic>[
payload['route_id'],
payload['routeId'],
json['route_id'],
json['routeId'],
]),
user: userJson == null ? null : User.fromJson(userJson),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'access_token': accessToken,
'token_type': tokenType,
'user_id': userId,
'role': role,
'route_id': routeId,
if (user != null) 'user': user!.toJson(),
};
}
static String? _pickString(Iterable<dynamic> candidates) {
for (final candidate in candidates) {
if (candidate is String && candidate.isNotEmpty) {
return candidate;
}
if (candidate != null && candidate.toString().isNotEmpty) {
return candidate.toString();
}
}
return null;
}
}

View File

@@ -0,0 +1,8 @@
export 'address.dart';
export 'address_create_request.dart';
export 'address_response.dart';
export 'auth_session.dart';
export 'auth_state.dart';
export 'colonia.dart';
export 'login_response.dart';
export 'user.dart';

View File

@@ -0,0 +1,50 @@
class User {
const User({
required this.id,
this.email,
this.phone,
required this.role,
this.routeId,
});
final String id;
final String? email;
final String? phone;
final String role;
final String? routeId;
factory User.fromJson(Map<String, dynamic> json) {
return User(
id:
_pickString(<dynamic>[json['id'], json['user_id'], json['userId']]) ??
'',
email: _pickString(<dynamic>[json['email'], json['mail']]),
phone: _pickString(<dynamic>[json['phone'], json['telefono']]),
role: _pickString(<dynamic>[json['role'], json['rol']]) ?? 'citizen',
routeId: _pickString(<dynamic>[json['routeId'], json['route_id']]),
);
}
Map<String, dynamic> toJson() {
return <String, dynamic>{
'id': id,
'email': email,
'phone': phone,
'role': role,
'routeId': routeId,
};
}
static String? _pickString(Iterable<dynamic> candidates) {
for (final candidate in candidates) {
if (candidate is String && candidate.isNotEmpty) {
return candidate;
}
if (candidate != null && candidate.toString().isNotEmpty) {
return candidate.toString();
}
}
return null;
}
}

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/models/address.dart';
import '../../core/models/address_create_request.dart';
import '../../core/models/colonia.dart';
import 'colonias_selector.dart';
@@ -36,10 +36,10 @@ class _NewAddressPageState extends ConsumerState<NewAddressPage> {
return;
}
final address = AddressModel(
final address = AddressCreateRequest(
label: _labelController.text.trim(),
street: _streetController.text.trim(),
colonia: _selectedColonia!,
colonia: _selectedColonia!.nombre,
);
ScaffoldMessenger.of(context).showSnackBar(

View File

@@ -39,20 +39,6 @@ class _LoginPageState extends ConsumerState<LoginPage> {
if (!mounted) {
return;
}
final authState = ref.read(authControllerProvider).asData?.value;
if (authState?.userRole == 'admin') {
context.go('/admin');
return;
}
if (authState?.userRole == 'driver') {
context.go('/driver');
return;
}
final routeId = authState?.routeId;
if (routeId != null && routeId.isNotEmpty) {
context.go('/home?routeId=$routeId');
return;
}
context.go('/home');
} catch (error) {
if (!mounted) {

View File

@@ -44,20 +44,6 @@ class _RegisterPageState extends ConsumerState<RegisterPage> {
if (!mounted) {
return;
}
final authState = ref.read(authControllerProvider).asData?.value;
if (authState?.userRole == 'admin') {
context.go('/admin');
return;
}
if (authState?.userRole == 'driver') {
context.go('/driver');
return;
}
final routeId = authState?.routeId;
if (routeId != null && routeId.isNotEmpty) {
context.go('/home?routeId=$routeId');
return;
}
context.go('/home');
} catch (error) {
if (!mounted) {