Flujo de compra (scan → checkout)¶
El recorrido central de la terminal: el cliente escanea productos, arma el carrito, valida y pasa a pagar. Esta página sigue ese flujo pantalla por pantalla, con los BLoCs, eventos y DTOs reales. Para los flujos de pago en sí (QR, Point, tarjeta), ver Pagos.
El código es la fuente de verdad
Reproducimos clases, eventos y rutas tal como están en lib/ a la fecha. Algunos números de línea cambiarán; los archivos no.
El mapa del flujo¶
graph LR
HOME["/home"] --> SCAN["/scan<br/>(escaneo + carrito)"]
SCAN -->|PAGAR| VAL{validateTicket}
VAL -->|estado OPEN| MP["/mediosPago"]
MP --> PAY["/pagoQR · /pagoPointSmart · /pagoTarjeta"]
PAY --> RES["/resultPayment"]
RES -->|Nueva compra| SCAN

El /home con la terminal ya conectada al backend: el botón COMENZAR abre un ticket, o el cliente escanea directamente un producto. El timer de inactividad y los badges de identidad acompañan toda la sesión.
La pantalla de escaneo (ScanPage)¶
lib/views/scan/scan_page.dart es el centro operativo. En initState:
- Lee la config de bolsas del
ConfigurationBloc. - Crea un
FocusNode(scannerFocusNode) para capturar el input del lector de código de barras como si fuera teclado. - Dispara
StartInactivityTimerdelInactividadBloc. - Consume cualquier escaneo pendiente y restaura el foco al lector.
Cómo se captura un escaneo¶
El lector de barras se comporta como un teclado: "tipea" el código y manda un Enter. La terminal lo intercepta con Focus(onKeyEvent: _handleKeyEvent):
_handleKeyEventacumula las teclas en un buffer hasta el Enter y produce unScanResult { code, type, errorMessage? }.- Si hay error de lectura, muestra un diálogo y no agrega nada.
- Si es válido, llama a
_processBarcode(code, type)y resetea el timer de inactividad (hubo actividad).
ScanType distingue barcode, qr y manual (este último, ingreso manual habilitado por supervisor).
Del escaneo al carrito¶
_processBarcode no agrega el ítem localmente: se lo pide al backend (el backend es la fuente de verdad del ticket). Despacha por el BackendClientBloc:
context.read<BackendClientBloc>().add(
SendMessage(
source: DestinationPage.scanPage,
destination: BackendDestination.getArticulo,
topic: BackendTopic.ticketArticulo,
message: RequestTicketArticuloDto(
ean: code,
cantidad: quantity.toDouble(),
orden: 0,
tipoScan: type.type,
ticketDto: ticketBloc.state.rawTicket, // el ticket actual
),
),
);
El backend resuelve el EAN (ver Decodificación de EAN), recalcula promos y total, y devuelve el TicketDto actualizado.
El TicketBloc y la respuesta¶
Un BlocListener<BackendClientBloc> procesa la respuesta según el estado:
| Estado backend | Acción en la UI |
|---|---|
sending |
SetScanning(true) — muestra spinner. |
received |
Parsea el TicketDto → AddTicket(...) — el carrito se actualiza y hace auto-scroll al ítem nuevo. |
error |
SetScanning(false) + diálogo de error. |
Eventos del TicketBloc (lib/viewmodels/ticket/):
| Evento | Qué hace |
|---|---|
AddTicket(ticket, rawTicket, selectedItem, moveScroll) |
Agrega/actualiza el ítem en el carrito. |
UpdateTicket(ticket, rawTicket) |
Actualiza el ticket (validación, pago). |
ClearTicket() |
Limpia el carrito (nueva compra). |
SetScanning(bool) |
Muestra/oculta el spinner. |
SetPendingScan(scanResult) / ClearPendingScan() |
Guarda/limpia un escaneo para consumir tras navegar. |
Por qué se guarda rawTicket
El TicketState mantiene dos versiones: el ticket (para mostrar) y el rawTicket (el crudo del servidor, que se manda de vuelta en el siguiente escaneo). Así el backend siempre recibe el estado exacto que él emitió, sin que el cliente lo "reinterprete".
El carrito en pantalla¶
Componentes (en lib/views/scan/):
| Widget | Rol |
|---|---|
ProductView → ListProductWidget → ItemProducto |
La lista scrollable de ítems (cantidad, precio unitario, total; el ítem nuevo se anima). |
SummaryPurchase |
Totales, impuestos y descuentos. |
MenuItem (tabs) |
Carrito / Bolsas / Fidelización. |
ModernPayButton |
El botón PAGAR, con estado de carga. |

El carrito recién abierto: a la izquierda los ítems (acá vacío, "No hay items agregados"), a la derecha el RESUMEN COMPRA con el subtotal, abajo los tabs Carrito/Bolsas y el botón PAGAR (deshabilitado hasta que haya ítems). Arriba, "CANCELAR COMPRA" y el branding del comercio.

El mismo carrito tras escanear un artículo (EAN 7790314000133): la línea muestra descripción, EAN, 1,00 UN × $2.520,66 y su total; el RESUMEN COMPRA refleja el subtotal, el badge del carrito marca 1 y el botón PAGAR se habilita (verde). Cada escaneo es un getArticulo que el backend resuelve y devuelve como TicketDto recalculado — exactamente el orden de cómputo.
Validación y salida al pago¶
Cuando el cliente toca PAGAR (_onTapPay):
- Bolsas: si está configurado preguntar por bolsas y el ticket no tiene, muestra el diálogo "¿Agregar bolsas?".
- Validación contra el backend: despacha
validateTicketcon elrawTicketcompleto. - Resultado:
- Si el ticket vuelve en estado
OPEN(validado, listo para pagar) → navega a/mediosPago. - Si hay error o timeout (la validación tarda > ~30 s) → diálogo con el contexto (
isScanPageValidateTicketTimeoutStatedetecta el timeout porcommandReference == 'TIMEOUT').

Si el comercio lo configura y el ticket no tiene bolsas, al tocar PAGAR aparece el prompt "¿Desea agregarlas ahora?" (No / Sí) antes de validar.

Tras validar, /mediosPago: las opciones QR y Tarjetas (Point Smart), el detalle de compra y el TOTAL A PAGAR. Desde acá cada medio abre su flujo —ver Pagos—.
Los DTOs del ticket¶
TicketDto¶
lib/dto/backend/ticket_dto.dart — el agregado que viaja entre terminal y backend. Campos principales:
- Identidad/fiscal:
codComercio,cuitComercio,codSucursal,nroPos,codTerminal,estado,tipoComprobante,nroPVFiscal,nroComprobanteFiscal,nroCAE,vtoCAE. - Ítems:
cantidadItems,cantidadItemsAnulados,items: List<ItemDto>. - Envases:
cantEnvases,cantEnvasesConsumidos,cantEnvasesCobrar,valeEnvases. - Totales (como núcleo impositivo):
totalAPagar,totalPromociones,total. - Relaciones:
cliente,mediosDePago,ordenDePago,promociones,vouchers,movimientos.
ItemDto lleva ean, descripcion, cantVenta, precioVenta, montoVenta, estadoItem (VENTA/ANULADO), orden, impuestos y la info de envase si aplica.
Request DTOs¶
| DTO | Se manda en | Contiene |
|---|---|---|
RequestTicketArticuloDto |
getArticulo / removeItem |
ean, cantidad, orden, tipoScan, ticketDto. |
RequestTicketPaymentDto |
payTicket |
ticketDto, commandReference (CONSULTAR/EJECUTAR), ordenes. |
RequestTicketStatusDto |
changeStatusTicket |
estado (CANCELED_USER, CANCELED_AUTOMATICALLY…), ticketDto, motivo?. |
OrdenPagoDto¶
Una orden de pago dentro del ticket: autorizador (MERCADOPAGO, etc.), idGlobalUUID, monto, customData (referencia del pago), orden, y estado (OPEN, PAGADA, ERROR, RECHAZADA). El navigationAction del TicketState decide la ruta posterior según el estado del ticket y de la última orden.
El despacho: BackendClientBloc¶
Todas las mutaciones pasan por el BackendClientBloc (_onSendMessage), que rutea según BackendDestination al método correspondiente del TicketService:
final result = switch (event.destination) {
BackendDestination.openTicket => ticketService.openTicket(...),
BackendDestination.getArticulo => ticketService.agregarArticulo(...),
BackendDestination.removeItem => ticketService.removeItem(...),
BackendDestination.closeTicket => ticketService.closeTicket(...),
BackendDestination.changeStatusTicket=> ticketService.changeStatus(...),
BackendDestination.validateTicket => ticketService.validateTicket(...),
BackendDestination.payTicket => _ejecutarPago(...),
};
Cada llamada lleva los headers de identidad (X-Cod-Terminal, X-Terminal-Uuid) que inyecta el HttpCliente. El detalle de la comunicación, el polling de salud y la resiliencia está en Comunicación POS ↔ Backend.