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
116 changes: 102 additions & 14 deletions api/src/main/java/restaurante/api/infra/impresora/ImpresoraService.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ public class ImpresoraService {
private static final String COCINA_2 = "COCINA_2";

// Papel 80mm a fuente normal ≈ 48 columnas (58mm serían 32)
private static final String SEPARADOR = "=".repeat(48);
private static final int ANCHO_TICKET = 48;
private static final String SEPARADOR = "=".repeat(ANCHO_TICKET);

// El ticket de cierre le cede una franja del margen derecho al nombre de la
// mesera, que baja letra por renglón (pedido de David 2026-08-11). Son 3
// espacios de aire y la columna 48 para la letra.
private static final int ANCHO_COLUMNA_NOMBRE = 4;
private static final int ANCHO_CONTENIDO = ANCHO_TICKET - ANCHO_COLUMNA_NOMBRE;

@Value("${impresora.cocina1.nombre}")
private String nombreCocina1;
Expand Down Expand Up @@ -348,10 +355,69 @@ static String filaAncho(String izquierda, String derecha, int ancho) {
return String.format("%-" + anchoIzq + "s %s", izquierda, derecha);
}

/** Fila de 48 columnas: producto + precio unitario + importe (32+8+8). */
private static String filaTicket3(String izquierda, String unitario, String importe) {
if (izquierda.length() > 32) izquierda = izquierda.substring(0, 32);
return String.format("%-32s%8s%8s", izquierda, unitario, importe);
/**
* Ancho de cada columna de dinero. Eran 8 y estaban desperdiciadas: el precio
* más caro de la carta es $200 y el importe más alto jamás cobrado en un
* renglón fue $800 — cuatro caracteres. Bajarlas a 6 es lo que deja que el
* nombre del platillo conserve sus 32 columnas después de cederle 4 al
* nombre de la mesera; si no, 30 platillos más saldrían cortados.
*/
private static final int ANCHO_IMPORTE = 6;

/**
* Fila de producto + precio unitario + importe, con el ancho total como
* parámetro (48 normalmente, 44 cuando el nombre de la mesera baja por la
* orilla derecha).
*
* Si algún día un importe no cupiera en sus 6 columnas, el espacio se le
* quita al nombre del platillo —que de todos modos ya viene recortado— y
* nunca a la fila: una fila más ancha que el papel se parte en dos renglones
* y descuadra la columna del nombre.
*
* Visible para pruebas.
*/
static String filaTicket3(String izquierda, String unitario, String importe, int ancho) {
int anchoUnitario = Math.max(ANCHO_IMPORTE, unitario.length() + 1);
int anchoImporte = Math.max(ANCHO_IMPORTE, importe.length() + 1);
int anchoIzq = ancho - anchoUnitario - anchoImporte;
if (anchoIzq <= 0) return (unitario + " " + importe).trim();
if (izquierda.length() > anchoIzq) izquierda = izquierda.substring(0, anchoIzq);
return String.format("%-" + anchoIzq + "s%" + anchoUnitario + "s%" + anchoImporte + "s",
izquierda, unitario, importe);
}

/**
* El nombre que baja por el margen derecho del ticket, una letra por renglón.
*
* Solo el nombre de pila y sin acentos, por dos razones prácticas: la
* impresora térmica no dibuja de forma fiable lo que se sale de ASCII, y un
* "Ma. de los Angeles" completo se comería el ticket a razón de un renglón
* por letra. Devuelve vacío si no hay nombre, y entonces el ticket sale
* exactamente como antes salvo por las 4 columnas de aire a la derecha.
*
* Visible para pruebas.
*/
static List<String> nombreVertical(String nombre) {
if (nombre == null || nombre.isBlank()) return List.of();
String pila = nombre.trim().split("\\s+")[0];
String limpio = java.text.Normalizer.normalize(pila, java.text.Normalizer.Form.NFD)
.replaceAll("[^A-Za-z0-9]", "")
.toUpperCase();
return limpio.chars().mapToObj(c -> String.valueOf((char) c)).toList();
}

/**
* Pega una línea de contenido con la letra que le toca del margen derecho.
* El contenido se recorta a 44 columnas para que la letra caiga siempre en la
* 48: un platillo de nombre largo empuja su propio texto, nunca la columna.
*
* Visible para pruebas.
*/
static String conMargen(String contenido, String letra) {
if (contenido.length() > ANCHO_CONTENIDO) contenido = contenido.substring(0, ANCHO_CONTENIDO);
// Sin letra no vale la pena mandar 4 espacios por el cable a la impresora.
if (letra.isEmpty()) return contenido.stripTrailing();
return String.format("%-" + ANCHO_CONTENIDO + "s%" + ANCHO_COLUMNA_NOMBRE + "s", contenido, letra);
}

/** Dinero sin decimales cuando es cantidad exacta ($50); con centavos solo si los hay. */
Expand Down Expand Up @@ -385,15 +451,21 @@ void escribirTicketCliente(EscPos escpos, DatosRespuestaCuenta ticket) throws Ex
.setFontSize(Style.FontSize._2, Style.FontSize._2)
.setBold(true);

String rayita = "-".repeat(48);
// Las rayitas también se quedan en 44 para que la columna del nombre sea
// una franja limpia por la orilla y no la corte ningún separador.
String rayita = "-".repeat(ANCHO_CONTENIDO);

// Cabecera: RESTFOOD + mesera y servicio en una sola línea
escpos.writeLF(centroNegrita, "RESTFOOD");
String servicio = "COMIDA".equalsIgnoreCase(ticket.servicio()) ? "Comida"
: "DESAYUNO".equalsIgnoreCase(ticket.servicio()) ? "Desayuno"
: ticket.servicio() != null ? ticket.servicio() : "";
String quien = ticket.nombreMesero() != null ? ticket.nombreMesero() : "";
String cabecera = (quien + (servicio.isEmpty() ? "" : " - " + servicio)).trim();
// Sin mesera la cabecera salía como "- Comida", con el guion colgando.
// Se destapó al dibujar el ticket con la vista previa el 2026-08-11.
String quien = ticket.nombreMesero() != null ? ticket.nombreMesero().trim() : "";
String cabecera = quien.isEmpty() ? servicio
: servicio.isEmpty() ? quien
: quien + " - " + servicio;
if (!cabecera.isEmpty()) {
escpos.writeLF(centro, cabecera);
}
Expand All @@ -406,12 +478,28 @@ void escribirTicketCliente(EscPos escpos, DatosRespuestaCuenta ticket) throws Ex
}
escpos.writeLF(rayita);

// Platillos: cantidad y nombre + precio unitario + importe en la misma línea
for (DatosDetalleRespuesta p : ticket.platillos()) {
escpos.writeLF(normal, filaTicket3(
p.cantidad() + "x " + p.nombre_producto(),
dinero(p.precio_unitario()),
dinero(p.subtotal())));
// Platillos: cantidad y nombre + precio unitario + importe en la misma
// línea, con el nombre de la mesera bajando por la orilla derecha.
//
// Si el nombre tiene más letras que platillos la columna sigue sola unos
// renglones más; si tiene menos, se acaba y ya. El bloque de arriba (la
// cabecera) y el de abajo (el TOTAL) no la llevan: el TOTAL va a doble
// tamaño desde el 2026-07-28 —24 columnas, no 48— porque las meseras
// confundían el 0 con el 8, y meterle una letra al margen obligaría a
// encogerlo otra vez. Ese renglón se queda como está.
List<String> letras = nombreVertical(ticket.nombreMesero());
int renglones = Math.max(ticket.platillos().size(), letras.size());
for (int i = 0; i < renglones; i++) {
String contenido = "";
if (i < ticket.platillos().size()) {
DatosDetalleRespuesta p = ticket.platillos().get(i);
contenido = filaTicket3(
p.cantidad() + "x " + p.nombre_producto(),
dinero(p.precio_unitario()),
dinero(p.subtotal()),
ANCHO_CONTENIDO);
}
escpos.writeLF(normal, conMargen(contenido, i < letras.size() ? letras.get(i) : ""));
}

escpos.writeLF(rayita);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,110 @@ void importeQueLlenaLaFila() {
assertEquals("$123456789012345678901234", fila);
}

// ─── El nombre de la mesera bajando por el margen derecho (2026-08-11) ────

@Test
@DisplayName("el nombre se parte en letras, una por renglon")
void nombreLetraPorRenglon() {
assertEquals(List.of("V", "A", "L", "E", "R", "I", "A"),
ImpresoraService.nombreVertical("Valeria"));
}

@Test
@DisplayName("solo el nombre de pila y sin acentos: la termica no dibuja lo que no es ASCII")
void nombreSoloPilaYSinAcentos() {
assertEquals(List.of("J", "O", "S", "E"), ImpresoraService.nombreVertical("José Pérez Loza"));
assertEquals(List.of(), ImpresoraService.nombreVertical(null));
assertEquals(List.of(), ImpresoraService.nombreVertical(" "));
}

@Test
@DisplayName("la letra cae siempre en la columna 48, aunque el platillo tenga nombre largo")
void laLetraNoSeMueve() {
String corto = ImpresoraService.conMargen("2x Agua", "V");
String largo = ImpresoraService.conMargen("1x " + "Bistec".repeat(20), "V");

assertEquals(48, corto.length());
assertEquals(48, largo.length());
assertTrue(corto.endsWith("V"), "la letra debe quedar pegada a la orilla derecha");
assertTrue(largo.endsWith("V"), "un nombre largo debe recortarse a si mismo, no empujar la columna");
}

@Test
@DisplayName("sin letra no se mandan espacios de relleno por el cable")
void sinLetraNoHayRelleno() {
assertEquals("2x Agua", ImpresoraService.conMargen("2x Agua", ""));
}

@Test
@DisplayName("MARELI baja por la orilla del ticket aunque solo haya un platillo")
void elNombreSigueCuandoSeAcabanLosPlatillos() throws Exception {
// El ticket de prueba trae UN platillo y la mesera se llama MARELI: la
// columna tiene que continuar 5 renglones despues del ultimo platillo.
String texto = new String(imprimir(ticketCon(new BigDecimal("120"))), StandardCharsets.UTF_8);

assertEquals(List.of("M", "A", "R", "E", "L", "I"), margenDerechoDe(texto),
"el margen derecho debe leerse MARELI de arriba abajo");
}

@Test
@DisplayName("el nombre del platillo no pierde columnas por la del margen")
void elPlatilloConservaSusColumnas() throws Exception {
// Los platillos de aqui tienen nombres larguisimos (hasta 62 caracteres),
// asi que cada columna que pierden se nota. Ceder 4 al nombre de la mesera
// sin tocar nada mas habria cortado 30 platillos mas de los que ya se
// cortaban; el espacio salio de las columnas de dinero, que sobraban.
String texto = new String(imprimir(ticketCon(new BigDecimal("120"))), StandardCharsets.UTF_8);

assertTrue(texto.contains("1x Bistec a la Tampiquena"),
"el nombre del platillo debe caber entero, como cabia antes del margen");
}

@Test
@DisplayName("un importe descomunal se come el nombre, nunca desborda la fila")
void importeGrandeNoDesbordaLaFila() {
String fila = ImpresoraService.conMargen(
ImpresoraService.filaTicket3("1x Camarones al mojo de ajo con arroz",
"$1234.56", "$98765.43", 44), "A");

assertEquals(48, fila.length(), "la fila no puede pasarse del papel: se partiria en dos renglones");
assertTrue(fila.endsWith("A"), "la letra del margen se queda en su columna");
assertTrue(fila.contains("$98765.43"), "el importe es el dato que no se puede recortar");
}

@Test
@DisplayName("el TOTAL a doble tamano se queda intacto: no lo encoge la columna del nombre")
void elTotalNoSeEncoge() throws Exception {
String texto = new String(imprimir(ticketCon(new BigDecimal("808"))), StandardCharsets.UTF_8);

assertTrue(texto.contains(ImpresoraService.filaAncho("TOTAL", "$808", 24)),
"el total debe seguir armado a 24 columnas de fuente doble, sin letra al margen");
}

// ─── Ayudas ──────────────────────────────────────────────────────────────

/**
* Las letras que quedaron pegadas a la orilla derecha, de arriba abajo.
*
* Cada renglon viene precedido por los bytes ESC/POS que fijan su estilo, asi
* que no se puede medir la linea entera: se toman las ultimas 48 columnas,
* que son el contenido, y se acepta como letra del margen la que este sola
* al final detras de espacios. Las rayitas terminan en '-' y el total mide
* menos de 48 columnas, asi que ninguno de los dos se cuela.
*/
private static List<String> margenDerechoDe(String ticket) {
var letras = new java.util.ArrayList<String>();
for (String linea : ticket.split("\n")) {
if (linea.length() < 48) continue;
String contenido = linea.substring(linea.length() - 48);
char ultima = contenido.charAt(47);
if (Character.isLetterOrDigit(ultima) && contenido.charAt(46) == ' ') {
letras.add(String.valueOf(ultima));
}
}
return letras;
}

private static int indiceDe(byte[] bytes, String texto) {
byte[] aguja = texto.getBytes(StandardCharsets.UTF_8);
for (int i = 0; i <= bytes.length - aguja.length; i++) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package restaurante.api.infra.impresora;

import com.github.anastaciocintra.escpos.EscPos;
import org.junit.jupiter.api.Test;
import restaurante.api.orden.DatosRespuestaCuenta;
import restaurante.api.ordenDetalle.DatosDetalleRespuesta;

import java.io.ByteArrayOutputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.util.List;

/**
* Ver el ticket de cierre sin gastar papel ni ocupar la impresora.
*
* ./mvnw test -Dtest=VistaPreviaTicket
*
* NO corre en el CI: surefire solo recoge las clases que terminan en Test o
* Tests, igual que pasa con CalibradorPlantillaMenu. Es una herramienta, no una
* prueba — no afirma nada, solo dibuja.
*
* Existe por la lección del 2026-08-01: comprobar coordenadas en la cabeza no es
* comprobar. Al mover una columna del ticket, mirar el dibujo antes de mandar
* nada a producción; los recuadros marcan dónde cae la columna 48.
*/
class VistaPreviaTicket {

@Test
void dibujar() throws Exception {
imprimirEnPantalla("Valeria", List.of(
platillo(2, "Pozole", "95", "190"),
platillo(1, "Agua de jamaica", "25", "25")), "215");

// Nombre más largo que la lista de platillos: la columna sigue sola.
imprimirEnPantalla("Guadalupe", List.of(
platillo(1, "Bistec a la Tampiquena", "120", "120")), "120");

// Un nombre de platillo que no cabe: debe recortarse él, no empujar la letra.
imprimirEnPantalla("Ana", List.of(
platillo(1, "Camarones al mojo de ajo con arroz y ensalada", "260", "260")), "260");

// Sin mesera: el ticket sale como siempre, sin columna.
imprimirEnPantalla(null, List.of(platillo(1, "Cafe", "30", "30")), "30");
}

private static DatosDetalleRespuesta platillo(int cantidad, String nombre, String unitario, String subtotal) {
return new DatosDetalleRespuesta(1L, cantidad, new BigDecimal(unitario), new BigDecimal(subtotal),
null, 1L, 1L, nombre);
}

private static void imprimirEnPantalla(String mesera, List<DatosDetalleRespuesta> platillos, String total)
throws Exception {
var ticket = new DatosRespuestaCuenta(1L, 42, "5", "LOZA",
LocalDateTime.now(), LocalDateTime.now(),
platillos, new BigDecimal(total), "CERRADA", mesera, "COMIDA");

var salida = new ByteArrayOutputStream();
var escpos = new EscPos(salida);
new ImpresoraService().escribirTicketCliente(escpos, ticket);
escpos.close();

// Fuera los bytes de control ESC/POS; queda el papel tal como se lee.
String texto = new String(salida.toByteArray(), StandardCharsets.UTF_8)
.replaceAll("[\\x00-\\x09\\x0B-\\x1F]", "");

System.out.println();
System.out.println("mesera: " + (mesera == null ? "(ninguna)" : mesera));
System.out.println("+" + "-".repeat(48) + "+");
for (String linea : texto.split("\n")) {
// Las últimas 48 columnas son el contenido: lo de antes son los bytes
// de estilo del renglón, que en pantalla se ven como basura.
String contenido = linea.length() >= 48 ? linea.substring(linea.length() - 48) : linea;
System.out.println("|" + String.format("%-48s", contenido) + "|");
}
System.out.println("+" + "-".repeat(48) + "+");
}
}
Loading