diff --git a/api/src/main/java/restaurante/api/infra/impresora/ImpresoraService.java b/api/src/main/java/restaurante/api/infra/impresora/ImpresoraService.java index 52cbfc8..0fc500f 100644 --- a/api/src/main/java/restaurante/api/infra/impresora/ImpresoraService.java +++ b/api/src/main/java/restaurante/api/infra/impresora/ImpresoraService.java @@ -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; @@ -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 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. */ @@ -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); } @@ -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 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); diff --git a/api/src/test/java/restaurante/api/infra/impresora/ImpresoraServiceTest.java b/api/src/test/java/restaurante/api/infra/impresora/ImpresoraServiceTest.java index c3ac489..62c5bd3 100644 --- a/api/src/test/java/restaurante/api/infra/impresora/ImpresoraServiceTest.java +++ b/api/src/test/java/restaurante/api/infra/impresora/ImpresoraServiceTest.java @@ -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 margenDerechoDe(String ticket) { + var letras = new java.util.ArrayList(); + 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++) { diff --git a/api/src/test/java/restaurante/api/infra/impresora/VistaPreviaTicket.java b/api/src/test/java/restaurante/api/infra/impresora/VistaPreviaTicket.java new file mode 100644 index 0000000..0e17e0e --- /dev/null +++ b/api/src/test/java/restaurante/api/infra/impresora/VistaPreviaTicket.java @@ -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 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) + "+"); + } +}