feat: modelado BD con schema, seeds y docker-compose
This commit is contained in:
192
init/01_schema.sql
Normal file
192
init/01_schema.sql
Normal file
@@ -0,0 +1,192 @@
|
||||
-- ================================================================
|
||||
-- SCHEMA - App Recolección Inteligente · Hackathon Celaya 2026
|
||||
-- ================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 1. USERS
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
email VARCHAR(255) UNIQUE,
|
||||
phone VARCHAR(20) UNIQUE,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
fcm_token VARCHAR(500),
|
||||
rol VARCHAR(20) NOT NULL DEFAULT 'ciudadano',
|
||||
activo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT chk_contacto CHECK (email IS NOT NULL OR phone IS NOT NULL)
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 2. RUTAS
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE rutas (
|
||||
id SERIAL PRIMARY KEY,
|
||||
route_id VARCHAR(20) NOT NULL UNIQUE,
|
||||
nombre VARCHAR(255) NOT NULL,
|
||||
truck_id SMALLINT NOT NULL,
|
||||
hora_inicio TIME NOT NULL,
|
||||
hora_fin_ref TIME NOT NULL,
|
||||
activa BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 3. RUTA_POSICIONES
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE ruta_posiciones (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
ruta_id INT NOT NULL REFERENCES rutas(id) ON DELETE CASCADE,
|
||||
position_id SMALLINT NOT NULL,
|
||||
lat DECIMAL(9,6) NOT NULL,
|
||||
lng DECIMAL(9,6) NOT NULL,
|
||||
speed_kmh SMALLINT NOT NULL DEFAULT 0,
|
||||
ts_referencia TIMESTAMPTZ NOT NULL,
|
||||
offset_seg INT NOT NULL DEFAULT 0,
|
||||
punto GEOGRAPHY(POINT, 4326) NOT NULL,
|
||||
CONSTRAINT uq_ruta_posicion UNIQUE (ruta_id, position_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_ruta_pos_ruta ON ruta_posiciones (ruta_id, position_id);
|
||||
CREATE INDEX idx_ruta_pos_punto ON ruta_posiciones USING GIST (punto);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 4. COLONIAS
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE colonias (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nombre VARCHAR(255) NOT NULL UNIQUE,
|
||||
ruta_id INT NOT NULL REFERENCES rutas(id),
|
||||
horario_turno VARCHAR(20) NOT NULL,
|
||||
hora_inicio_est TIME NOT NULL,
|
||||
hora_fin_est TIME NOT NULL,
|
||||
poligono GEOGRAPHY(POLYGON, 4326),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_colonias_ruta ON colonias (ruta_id);
|
||||
CREATE INDEX idx_colonias_pol ON colonias USING GIST (poligono) WHERE poligono IS NOT NULL;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 5. DOMICILIOS
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE domicilios (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
alias VARCHAR(50) NOT NULL,
|
||||
direccion_texto TEXT NOT NULL,
|
||||
lat DECIMAL(9,6) NOT NULL,
|
||||
lng DECIMAL(9,6) NOT NULL,
|
||||
coordenada GEOGRAPHY(POINT, 4326) NOT NULL,
|
||||
colonia_id INT REFERENCES colonias(id) ON DELETE SET NULL,
|
||||
validado BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_domicilio_alias UNIQUE (user_id, alias)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_domicilios_user ON domicilios (user_id);
|
||||
CREATE INDEX idx_domicilios_col ON domicilios (colonia_id);
|
||||
CREATE INDEX idx_domicilios_coord ON domicilios USING GIST (coordenada);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 6. ESTADO_RUTA (actualizado por el simulador/cron)
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE estado_ruta (
|
||||
ruta_id INT PRIMARY KEY REFERENCES rutas(id) ON DELETE CASCADE,
|
||||
estado VARCHAR(20) NOT NULL DEFAULT 'INACTIVA',
|
||||
position_id_actual SMALLINT NOT NULL DEFAULT 1,
|
||||
lat_actual DECIMAL(9,6),
|
||||
lng_actual DECIMAL(9,6),
|
||||
hora_real_inicio TIMESTAMPTZ,
|
||||
hora_estim_fin TIMESTAMPTZ,
|
||||
minutos_retraso SMALLINT NOT NULL DEFAULT 0,
|
||||
motivo_retraso TEXT,
|
||||
actualizado_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 7. NOTIFICACION_TEMPLATES
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE notificacion_templates (
|
||||
id SERIAL PRIMARY KEY,
|
||||
trigger_event VARCHAR(30) NOT NULL UNIQUE,
|
||||
position_id_trigger SMALLINT NOT NULL,
|
||||
titulo VARCHAR(150) NOT NULL,
|
||||
cuerpo TEXT NOT NULL,
|
||||
activo BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 8. NOTIFICACIONES
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE notificaciones (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domicilio_id UUID REFERENCES domicilios(id) ON DELETE SET NULL,
|
||||
template_id INT REFERENCES notificacion_templates(id),
|
||||
ruta_id INT REFERENCES rutas(id),
|
||||
titulo VARCHAR(150) NOT NULL,
|
||||
mensaje TEXT NOT NULL,
|
||||
enviada_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
leida BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
leida_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notif_user ON notificaciones (user_id, enviada_at DESC);
|
||||
CREATE INDEX idx_notif_no_leida ON notificaciones (user_id) WHERE leida = FALSE;
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 9. REPORTES
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE reportes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
domicilio_id UUID REFERENCES domicilios(id) ON DELETE SET NULL,
|
||||
ruta_id INT REFERENCES rutas(id),
|
||||
tipo VARCHAR(40) NOT NULL,
|
||||
descripcion TEXT,
|
||||
calificacion SMALLINT CHECK (calificacion BETWEEN 1 AND 5),
|
||||
estado VARCHAR(20) NOT NULL DEFAULT 'abierto',
|
||||
creado_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_reportes_user ON reportes (user_id);
|
||||
CREATE INDEX idx_reportes_ruta ON reportes (ruta_id, creado_at);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- 10. GUÍA DE SEPARACIÓN
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE TABLE residuos_categorias (
|
||||
id SERIAL PRIMARY KEY,
|
||||
nombre VARCHAR(50) NOT NULL,
|
||||
descripcion TEXT,
|
||||
color_hex CHAR(7),
|
||||
icono VARCHAR(100)
|
||||
);
|
||||
|
||||
CREATE TABLE residuos_ejemplos (
|
||||
id SERIAL PRIMARY KEY,
|
||||
categoria_id INT NOT NULL REFERENCES residuos_categorias(id) ON DELETE CASCADE,
|
||||
nombre VARCHAR(255) NOT NULL,
|
||||
descripcion TEXT
|
||||
);
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- TRIGGERS
|
||||
-- ----------------------------------------------------------------
|
||||
CREATE OR REPLACE FUNCTION fn_set_updated_at()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER trg_users_updated_at
|
||||
BEFORE UPDATE ON users
|
||||
FOR EACH ROW EXECUTE FUNCTION fn_set_updated_at();
|
||||
49
init/02_seed_static.sql
Normal file
49
init/02_seed_static.sql
Normal file
@@ -0,0 +1,49 @@
|
||||
-- ================================================================
|
||||
-- SEED ESTÁTICO - Templates de notificaciones y Guía de residuos
|
||||
-- ================================================================
|
||||
|
||||
INSERT INTO notificacion_templates (trigger_event, position_id_trigger, titulo, cuerpo)
|
||||
VALUES
|
||||
(
|
||||
'ROUTE_START', 2,
|
||||
'¡Ruta Iniciada!',
|
||||
'El camión recolector ha salido del Relleno Sanitario rumbo a tu sector. Asegúrate de tener listos tus residuos.'
|
||||
),
|
||||
(
|
||||
'TRUCK_PROXIMITY', 4,
|
||||
'Camión Cercano',
|
||||
'El camión está a menos de 15 minutos de tu domicilio. Es momento de sacar tus bolsas a la acera.'
|
||||
),
|
||||
(
|
||||
'ROUTE_COMPLETED', 8,
|
||||
'Servicio Finalizado',
|
||||
'El camión de tu sector ha concluido su jornada de recolección diaria.'
|
||||
);
|
||||
|
||||
INSERT INTO residuos_categorias (nombre, descripcion, color_hex, icono) VALUES
|
||||
('Orgánicos', 'Residuos de origen natural que se descomponen', '#4CAF50', 'leaf'),
|
||||
('Reciclables', 'Materiales que pueden recuperarse y reutilizarse', '#2196F3', 'recycle'),
|
||||
('Sanitarios', 'Residuos que implican riesgo de contagio o contaminación', '#F44336', 'biohazard'),
|
||||
('Especiales', 'Residuos que requieren manejo diferenciado', '#FF9800', 'warning');
|
||||
|
||||
INSERT INTO residuos_ejemplos (categoria_id, nombre, descripcion) VALUES
|
||||
(1, 'Cáscaras de frutas y verduras', 'Restos de cocina de origen vegetal'),
|
||||
(1, 'Sobras de comida', 'Alimentos preparados ya no consumibles'),
|
||||
(1, 'Posos de café y filtros', 'Biodegradables, ideales para composta'),
|
||||
(1, 'Cáscaras de huevo', 'Se descomponen lentamente, aptas para composta'),
|
||||
(1, 'Servilletas y papel sucios', 'Con grasa o alimentos, van con orgánicos'),
|
||||
(2, 'Botellas y envases PET', 'Plástico transparente, enjuagados'),
|
||||
(2, 'Latas de aluminio y hojalata', 'Limpias y aplastadas para ahorrar espacio'),
|
||||
(2, 'Cartón y papel limpio', 'Sin grasa ni humedad'),
|
||||
(2, 'Vidrio', 'Botellas y frascos; NO vidrio de ventana'),
|
||||
(2, 'Envases Tetra Pak', 'Enjuagar y aplastar antes de depositar'),
|
||||
(3, 'Pañales desechables', 'Van en bolsa cerrada'),
|
||||
(3, 'Toallas sanitarias y tampones', 'Bolsa sellada por higiene'),
|
||||
(3, 'Papel higiénico usado', 'No es reciclable'),
|
||||
(3, 'Gasas y vendas usadas', 'Materiales con sangre u otros fluidos'),
|
||||
(3, 'Cubrebocas y guantes usados', 'Doble bolsa por precaución'),
|
||||
(4, 'Pilas y baterías', 'Llevar a puntos RAEE o tiendas participantes'),
|
||||
(4, 'Medicamentos caducos', 'Farmacias con programa de devolución'),
|
||||
(4, 'Aceite vegetal usado', 'Envasar en botella; NO tirar por el drenaje'),
|
||||
(4, 'Electrónicos y cables', 'Puntos de reciclaje RAEE'),
|
||||
(4, 'Pinturas y solventes', 'Centro de acopio municipal; nunca al bote normal');
|
||||
146
init/03_seed_json.js
Normal file
146
init/03_seed_json.js
Normal file
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* SEED JSON - App Recolección Inteligente
|
||||
* Uso: npm run seed
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
const { Pool } = require('pg');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const pool = new Pool({
|
||||
host: process.env.DB_HOST || 'localhost',
|
||||
port: parseInt(process.env.DB_PORT || '5433'),
|
||||
database: process.env.DB_NAME || 'recoleccion_db',
|
||||
user: process.env.DB_USER || 'recoleccion',
|
||||
password: process.env.DB_PASSWORD || 'recoleccion123',
|
||||
});
|
||||
|
||||
function readJson(filename) {
|
||||
const filepath = path.join(__dirname, '..', 'data', filename);
|
||||
return JSON.parse(fs.readFileSync(filepath, 'utf-8'));
|
||||
}
|
||||
|
||||
function parseHorario(str) {
|
||||
const match = str.match(/^(\w+)\s*\((\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})\)/);
|
||||
if (!match) throw new Error(`Horario no reconocido: "${str}"`);
|
||||
return { turno: match[1], inicio: match[2], fin: match[3] };
|
||||
}
|
||||
|
||||
async function seed() {
|
||||
const client = await pool.connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
|
||||
const rutas = readJson('rutas.json');
|
||||
const colonias = readJson('colonias-rutas.json');
|
||||
|
||||
console.log(`\n📦 Sembrando ${rutas.length} rutas...`);
|
||||
|
||||
for (const ruta of rutas) {
|
||||
const pos1 = ruta.positions.find(p => p.positionId === 1);
|
||||
const pos8 = ruta.positions.find(p => p.positionId === 8);
|
||||
|
||||
const horaInicio = new Date(pos1.timestamp).toISOString().substring(11, 16);
|
||||
const horaFin = new Date(pos8.timestamp).toISOString().substring(11, 16);
|
||||
|
||||
// ── Insertar ruta ──────────────────────────────────────
|
||||
const { rows: [rutaRow] } = await client.query(
|
||||
`INSERT INTO rutas (route_id, nombre, truck_id, hora_inicio, hora_fin_ref)
|
||||
VALUES ($1, $2, $3, $4::TIME, $5::TIME)
|
||||
ON CONFLICT (route_id) DO UPDATE
|
||||
SET nombre = EXCLUDED.nombre,
|
||||
truck_id = EXCLUDED.truck_id,
|
||||
hora_inicio = EXCLUDED.hora_inicio,
|
||||
hora_fin_ref = EXCLUDED.hora_fin_ref
|
||||
RETURNING id`,
|
||||
[ruta.routeId, ruta.name, ruta.truckId, horaInicio, horaFin]
|
||||
);
|
||||
const rutaId = rutaRow.id;
|
||||
|
||||
// ── Estado inicial ─────────────────────────────────────
|
||||
await client.query(
|
||||
`INSERT INTO estado_ruta (ruta_id, estado, position_id_actual)
|
||||
VALUES ($1, 'INACTIVA', 1)
|
||||
ON CONFLICT (ruta_id) DO NOTHING`,
|
||||
[rutaId]
|
||||
);
|
||||
|
||||
// ── Posiciones ─────────────────────────────────────────
|
||||
const tsBase = new Date(pos1.timestamp).getTime();
|
||||
|
||||
for (const pos of ruta.positions) {
|
||||
const offsetSeg = Math.round((new Date(pos.timestamp).getTime() - tsBase) / 1000);
|
||||
|
||||
// IMPORTANTE: punto va al final ($8) para que los parámetros
|
||||
// sean estrictamente secuenciales $1..$8 sin saltos
|
||||
const wkt = `SRID=4326;POINT(${pos.lng} ${pos.lat})`;
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO ruta_posiciones
|
||||
(ruta_id, position_id, lat, lng, speed_kmh, ts_referencia, offset_seg, punto)
|
||||
VALUES
|
||||
($1, $2, $3, $4, $5, $6::timestamptz,$7, $8::geography)
|
||||
ON CONFLICT (ruta_id, position_id) DO UPDATE
|
||||
SET lat = EXCLUDED.lat,
|
||||
lng = EXCLUDED.lng,
|
||||
speed_kmh = EXCLUDED.speed_kmh,
|
||||
ts_referencia = EXCLUDED.ts_referencia,
|
||||
offset_seg = EXCLUDED.offset_seg,
|
||||
punto = EXCLUDED.punto`,
|
||||
[rutaId, pos.positionId, pos.lat, pos.lng,
|
||||
pos.speed, pos.timestamp, offsetSeg, wkt]
|
||||
);
|
||||
}
|
||||
|
||||
console.log(` ✓ ${ruta.routeId} — ${ruta.name}`);
|
||||
}
|
||||
|
||||
// ── Colonias ───────────────────────────────────────────────
|
||||
console.log(`\n🗺️ Sembrando ${colonias.length} colonias...`);
|
||||
|
||||
for (const col of colonias) {
|
||||
const { rows: [rutaRow] } = await client.query(
|
||||
'SELECT id FROM rutas WHERE route_id = $1',
|
||||
[col.routeId]
|
||||
);
|
||||
|
||||
if (!rutaRow) {
|
||||
console.warn(` ⚠️ ${col.routeId} no encontrada para "${col.colonia}". Omitida.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const h = parseHorario(col.horarioEstimado);
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO colonias (nombre, ruta_id, horario_turno, hora_inicio_est, hora_fin_est)
|
||||
VALUES ($1, $2, $3, $4::TIME, $5::TIME)
|
||||
ON CONFLICT (nombre) DO UPDATE
|
||||
SET ruta_id = EXCLUDED.ruta_id,
|
||||
horario_turno = EXCLUDED.horario_turno,
|
||||
hora_inicio_est = EXCLUDED.hora_inicio_est,
|
||||
hora_fin_est = EXCLUDED.hora_fin_est`,
|
||||
[col.colonia, rutaRow.id, h.turno, h.inicio, h.fin]
|
||||
);
|
||||
|
||||
console.log(` ✓ ${col.colonia} → ${col.routeId} (${h.turno} ${h.inicio}-${h.fin})`);
|
||||
}
|
||||
|
||||
await client.query('COMMIT');
|
||||
console.log('\n✅ Seed completado exitosamente.\n');
|
||||
|
||||
} catch (err) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('\n❌ Error en el seed, se hizo ROLLBACK:', err.message);
|
||||
throw err;
|
||||
} finally {
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
seed().catch((err) => {
|
||||
console.error('💥 Error fatal:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user