feat: Funcion de sistema tenants

This commit is contained in:
2026-02-23 13:01:24 -07:00
parent ceea67eb2b
commit 1ccc39732b
58 changed files with 1889 additions and 315 deletions

View File

@@ -0,0 +1,7 @@
from app.models.ticket import Ticket
from app.models import relationships # ensure relationships are loaded
from sqlalchemy import inspect
mapper = inspect(Ticket)
print("Relationships:", [r.key for r in mapper.relationships])
print("Columns:", [c.key for c in mapper.columns])

40
scripts/check_sla.py Normal file
View File

@@ -0,0 +1,40 @@
"""Check SLA state of tickets and categories"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async with engine.connect() as c:
print("=== CATEGORIES SLA HOURS ===")
r = await c.execute(text(
"SELECT name, sla_response_hours, sla_resolution_hours "
"FROM ticket_categories "
"ORDER BY name"
))
for row in r.fetchall():
print(f" {row[0]}: response={row[1]}h, resolution={row[2]}h")
print("\n=== TICKETS SLA DATES ===")
r2 = await c.execute(text(
"SELECT ticket_number, category_id, sla_response_due, sla_resolution_due "
"FROM tickets "
"ORDER BY created_at "
"LIMIT 10"
))
for row in r2.fetchall():
print(f" {row[0]}: cat={str(row[1])[:8] if row[1] else 'None'}, sla_resp={row[2]}, sla_res={row[3]}")
await engine.dispose()
asyncio.run(run())

41
scripts/debug_category.py Normal file
View File

@@ -0,0 +1,41 @@
"""Debug: check if ticket's category_id maps to a valid category and what tenant it belongs to"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine
from sqlalchemy import text
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url)
async with engine.connect() as c:
# Check tickets and their category names via direct JOIN
r = await c.execute(text("""
SELECT t.ticket_number, t.category_id,
cat.name as category_name, cat.tenant_id as cat_tenant,
t.tenant_id as ticket_tenant
FROM tickets t
LEFT JOIN ticket_categories cat ON cat.id = t.category_id
WHERE t.category_id IS NOT NULL
LIMIT 10
"""))
print("=== TICKET -> CATEGORY JOIN ===")
for row in r.fetchall():
match = "✓ SAME TENANT" if row[3] == row[4] else "✗ DIFFERENT TENANT"
print(f" {row[0]}: cat_id={str(row[1])[:8]}, cat_name={row[2]}, {match}")
# Check what tenant aduanasoft-demo is
r2 = await c.execute(text("SELECT id, slug FROM tenants WHERE slug='aduanasoft-demo'"))
tenant = r2.fetchone()
print(f"\nTenant aduanasoft-demo: {tenant[0] if tenant else 'NOT FOUND'}")
await engine.dispose()
asyncio.run(run())

49
scripts/debug_orm.py Normal file
View File

@@ -0,0 +1,49 @@
"""Debug: check SQLAlchemy ORM category loading"""
import asyncio
import os
import sys
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, selectinload
from sqlalchemy import select
from app.models.ticket import Ticket
from app.models.category import Category
async def run():
database_url = os.environ.get('DATABASE_URL', 'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager')
engine = create_async_engine(database_url, echo=True)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Test selectinload
result = await session.execute(
select(Ticket)
.options(selectinload(Ticket.category))
.where(Ticket.category_id != None)
.limit(3)
)
tickets = result.scalars().all()
print(f"\n=== ORM RESULTS ({len(tickets)} tickets) ===")
for t in tickets:
print(f" {t.ticket_number}: category_id={t.category_id}, category={t.category}")
if t.category:
print(f" -> category.name={t.category.name}")
else:
print(f" -> category is None!")
# Check if Category model can be queried directly
r2 = await session.execute(select(Category).limit(3))
cats = r2.scalars().all()
print(f"\n=== DIRECT CATEGORY QUERY ({len(cats)} categories) ===")
for c in cats:
print(f" id={c.id}, name={c.name}")
await engine.dispose()
asyncio.run(run())

82
scripts/fix_sla_dates.py Normal file
View File

@@ -0,0 +1,82 @@
"""
Backfill SLA response_due and resolution_due on all tickets that have
a category but no SLA dates set.
"""
import asyncio
import os
import sys
from datetime import timedelta
sys.path.insert(0, '/app')
os.chdir('/app')
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import text
async def run():
database_url = os.environ.get(
'DATABASE_URL',
'postgresql+asyncpg://postgres:postgres@db:5432/servicemanager'
)
engine = create_async_engine(database_url)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Find all tickets with a category but missing SLA dates
result = await session.execute(text("""
SELECT
t.id,
t.created_at,
c.sla_response_hours,
c.sla_resolution_hours
FROM tickets t
JOIN ticket_categories c ON c.id = t.category_id
WHERE t.sla_response_due IS NULL
AND t.sla_resolution_due IS NULL
"""))
rows = result.fetchall()
print(f"Tickets to update: {len(rows)}")
updated = 0
for row in rows:
ticket_id, created_at, resp_h, res_h = row
# Ensure naive datetime for DB compatibility
if hasattr(created_at, 'tzinfo') and created_at.tzinfo is not None:
from datetime import timezone
created_at = created_at.astimezone(timezone.utc).replace(tzinfo=None)
sla_response_due = created_at + timedelta(hours=float(resp_h))
sla_resolution_due = created_at + timedelta(hours=float(res_h))
await session.execute(text("""
UPDATE tickets
SET sla_response_due = :sla_resp,
sla_resolution_due = :sla_res
WHERE id = :ticket_id
"""), {
"sla_resp": sla_response_due,
"sla_res": sla_resolution_due,
"ticket_id": ticket_id,
})
updated += 1
await session.commit()
print(f"Updated {updated} tickets with SLA dates.")
# Verify
r2 = await session.execute(text("""
SELECT ticket_number, sla_response_due, sla_resolution_due
FROM tickets
WHERE sla_response_due IS NOT NULL
ORDER BY created_at
LIMIT 10
"""))
print("\nSample of tickets with SLA dates now:")
for row in r2.fetchall():
print(f" {row[0]}: response_due={row[1]}, resolution_due={row[2]}")
await engine.dispose()
asyncio.run(run())

238
scripts/seed_data.py Normal file
View File

@@ -0,0 +1,238 @@
"""
Script de datos iniciales (seed) para ServiceManagerWeb.
Crea categorías, sistemas, y usuarios de prueba en el tenant aduanasoft-demo.
Ejecutar desde dentro del contenedor: python /scripts/seed_data.py
"""
import asyncio
import sys
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
# Configurar path para importar el app
sys.path.insert(0, '/app')
from app.core.config import get_settings
settings = get_settings()
from app.core.security import security
from app.models.tenant import Tenant
from app.models.category import Category
from app.models.system import System
from app.models.user import User, UserRole
TENANT_SLUG = "aduanasoft-demo"
CATEGORIES = [
{
"name": "Soporte Técnico",
"description": "Problemas técnicos con sistemas y aplicaciones empresariales",
"color": "#EF4444",
"sla_response_hours": 2,
"sla_resolution_hours": 24,
"is_active": True,
},
{
"name": "Facturación",
"description": "Consultas y problemas relacionados con facturación y pagos",
"color": "#F59E0B",
"sla_response_hours": 4,
"sla_resolution_hours": 48,
"is_active": True,
},
{
"name": "Incidentes Críticos",
"description": "Fallas graves que afectan la operación del negocio",
"color": "#DC2626",
"sla_response_hours": 1,
"sla_resolution_hours": 8,
"is_active": True,
},
{
"name": "Consultas Generales",
"description": "Preguntas generales sobre productos y servicios",
"color": "#3B82F6",
"sla_response_hours": 8,
"sla_resolution_hours": 72,
"is_active": True,
},
{
"name": "Capacitación",
"description": "Solicitudes de entrenamiento y capacitación en sistemas",
"color": "#8B5CF6",
"sla_response_hours": 24,
"sla_resolution_hours": 96,
"is_active": True,
},
{
"name": "Infraestructura",
"description": "Problemas de red, servidores y componentes de infraestructura",
"color": "#06B6D4",
"sla_response_hours": 2,
"sla_resolution_hours": 16,
"is_active": True,
},
]
SYSTEMS = [
{
"name": "ERP Aduanero",
"description": "Sistema principal de gestión aduanera y comercio exterior",
"is_active": True,
},
{
"name": "Portal Web",
"description": "Portal de autogestión y consultas en línea para clientes",
"is_active": True,
},
{
"name": "Gestión Documental",
"description": "Sistema de administración y archivo de documentos aduaneros",
"is_active": True,
},
{
"name": "App Móvil",
"description": "Aplicación móvil para seguimiento de trámites en tiempo real",
"is_active": True,
},
{
"name": "Reportes y BI",
"description": "Plataforma de inteligencia de negocio y generación de reportes",
"is_active": True,
},
]
USERS = [
{
"email": "agente@aduanasoft.com",
"password": "agente123",
"first_name": "Carlos",
"last_name": "Agente",
"role": UserRole.AGENT,
"is_active": True,
},
{
"email": "manager@aduanasoft.com",
"password": "manager123",
"first_name": "Laura",
"last_name": "Gerente",
"role": UserRole.SUPPORT_MANAGER,
"is_active": True,
},
{
"email": "cliente@empresa-demo.com",
"password": "cliente123",
"first_name": "Roberto",
"last_name": "Cliente",
"role": UserRole.CLIENT_USER,
"is_active": True,
},
{
"email": "admin-cliente@empresa-demo.com",
"password": "clienteadmin123",
"first_name": "Ana",
"last_name": "Admin",
"role": UserRole.CLIENT_ADMIN,
"is_active": True,
},
]
async def main():
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# 1. Obtener tenant
result = await session.execute(
select(Tenant).where(Tenant.slug == TENANT_SLUG)
)
tenant = result.scalar_one_or_none()
if not tenant:
print(f"ERROR: Tenant '{TENANT_SLUG}' no encontrado. Ejecuta el seed de tenants primero.")
return
tenant_id = tenant.id
print(f"✅ Tenant: {tenant.name} (id={str(tenant_id)[:8]}...)")
# 2. Categorías
print("\n📁 Creando categorías:")
# Eliminar la categoría "Infraestructura" duplicada creada previamente si existe
existing_cats = (await session.execute(
select(Category).where(Category.tenant_id == tenant_id)
)).scalars().all()
existing_names = {c.name for c in existing_cats}
created = 0
for cat_data in CATEGORIES:
if cat_data["name"] in existing_names:
print(f" ⏭ Ya existe: {cat_data['name']}")
continue
cat = Category(
tenant_id=tenant_id,
**cat_data,
)
session.add(cat)
print(f"{cat_data['name']} (resp={cat_data['sla_response_hours']}h, resol={cat_data['sla_resolution_hours']}h)")
created += 1
await session.flush()
print(f"{created} categorías creadas, {len(existing_names)} ya existían")
# 3. Sistemas
print("\n🖥 Creando sistemas:")
existing_sys = (await session.execute(
select(System).where(System.tenant_id == tenant_id)
)).scalars().all()
existing_sys_names = {s.name for s in existing_sys}
created_sys = 0
for sys_data in SYSTEMS:
if sys_data["name"] in existing_sys_names:
print(f" ⏭ Ya existe: {sys_data['name']}")
continue
sys_obj = System(
tenant_id=tenant_id,
**sys_data,
)
session.add(sys_obj)
print(f"{sys_data['name']}")
created_sys += 1
await session.flush()
print(f"{created_sys} sistemas creados")
# 4. Usuarios
print("\n👤 Creando usuarios:")
existing_users = (await session.execute(
select(User).where(User.tenant_id == tenant_id)
)).scalars().all()
existing_emails = {u.email for u in existing_users}
created_users = 0
for user_data in USERS:
if user_data["email"] in existing_emails:
print(f" ⏭ Ya existe: {user_data['email']}")
continue
pwd = user_data.pop("password")
hashed_pwd = security.hash_password(pwd)
user = User(
tenant_id=tenant_id,
password_hash=hashed_pwd,
**user_data,
)
session.add(user)
print(f"{user_data['email']} [{user_data['role'].value}] pwd={pwd}")
created_users += 1
await session.commit()
print(f"{created_users} usuarios creados")
await engine.dispose()
print("\n🎉 Seed completado exitosamente.")
if __name__ == "__main__":
asyncio.run(main())

215
scripts/seed_tickets.py Normal file
View File

@@ -0,0 +1,215 @@
"""
Seed de tickets de demostración para ServiceManagerWeb.
Crea tickets variados con categorías, sistemas y prioridades diferentes.
"""
import asyncio
import sys
sys.path.insert(0, '/app')
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select
from app.core.config import get_settings
from app.models.tenant import Tenant
from app.models.user import User
from app.models.category import Category
from app.models.system import System
from app.models.ticket import Ticket, TicketStatus, TicketPriority
from app.models.comment import TicketComment
settings = get_settings()
TENANT_SLUG = "aduanasoft-demo"
DEMO_TICKETS = [
{
"subject": "Error en módulo de importaciones del ERP",
"description": "Al intentar registrar una nueva importación, el sistema arroja el error 'NullReferenceException' en el módulo de gestión aduanera. Esto bloquea completamente el flujo de trabajo de importaciones para todos los usuarios del área.",
"priority": TicketPriority.URGENT,
"category_name": "Incidentes Críticos",
"system_name": "ERP Aduanero",
"status": TicketStatus.IN_PROGRESS,
},
{
"subject": "No puedo acceder al portal web desde el lunes",
"description": "Desde el lunes 18 de febrero, el portal web no carga correctamente. La página queda en blanco y en la consola del navegador aparece un error 503. He probado desde diferentes equipos y navegadores con el mismo resultado.",
"priority": TicketPriority.HIGH,
"category_name": "Soporte Técnico",
"system_name": "Portal Web",
"status": TicketStatus.NEW,
},
{
"subject": "Solicitud de capacitación en módulo de reportes",
"description": "Nuestro equipo necesita una sesión de capacitación para el nuevo módulo de reportes y BI. Somos 8 personas del área de contabilidad. Por favor confirmar disponibilidad para la semana del 3 de marzo.",
"priority": TicketPriority.LOW,
"category_name": "Capacitación",
"system_name": "Reportes y BI",
"status": TicketStatus.NEW,
},
{
"subject": "Discrepancia en factura #F-2024-0892",
"description": "La factura F-2024-0892 emitida el 15 de febrero muestra un monto de $47,500 MXN pero según nuestros registros el importe correcto es $45,200 MXN. Favor revisar y emitir nota de crédito si corresponde.",
"priority": TicketPriority.MEDIUM,
"category_name": "Facturación",
"system_name": None,
"status": TicketStatus.WAITING_CUSTOMER,
},
{
"subject": "La app móvil no sincroniza pedidos pendientes",
"description": "En la aplicación móvil versión 3.2.1, los pedidos creados desde el celular no se sincronizan al ERP. Ya desinstalé y reinstalé la app. El problema persiste en iOS y Android.",
"priority": TicketPriority.HIGH,
"category_name": "Soporte Técnico",
"system_name": "App Móvil",
"status": TicketStatus.IN_PROGRESS,
},
{
"subject": "Consulta sobre proceso de pedimento de exportación",
"description": "¿Cuáles son los documentos necesarios para tramitar un pedimento de exportación bajo la clave A1? Necesito la lista actualizada con los cambios del SAT de enero 2025.",
"priority": TicketPriority.LOW,
"category_name": "Consultas Generales",
"system_name": None,
"status": TicketStatus.RESOLVED,
},
{
"subject": "Servidores lentos en horario pico (11am-2pm)",
"description": "Durante el horario de 11am a 2pm el ERP se vuelve extremadamente lento. Las consultas que normalmente tardan 2 segundos pueden llevar hasta 45 segundos. El problema afecta a todos los usuarios concurrentemente.",
"priority": TicketPriority.URGENT,
"category_name": "Infraestructura",
"system_name": "ERP Aduanero",
"status": TicketStatus.IN_PROGRESS,
},
{
"subject": "Error al generar reporte mensual de exportaciones",
"description": "El reporte de exportaciones del mes de enero no se genera correctamente. Al dar clic en 'Generar PDF' aparece un error: 'Timeout al procesar el reporte'. Necesito este reporte para la reunión del viernes.",
"priority": TicketPriority.MEDIUM,
"category_name": "Soporte Técnico",
"system_name": "Reportes y BI",
"status": TicketStatus.NEW,
},
]
DEMO_COMMENTS = {
"Error en módulo de importaciones del ERP": [
("admin@aduanasoft.com", "Hemos identificado el problema. Es un error en la versión 4.2.1 del ERP. Estamos aplicando el parche de emergencia. Estimamos resolución en 2 horas.", False),
("cliente@empresa-demo.com", "Gracias por la respuesta rápida. ¿Podemos continuar con el registro manual mientras tanto?", False),
],
"No puedo acceder al portal web desde el lunes": [
("agente@aduanasoft.com", "Hemos recibido tu ticket. ¿Puedes indicarnos el mensaje exacto que aparece en la consola del navegador? Esto nos ayudará a diagnosticar más rápido.", False),
],
}
async def main():
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async with async_session() as session:
# Obtener tenant
result = await session.execute(select(Tenant).where(Tenant.slug == TENANT_SLUG))
tenant = result.scalar_one_or_none()
if not tenant:
print(f"ERROR: Tenant '{TENANT_SLUG}' no encontrado.")
return
tenant_id = tenant.id
print(f"✅ Tenant: {tenant.name}")
# Obtener usuario cliente para asignar como creador
client_user_result = await session.execute(
select(User).where(User.email == "cliente@empresa-demo.com").limit(1)
)
client_user = client_user_result.scalar_one_or_none()
if not client_user:
print("ERROR: cliente@empresa-demo.com no encontrado")
return
# Obtener agente para comentarios
agent_result = await session.execute(
select(User).where(User.email == "agente@aduanasoft.com").limit(1)
)
agent_user = agent_result.scalar_one_or_none()
admin_result = await session.execute(
select(User).where(User.email == "admin@aduanasoft.com").limit(1)
)
admin_user = admin_result.scalar_one_or_none()
# Mapear categorías y sistemas
cats_result = await session.execute(select(Category).where(Category.tenant_id == tenant_id))
cats = {c.name: c for c in cats_result.scalars().all()}
sys_result = await session.execute(select(System).where(System.tenant_id == tenant_id))
systems = {s.name: s for s in sys_result.scalars().all()}
# Verificar tickets existentes
existing_tickets_result = await session.execute(
select(Ticket).where(Ticket.tenant_id == tenant_id)
)
existing_subjects = {t.subject for t in existing_tickets_result.scalars().all()}
print(f"\n🎫 Creando tickets de demostración:")
created = 0
ticket_objects = {}
for ticket_data in DEMO_TICKETS:
subject = ticket_data["subject"]
if subject in existing_subjects:
print(f" ⏭ Ya existe: {subject[:50]}...")
continue
cat_name = ticket_data.pop("category_name", None)
sys_name = ticket_data.pop("system_name", None)
category = cats.get(cat_name) if cat_name else None
system = systems.get(sys_name) if sys_name else None
ticket = Ticket(
tenant_id=tenant_id,
category_id=category.id if category else None,
affected_system_id=system.id if system else None,
created_by=client_user.id,
**ticket_data,
)
session.add(ticket)
await session.flush() # Para obtener el ID
ticket_objects[subject] = ticket
print(f" ✓ [{ticket_data['priority'].value}] {subject[:60]}")
created += 1
await session.flush()
# Crear comentarios de demostración
print(f"\n💬 Añadiendo comentarios:")
for subject, comments in DEMO_COMMENTS.items():
ticket = ticket_objects.get(subject)
if not ticket:
continue
for author_email, content, is_internal in comments:
if author_email == "admin@aduanasoft.com":
author = admin_user
elif author_email == "agente@aduanasoft.com":
author = agent_user
else:
author = client_user
if author:
comment = TicketComment(
ticket_id=ticket.id,
author_id=author.id,
content=content,
is_internal=is_internal,
)
session.add(comment)
print(f" ✓ Comentario en '{subject[:40]}...' por {author_email}")
await session.commit()
print(f"\n{created} tickets creados exitosamente.")
await engine.dispose()
print("🎉 Seed de tickets completado.")
if __name__ == "__main__":
asyncio.run(main())