Saltar a contenido

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

Pantalla de inicio de la terminal

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 StartInactivityTimer del InactividadBloc.
  • 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):

  1. _handleKeyEvent acumula las teclas en un buffer hasta el Enter y produce un ScanResult { code, type, errorMessage? }.
  2. Si hay error de lectura, muestra un diálogo y no agrega nada.
  3. 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 TicketDtoAddTicket(...) — 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
ProductViewListProductWidgetItemProducto 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.

Pantalla de carrito (ticket abierto, vacío)

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.

Carrito con un artículo escaneado

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):

  1. Bolsas: si está configurado preguntar por bolsas y el ticket no tiene, muestra el diálogo "¿Agregar bolsas?".
  2. Validación contra el backend: despacha validateTicket con el rawTicket completo.
  3. Resultado:
  4. Si el ticket vuelve en estado OPEN (validado, listo para pagar) → navega a /mediosPago.
  5. Si hay error o timeout (la validación tarda > ~30 s) → diálogo con el contexto (isScanPageValidateTicketTimeoutState detecta el timeout por commandReference == 'TIMEOUT').

Diálogo de bolsas antes de validar

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.

Pantalla de medios de pago

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.