Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions backend/tests/integration/test_comprobante.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,3 +98,28 @@ def test_comprobante_errores(client, url, cabeceras, status):

def test_numero_de_factura():
assert numero_factura(7) == "KEC-00000007"


# ---------------------------------------------------------------- asientos en la factura


def _con_asientos(*asientos):
base = _detalle()
return ReservaDetalle(base.reserva, base.vuelo, tuple(asientos))


def test_la_factura_lista_los_asientos_de_la_reserva():
datos = armar_datos_factura(_con_asientos("1A", "2B"), ANA)
assert datos["detalles"][0]["asientos"] == "1A, 2B"
raiz = ElementTree.fromstring(generar_factura_xml(datos).encode("utf-8"))
producto = raiz.find("Detalle/producto")
assert [c.tag for c in producto] == ["Nombre", "Asientos", "Cantidad", "PrecioUnitario", "Subtotal"]
assert producto.findtext("Asientos") == "1A, 2B"


def test_los_caracteres_especiales_del_cliente_no_rompen_el_xml():
cliente = un_usuario(id=1, nombre="Ana <script>&\"'", correo="ana@correo.com")
xml = generar_factura_xml(armar_datos_factura(_con_asientos("1A"), cliente))
raiz = ElementTree.fromstring(xml.encode("utf-8")) # sigue siendo XML válido
assert raiz.findtext("cliente/nombre") == "Ana <script>&\"'"
assert "<script>" not in xml # va escapado como &lt;script&gt;
3 changes: 3 additions & 0 deletions backend/utils/factura_xml.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def armar_datos_factura(detalle: ReservaDetalle, cliente: Usuario) -> dict[str,
f"Pasaje {vuelo.codigo} {vuelo.origen} - {vuelo.destino} "
f"({vuelo.fecha} {vuelo.hora_salida:%H:%M})"
),
**({"asientos": ", ".join(detalle.asientos)} if detalle.asientos else {}), # "1A, 1B"
"cantidad": reserva.cantidad_asientos,
"precio": redondear(reserva.subtotal / reserva.cantidad_asientos),
"subtotal": reserva.subtotal,
Expand Down Expand Up @@ -81,6 +82,8 @@ def generar_factura_xml(datos: dict[str, Any]) -> str:
for item in datos["detalles"]:
producto = ET.SubElement(detalle, "producto")
ET.SubElement(producto, "Nombre").text = item["nombre"]
if "asientos" in item:
ET.SubElement(producto, "Asientos").text = item["asientos"]
ET.SubElement(producto, "Cantidad").text = str(item["cantidad"])
ET.SubElement(producto, "PrecioUnitario").text = item["precio"]
ET.SubElement(producto, "Subtotal").text = item["subtotal"]
Expand Down
2 changes: 1 addition & 1 deletion docs/PLANIFICACION.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ Excepción documentada: `flasgger==0.9.7.1` (la 0.9.4 no funciona con Flask 3).
| F1 | Frontend: tema Ecuador, login, registro, sesión persistente y rutas protegidas | ✅ Hecho |
| F2 | Frontend: lista paginada y búsqueda de vuelos, detalle, crear vuelo (admin), panel de administración con IVA | ✅ Hecho |
| F3 | Frontend: reservar desde el detalle, mis reservas (paginadas), modificar con versión y cancelar. **El administrador no reserva** (403 en el API, sin pantallas) | ✅ Hecho |
| F4 | Frontend: comprobante XML (panel "Factura XML") | ⏳ Siguiente (F4) |
| F4 | Frontend: comprobante (factura legible + panel "Factura XML" con copiar) y mejoras de usabilidad con las heurísticas de Nielsen (barra de navegación inferior, logo → inicio, menú de cuenta, migas de pan, aviso al salir con cambios). Ver `docs/USABILIDAD.md` | ✅ Hecho |
| P | Pruebas funcionales y capturas | Pendiente |
| D | Diagrama SOA + informe PDF | Pendiente |

Expand Down
32 changes: 32 additions & 0 deletions docs/USABILIDAD.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Kuntur EC · Usabilidad (heurísticas de Nielsen)

Revisión de la interfaz con las 10 heurísticas de usabilidad de Jakob Nielsen y lo que se hizo con cada una.
El caso que la disparó: en el detalle de una reserva un usuario común solo tenía la flecha "Volver"; no
sabía cómo ir al inicio ni a sus reservas.

## Hallazgos y mejoras

| # | Heurística | Problema detectado | Mejora aplicada |
|---|---|---|---|
| 1 | Visibilidad del estado del sistema | El usuario no sabía en qué sección estaba. | Barra de navegación inferior con la sección resaltada y migas de pan ("Mis reservas › KEC-0058 › Comprobante"). Contadores ("12 vuelos disponibles", "Asientos 3 de 10"), indicadores de carga y avisos de éxito. |
| 2 | Relación con el mundo real | Datos técnicos o en inglés. | Todo en español; fechas "mar 1 dic 2026", horas 24 h, dinero "$75.50", asientos como en un avión (1A, 2B) con pasillo. |
| 3 | Control y libertad del usuario | Solo se podía retroceder con la flecha; no había salida clara. | Logo siempre lleva al inicio; barra inferior a cualquier sección; migas tocables; "Reiniciar selección" / "Restablecer"; aviso "¿Salir sin guardar?" al abandonar el mapa con cambios; confirmación al cancelar una reserva y al cerrar sesión. |
| 4 | Consistencia y estándares | Íconos sueltos sin texto (escudo, ticket, nube) y acciones distintas según la pantalla. | Menú de cuenta único (avatar) y barra inferior con **texto** en cada destino; misma barra superior en todas las pantallas; botones con el mismo nombre para la misma acción. |
| 5 | Prevención de errores | Se podían escribir datos fuera de rango. | Campos que no dejan escribir lo inválido (letras en precio, más de 200 asientos, emojis); máximo 10 asientos por reserva (el resto se atenúa); asientos ocupados bloqueados; botones desactivados si no hay nada que enviar; protección contra doble toque. |
| 6 | Reconocer antes que recordar | El usuario debía recordar cómo llegó. | Migas de pan, filtros que se conservan al volver, leyenda del mapa (Disponible / Seleccionado / Ocupado), resumen fijo con asientos y total. |
| 7 | Flexibilidad y eficiencia | — | Búsqueda por origen/destino/fecha, paginación, Enter envía los formularios, copiar el XML con un toque. |
| 8 | Diseño estético y minimalista | — | Una acción principal por pantalla; paleta sobria de Ecuador; el panel del administrador separado del resto. |
| 9 | Ayudar a reconocer, diagnosticar y recuperarse de errores | — | Mensajes en español que dicen qué pasó y qué hacer ("Actualice el mapa y elija de nuevo"), botón "Reintentar", y si alguien toma un asiento la app lo quita de la selección y recarga el mapa. |
| 10 | Ayuda y documentación | El mapa no explicaba cómo usarse. | Texto de ayuda breve sobre el mapa ("Toque los asientos que quiere (hasta 10)…"), textos auxiliares en los formularios ("Máximo $10000, hasta 2 decimales"). |

## Navegación (resumen)

- **Cliente:** Vuelos · Mis reservas (barra inferior). **Administrador:** Vuelos · Administración.
- Arriba, siempre: logo (→ inicio), flecha Volver (cuando hay a dónde volver) y el avatar de la cuenta
(nombre, correo, rol, estado del servicio y cerrar sesión).
- El mapa de asientos no lleva barra inferior a propósito: es un paso a medio camino con su propio resumen y
botón de confirmar; se sale con la flecha, el logo o las migas (y avisa si hay una selección sin guardar).

## Pendiente (ideas, no bloquea)
- Descargar el comprobante como archivo `.xml`/PDF.
- Recordar la sesión en varias pestañas y mostrar el tiempo restante del token.
6 changes: 6 additions & 0 deletions frontend/lib/config/app_router.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import '../controllers/sesion_controller.dart';
import '../pages/estado_api_page.dart';
import '../pages/admin_page.dart';
import '../pages/comprobante_page.dart';
import '../pages/crear_vuelo_page.dart';
import '../pages/login_page.dart';
import '../pages/no_encontrada_page.dart';
Expand All @@ -27,6 +28,7 @@ class AppRoutes {
static const estado = '/estado';
static const reservas = '/reservas';
static String reserva(int id) => '/reservas/$id';
static String comprobante(int id) => '/reservas/$id/comprobante';
static String asientosDelVuelo(int id) => '/vuelos/$id/asientos';
static String asientosDeLaReserva(int id) => '/reservas/$id/asientos';
static const admin = '/admin';
Expand Down Expand Up @@ -85,6 +87,10 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: 'asientos',
builder: (_, state) => SeleccionAsientosPage(reservaId: int.parse(state.pathParameters['id']!)),
),
GoRoute(
path: 'comprobante',
builder: (_, state) => ComprobantePage(reservaId: int.parse(state.pathParameters['id']!)),
),
],
),
GoRoute(path: AppRoutes.admin, builder: (_, _) => const AdminPage()),
Expand Down
17 changes: 17 additions & 0 deletions frontend/lib/config/app_theme.dart
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,23 @@ class AppTheme {
textStyle: const TextStyle(fontWeight: FontWeight.w700),
),
),
navigationBarTheme: NavigationBarThemeData(
backgroundColor: AppColors.superficie,
indicatorColor: AppColors.amarillo.withValues(alpha: 0.55),
height: 68,
labelTextStyle: WidgetStateProperty.resolveWith(
(estados) => TextStyle(
fontSize: 12.5,
fontWeight: estados.contains(WidgetState.selected) ? FontWeight.w800 : FontWeight.w600,
color: estados.contains(WidgetState.selected) ? AppColors.textoPrincipal : AppColors.textoSecundario,
),
),
iconTheme: WidgetStateProperty.resolveWith(
(estados) => IconThemeData(
color: estados.contains(WidgetState.selected) ? AppColors.azulNoche : AppColors.textoSecundario,
),
),
),
snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.azulNoche,
Expand Down
24 changes: 24 additions & 0 deletions frontend/lib/controllers/comprobante_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../config/providers.dart';
import '../models/factura.dart';
import 'con_sesion.dart';

/// Controlador (C) del comprobante: pide el XML de la factura al servidor y lo convierte en un [Factura].
/// Un estado por reserva; se descarta al salir de la pantalla.
class ComprobanteController extends AsyncNotifier<Factura> {
ComprobanteController(this.reservaId);

final int reservaId;

@override
Future<Factura> build() async {
final xml = await conSesion(ref, () => ref.read(reservaServiceProvider).comprobanteXml(reservaId));
return Factura.desdeXml(xml);
}
}

final comprobanteControllerProvider = AsyncNotifierProvider.autoDispose.family<ComprobanteController, Factura, int>(
ComprobanteController.new,
retry: (_, _) => null,
);
116 changes: 116 additions & 0 deletions frontend/lib/models/factura.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
import 'package:xml/xml.dart';

import '../services/api_exception.dart';
import 'vuelo.dart';

/// Comprobante (factura) de una reserva, leído del XML que genera el servidor.
///
/// Estructura del XML (la de la práctica de clase):
/// `factura/{cabecera/{numero,fecha}, cliente/{nombre,correo}, Detalle/producto/{Nombre,Asientos?,Cantidad,
/// PrecioUnitario,Subtotal}, Totales/{Subtotal,PorcentajeIVA,IVA,Total}, Estado}`.
/// Los montos son los guardados en la reserva; la app solo los muestra.
class Factura {
const Factura({
required this.numero,
required this.fecha,
required this.clienteNombre,
required this.clienteCorreo,
required this.descripcion,
required this.asientos,
required this.cantidad,
required this.precioUnitarioCentavos,
required this.subtotalCentavos,
required this.ivaPorcentaje,
required this.ivaCentavos,
required this.totalCentavos,
required this.estado,
required this.xml,
});

/// Lee el XML. Cualquier cosa que no sea una factura válida lanza [InvalidResponseFailure].
factory Factura.desdeXml(String texto) {
final XmlDocument documento;
try {
documento = XmlDocument.parse(texto);
} on XmlException {
throw const InvalidResponseFailure();
}
final raiz = documento.rootElement;
if (raiz.name.local != 'factura') throw const InvalidResponseFailure();

String? dato(String ruta) {
XmlElement? nodo = raiz;
for (final parte in ruta.split('/')) {
nodo = nodo?.getElement(parte);
}
final valor = nodo?.innerText.trim();
return valor == null || valor.isEmpty ? null : valor;
}

final numero = dato('cabecera/numero');
final fecha = RegExp(r'^(\d{4})-(\d{2})-(\d{2})$').firstMatch(dato('cabecera/fecha') ?? '');
final nombre = dato('cliente/nombre');
final correo = dato('cliente/correo');
final descripcion = dato('Detalle/producto/Nombre');
final cantidad = int.tryParse(dato('Detalle/producto/Cantidad') ?? '');
final unitario = Formatos.centavosDe(dato('Detalle/producto/PrecioUnitario'));
final subtotalLinea = Formatos.centavosDe(dato('Detalle/producto/Subtotal'));
final subtotal = Formatos.centavosDe(dato('Totales/Subtotal'));
final porcentaje = dato('Totales/PorcentajeIVA');
final iva = Formatos.centavosDe(dato('Totales/IVA'));
final total = Formatos.centavosDe(dato('Totales/Total'));
final estado = dato('Estado');

if (numero == null ||
fecha == null ||
nombre == null ||
correo == null ||
descripcion == null ||
cantidad == null ||
unitario == null ||
subtotalLinea == null ||
subtotal == null ||
porcentaje == null ||
!RegExp(r'^\d{1,3}(\.\d{1,2})?$').hasMatch(porcentaje) ||
iva == null ||
total == null ||
estado == null) {
throw const InvalidResponseFailure();
}
return Factura(
numero: numero,
fecha: DateTime(int.parse(fecha[1]!), int.parse(fecha[2]!), int.parse(fecha[3]!)),
clienteNombre: nombre,
clienteCorreo: correo,
descripcion: descripcion,
asientos: (dato('Detalle/producto/Asientos') ?? '').split(',').map((a) => a.trim()).where((a) => a.isNotEmpty).toList(),
cantidad: cantidad,
precioUnitarioCentavos: unitario,
subtotalCentavos: subtotal,
ivaPorcentaje: porcentaje,
ivaCentavos: iva,
totalCentavos: total,
estado: estado,
xml: texto,
);
}

final String numero;
final DateTime fecha;
final String clienteNombre;
final String clienteCorreo;
final String descripcion;
final List<String> asientos;
final int cantidad;
final int precioUnitarioCentavos;
final int subtotalCentavos;
final String ivaPorcentaje;
final int ivaCentavos;
final int totalCentavos;
final String estado;

/// El XML tal como lo entregó el servidor (para el panel "Factura XML").
final String xml;

bool get anulada => estado.toLowerCase() == 'cancelada';
}
5 changes: 4 additions & 1 deletion frontend/lib/models/reserva.dart
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,10 @@ class Reserva {
bool puedeEditarse({DateTime? ahora}) => !cancelada && !vuelo.yaPartio(ahora: ahora);

/// Número de comprobante: "KEC-00000058".
String get numero => 'KEC-${id.toString().padLeft(8, '0')}';
String get numero => numeroDe(id);

/// "KEC-00000058" a partir del id (el mismo formato que usa el servidor en la factura).
static String numeroDe(int id) => 'KEC-${id.toString().padLeft(8, '0')}';
}

/// Una página del historial. Contrato: {"total", "pagina", "por_pagina", "total_paginas", "reservas": [...]}.
Expand Down
4 changes: 3 additions & 1 deletion frontend/lib/pages/admin_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import '../widgets/aviso.dart';
import '../widgets/barra_kuntur.dart';
import '../widgets/boton_degradado.dart';
import '../widgets/campo_selector.dart';
import '../widgets/navegacion_principal.dart';

/// Panel del administrador (V): acceso a crear vuelos y gestión del IVA.
/// Solo lo ve quien tiene rol admin (lo garantiza el router y, además, el servidor con 403).
Expand All @@ -24,7 +25,8 @@ class AdminPage extends ConsumerWidget {
final iva = ref.watch(ivaControllerProvider);

return Scaffold(
appBar: const BarraKuntur(conAtras: true),
appBar: const BarraKuntur(),
bottomNavigationBar: const NavegacionPrincipal(actual: Seccion.administracion),
body: SafeArea(
child: Center(
child: ConstrainedBox(
Expand Down
Loading