Spring Boot Framework

Sistema de ventas con Spring MVC, MySQL y Bootstrap

En este post voy a presentar el código fuente y el JAR de un sistema de ventas o punto de venta open source programado en Java, utilizando el framework web Spring Boot con el paradigma MVC.

Para el diseño he utilizado Bootstrap, y para la persistencia de datos, MySQL.

A través de este post te explicaré cómo fue programado este sistema de ventas con Spring Boot, además de mostrarte en dónde está el código fuente y cómo ejecutar el sistema; ya que el mismo es open source y gratuito.

Sistema de ventas con Spring Boot

Lo que tiene el sistema es:

  • Control de productos. Registrar producto, eliminar, editar
  • Descuento de existencias por cada venta
  • Interfaz para vender, en donde se escanea el código de barras y se agrega al carrito de compras
  • Registro de ventas, en donde se muestra la fecha y hora de la venta, el total y los productos que se compraron
  • Diseño responsivo. Se adapta a teléfonos, tabletas y computadoras portátiles o de escritorio

Puedes descargar el sistema en la página de releases, o analizar, estudiar y modificar el código fuente en GitHub (recuerda configurar GitHub desktop antes)

Presentación en YouTube

Si quieres saber cómo ejecutarlo o quieres ver un vídeo con las características de este software, míralo a continuación:

Características técnicas

Este sistema de ventas con Spring Boot utiliza el motor de base de datos MySQL, y Bootstrap 4 como framework CSS para el diseño.

También he cargado los iconos de FontAwesome y un poco de JavaScript para el menú responsivo de Bootstrap 4.

El motor de plantillas usado es Thymeleaf.

Dependencias y configuración del sistema de ventas con Spring Boot

Las dependencias o el archivo build.gradle es el siguiente:

buildscript {
    repositories {
        mavenCentral()
    }
    dependencies {
        classpath("org.springframework.boot:spring-boot-gradle-plugin:2.1.6.RELEASE")
    }
}

apply plugin: 'java'
apply plugin: 'eclipse'
apply plugin: 'idea'
apply plugin: 'org.springframework.boot'
apply plugin: 'io.spring.dependency-management'

bootJar {
    baseName = 'sistema-ventas-by-parzibyte'
    version = '0.2.0'
}

repositories {
    mavenCentral()
}

sourceCompatibility = 1.8
targetCompatibility = 1.8

configurations {
    developmentOnly
    runtimeClasspath {
        extendsFrom developmentOnly
    }
}

dependencies {
    // https://mvnrepository.com/artifact/nz.net.ultraq.thymeleaf/thymeleaf-layout-dialect
    compile group: 'nz.net.ultraq.thymeleaf', name: 'thymeleaf-layout-dialect', version: '2.4.1'
    compile("org.springframework.boot:spring-boot-starter-web")
    testCompile('org.springframework.boot:spring-boot-starter-test')
    testCompile('com.jayway.jsonpath:json-path')
    compile("org.springframework.boot:spring-boot-starter-thymeleaf")
    developmentOnly("org.springframework.boot:spring-boot-devtools")
    compile 'org.springframework.boot:spring-boot-starter-data-jpa'
    compile 'mysql:mysql-connector-java'
}

El archivo application.properties es el siguiente:

spring.thymeleaf.cache=false
spring.jpa.hibernate.ddl-auto=update
spring.datasource.url=jdbc:mysql://localhost:3306/ventas_springboot?useUnicode=true&serverTimezone=UTC
spring.datasource.username=parzibyte
spring.datasource.password=hunter2
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQL5Dialect

Solo estoy configurando la manera en la que hibernate hace las migraciones, además de indicar que se usará el dialecto MySQL, la contraseña, usuario y cadena de conexión a la base de datos.

En este caso la base de datos se llama ventas_springboot.

Los templates o layouts

Voy a comenzar explicando los layouts, que fue un tema un poco complicado. Lo que requería hacer era incluir o reutilizar plantillas como el encabezado o el pie, modificando únicamente el contenido.

Al final lo logré utilizando alguna dependencia de por ahí. Así que definí la plantilla maestra:

<!doctype html>
<!--
    Programado por Luis Cabrera Benito
  ____          _____               _ _           _
 |  _ \        |  __ \             (_) |         | |
 | |_) |_   _  | |__) |_ _ _ __ _____| |__  _   _| |_ ___
 |  _ <| | | | |  ___/ _` | '__|_  / | '_ \| | | | __/ _ \
 | |_) | |_| | | |  | (_| | |   / /| | |_) | |_| | ||  __/
 |____/ \__, | |_|   \__,_|_|  /___|_|_.__/ \__, |\__\___|
         __/ |                               __/ |
        |___/                               |___/


    Blog:       https://parzibyte.me/blog
    Ayuda:      https://parzibyte.me/blog/contrataciones-ayuda/
    Contacto:   https://parzibyte.me/blog/contacto/
-->
<html lang="es" xmlns:th="http://www.w3.org/1999/xhtml">
<head th:replace="layouts/encabezado :: encabezado"></head>
<body>
<nav th:replace="layouts/navbar :: navbar"></nav>
<main layout:fragment="contenido" role="main" class="container"></main>
<footer th:replace="layouts/pie :: pie"></footer>
</body>
</html>

Tengo 4 partes, el encabezado, el menú de navegación, el contenido principal y el pie de página.

El encabezado queda así:

<head th:fragment="encabezado" xmlns:th="http://www.w3.org/1999/xhtml">
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="Punto de venta con Spring Boot. Control de inventario y ventas ">
    <meta name="author" content="Parzibyte">
    <title>Sistema de ventas Spring Boot</title>
    <!-- Cargar el CSS de Boostrap-->
    <link th:href="@{/css/bootstrap.min.css}" rel="stylesheet">
    <link th:href="@{/css/fa.min.css}" rel="stylesheet">
    <link th:href="@{/css/style.css}" rel="stylesheet">
</head>

En el mismo cargo el CSS de los estilos y los iconos, así como el framework de Bootstrap.

Después está el menú de navegación:

<nav th:fragment="navbar" xmlns:th="http://www.w3.org/1999/xhtml"
     class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
    <a class="navbar-brand" target="_blank" href="//parzibyte.me/blog">POS by Parzibyte</a>
    <button class="navbar-toggler" type="button" id="botonMenu">
        <span class="navbar-toggler-icon"></span>
    </button>
    <div class="collapse navbar-collapse" id="menu">
        <ul class="navbar-nav mr-auto">
            <li class="nav-item">
                <a class="nav-link" th:href="@{/vender/}">Vender</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" th:href="@{/productos/mostrar}">Productos</a>
            </li>
            <li class="nav-item">
                <a class="nav-link" th:href="@{/ventas/}">Ventas</a>
            </li>
        </ul>
    </div>
</nav>

El navbar solo tiene enlaces hacia cada apartado del sistema. Luego tenemos al pie (ya sé que en orden, ahora iría el contenido, pero el mismo se define en cada sección que veremos más abajo):

<footer th:fragment="pie" class="px-2 py-2 fixed-bottom bg-dark" xmlns:th="http://www.w3.org/1999/xhtml">
    <span class="text-muted">Sistema de ventas Spring Boot
        <i class="fa fa-code"></i>
        con
        <i class="fa fa-heart"></i>
        por
        <a class="text-white" href="//parzibyte.me/blog">Parzibyte</a>
    </span>
    <script type="text/javascript">
        // Tomado de https://github.com/parzibyte/cotizaciones_web/blob/master/js/cotizaciones.js#L2
        document.addEventListener("DOMContentLoaded", () => {
            const menu = document.querySelector("#menu"),
                botonMenu = document.querySelector("#botonMenu");
            if (menu) {
                botonMenu.addEventListener("click", () => menu.classList.toggle("show"));
            }
        });
    </script>
</footer>

Lo único que se hace en el pie es definir los créditos y un script para que el menú sea responsivo. Ese es todo el JavaScript que verás dentro del sistema.

Ahora que he presentado el layout principal veremos cada apartado.

Productos

La gestión de los productos es sencilla. Comenzamos por ver las entidades y repositorios.

Primero definimos la entidad Producto, que tendrá su respectiva tabla en MySQL.

package me.parzibyte.sistemaventasspringboot;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;

@Entity
public class Producto {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;

    @NotNull(message = "Debes especificar el nombre")
    @Size(min = 1, max = 50, message = "El nombre debe medir entre 1 y 50")
    private String nombre;

    @NotNull(message = "Debes especificar el código")
    @Size(min = 1, max = 50, message = "El código debe medir entre 1 y 50")
    private String codigo;

    @NotNull(message = "Debes especificar el precio")
    @Min(value = 0, message = "El precio mínimo es 0")
    private Float precio;

    @NotNull(message = "Debes especificar la existencia")
    @Min(value = 0, message = "La existencia mínima es 0")
    private Float existencia;


    public Producto(String nombre, String codigo, Float precio, Float existencia, Integer id) {
        this.nombre = nombre;
        this.codigo = codigo;
        this.precio = precio;
        this.existencia = existencia;
        this.id = id;
    }

    public Producto(String nombre, String codigo, Float precio, Float existencia) {
        this.nombre = nombre;
        this.codigo = codigo;
        this.precio = precio;
        this.existencia = existencia;
    }

    public Producto(@NotNull(message = "Debes especificar el código") @Size(min = 1, max = 50, message = "El código debe medir entre 1 y 50") String codigo) {
        this.codigo = codigo;
    }

    public Producto() {
    }

    public boolean sinExistencia() {
        return this.existencia <= 0;
    }

    public String getCodigo() {
        return codigo;
    }

    public void setCodigo(String codigo) {
        this.codigo = codigo;
    }

    public Float getPrecio() {
        return precio;
    }

    public void setPrecio(Float precio) {
        this.precio = precio;
    }

    public Float getExistencia() {
        return existencia;
    }

    public void setExistencia(Float existencia) {
        this.existencia = existencia;
    }

    public void restarExistencia(Float existencia) {
        this.existencia -= existencia;
    }

    public String getNombre() {
        return nombre;
    }

    public void setNombre(String nombre) {
        this.nombre = nombre;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }
}

Lo único que defino es un producto (que luego será un producto que se agrega al carrito de ventas o un producto vendido) con algunas validaciones, constructores, getters y setters.

El repositorio de productos es el siguiente:

package me.parzibyte.sistemaventasspringboot;

import org.springframework.data.repository.CrudRepository;

public interface ProductosRepository extends CrudRepository<Producto, Integer> {

    Producto findFirstByCodigo(String codigo);
}

Estoy extendiendo de CrudRepository, lo único que hago es definir mi interfaz con la entidad y Spring se encarga del resto. He definido también una “consulta” adicional (findFirstByCodigo) que buscará el primer producto por código de barras, esto será útil en otros casos que ya veremos.

Agregar producto

Cuando el usuario quiere agregar un producto se invoca al siguiente método del controlador:

@GetMapping(value = "/agregar")
public String agregarProducto(Model model) {
    model.addAttribute("producto", new Producto());
    return "productos/agregar_producto";
}

Simplemente regresamos la vista con un Producto vacío, si quieres saber más sobre esto mira mi tutorial sobre el procesamiento de formularios en Spring Boot.

Para agregar un producto se define el siguiente formulario:

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="master">
<body>
<main layout:fragment="contenido">
    <div class="col-12">
        <h2>Agregar producto</h2>
        <div th:classappend="'alert-' + (${clase != null} ? ${clase} : info)" th:if="${mensaje != null}"
             th:text="${mensaje}"
             class="alert">
        </div>
        <form th:object="${producto}" th:action="@{/productos/agregar}" method="post">
            <div class="form-group">
                <label for="nombre">Nombre del producto</label>
                <input autocomplete="off" autofocus th:field="*{nombre}" id="nombre"
                       placeholder="Escribe el nombre del producto" type="text"
                       class="form-control" th:classappend="${#fields.hasErrors('nombre')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{nombre}"></div>
            </div>
            <div class="form-group">
                <label for="codigo">Código de barras</label>
                <input autocomplete="off" th:field="*{codigo}" id="codigo" placeholder="Escribe el código del producto"
                       type="text"
                       class="form-control" th:classappend="${#fields.hasErrors('codigo')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{codigo}"></div>

            </div>
            <div class="form-group">
                <label for="existencia">Existencia actual</label>
                <input autocomplete="off" th:field="*{existencia}" id="existencia"
                       placeholder="Cantidad actual del producto" type="number"
                       class="form-control" th:classappend="${#fields.hasErrors('existencia')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{existencia}"></div>

            </div>
            <div class="form-group">
                <label for="existencia">Precio</label>
                <input autocomplete="off" th:field="*{precio}" id="precio" placeholder="Precio del producto"
                       type="number"
                       class="form-control" th:classappend="${#fields.hasErrors('precio')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{precio}"></div>

            </div>
            <button class="btn btn-success" type="submit">Guardar</button>
            &nbsp;<a class="btn btn-warning" th:href="@{/productos/mostrar}">Ver todos</a>
        </form>
    </div>
</main>
</body>
</html>

Tiene todo lo necesario para mostrar los errores de validación en caso de que existan. Su action es el método del controlador:

@PostMapping(value = "/agregar")
public String guardarProducto(@ModelAttribute @Valid Producto producto, BindingResult bindingResult, RedirectAttributes redirectAttrs) {
    if (bindingResult.hasErrors()) {
        return "productos/agregar_producto";
    }
    if (productosRepository.findFirstByCodigo(producto.getCodigo()) != null) {
        redirectAttrs
                .addFlashAttribute("mensaje", "Ya existe un producto con ese código")
                .addFlashAttribute("clase", "warning");
        return "redirect:/productos/agregar";
    }
    productosRepository.save(producto);
    redirectAttrs
            .addFlashAttribute("mensaje", "Agregado correctamente")
            .addFlashAttribute("clase", "success");
    return "redirect:/productos/agregar";
}

En la función recibimos el objeto del formulario a través de @ModelAtribute.

Validamos el formulario y en caso de que haya errores de validación regresamos la vista y detenemos la ejecución del código.

Como segundo paso verificamos que no exista un producto con ese código de barras, en caso de que exista ponemos algunos mensajes flash y regresamos.

En caso de que todo vaya bien, guardamos el producto invocando a productosRepository.save(producto) y volvemos al formulario, con un mensaje de éxito.

Mostrar productos

Además de los detalles de los productos tengo 2 botones, o eso parece, ya que en realidad uno es un enlace que lleva a la edición del producto, y otro es el botón submit de un formulario que eliminará el producto como veremos más adelante.

Para mostrar productos se visita la URL /productos/mostrar que invoca al siguiente método:

@GetMapping(value = "/mostrar")
public String mostrarProductos(Model model) {
    model.addAttribute("productos", productosRepository.findAll());
    return "productos/ver_productos";
}

Devuelve la vista ver_productos.html y le pasa una lista de todos los productos, invocando al método findAll del repositorio de productos.

La vista en cuestión es la siguiente, en donde se dibuja una tabla responsiva de Bootstrap usando th:each propio de Thymeleaf para renderizar arreglos o listas.

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="master">
<body>
<main layout:fragment="contenido">
    <div class="col-12">
        <h2>Productos</h2>
        <div th:classappend="'alert-' + (${clase != null} ? ${clase} : info)" th:if="${mensaje != null}"
             th:text="${mensaje}"
             class="alert">
        </div>
        <a class="btn btn-success mb-2" th:href="@{/productos/agregar}">Agregar</a>
        <div class="table-responsive">
            <table class="table table-bordered">
                <thead>
                <tr>
                    <th>Nombre</th>
                    <th>Código</th>
                    <th>Precio</th>
                    <th>Existencia</th>
                    <th>Editar</th>
                    <th>Eliminar</th>
                </tr>
                </thead>
                <tbody>
                <tr th:each="producto : ${productos}">
                    <td th:text="${producto.nombre}"></td>
                    <td th:text="${producto.codigo}"></td>
                    <td th:text="${producto.precio}"></td>
                    <td th:text="${producto.existencia}"></td>
                    <td>
                        <a class="btn btn-warning" th:href="@{/productos/editar/} + ${producto.id}">Editar <i
                                class="fa fa-edit"></i></a>
                    </td>
                    <td>
                        <form th:action="@{/productos/eliminar}" method="post">
                            <input type="hidden" name="id" th:value="${producto.id}"/>
                            <button type="submit" class="btn btn-danger">Eliminar <i class="fa fa-trash"></i>
                            </button>
                        </form>
                    </td>
                </tr>
                </tbody>
            </table>
        </div>
    </div>
</main>
</body>
</html>

Editar producto

Formulario para editar producto de POS usando Spring y Bootstrap 4

Cuando alguien hace click en Editar se va a una URL como la siguiente: /productos/editar/2 en donde el último valor es el id del producto. Lo que se hace es obtener ese producto por id y regresar la view que va a mostrar el formulario, así que el controlador queda así:

@GetMapping(value = "/editar/{id}")
public String mostrarFormularioEditar(@PathVariable int id, Model model) {
    model.addAttribute("producto", productosRepository.findById(id).orElse(null));
    return "productos/editar_producto";
}

A través del método findById busco el producto usando el id obtenido de @PathVariable. Utilizo orElse para asegurarme de que me regresa un objeto de tipo Producto (y no del tipo Optional) o null.

La vista es la siguiente:

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="master">
<body>
<main layout:fragment="contenido">
    <div class="col-12">
        <h2>Editar producto </h2>
        <form th:object="${producto}" th:action="@{/productos/editar/} + ${producto.id}" method="post">
            <input type="hidden" th:field="*{id}">
            <div class="form-group">
                <label for="nombre">Nombre del producto</label>
                <input autocomplete="off" autofocus th:field="*{nombre}" id="nombre"
                       placeholder="Escribe el nombre del producto" type="text"
                       class="form-control" th:classappend="${#fields.hasErrors('nombre')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{nombre}"></div>
            </div>
            <div class="form-group">
                <label for="codigo">Código de barras</label>
                <input autocomplete="off" th:field="*{codigo}" id="codigo" placeholder="Escribe el código del producto"
                       type="text"
                       class="form-control" th:classappend="${#fields.hasErrors('codigo')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{codigo}"></div>
            </div>
            <div class="form-group">
                <label for="existencia">Existencia actual</label>
                <input autocomplete="off" th:field="*{existencia}" id="existencia"
                       placeholder="Cantidad actual del producto" type="number"
                       class="form-control" th:classappend="${#fields.hasErrors('existencia')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{existencia}"></div>
            </div>
            <div class="form-group">
                <label for="existencia">Precio</label>
                <input autocomplete="off" th:field="*{precio}" id="precio" placeholder="Precio del producto"
                       type="number"
                       class="form-control" th:classappend="${#fields.hasErrors('precio')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{precio}"></div>
            </div>
            <button class="btn btn-success" type="submit">Guardar</button>
            &nbsp;<a class="btn btn-warning" th:href="@{/productos/mostrar}">Ver todos</a>
        </form>
    </div>
</main>
</body>
</html>

De igual manera es un formulario que mostrará los errores de validación en caso de que existan. Tengo además un input de tipo hidden con el id del producto, aunque bien se podría pasar el id en la ruta.

Cuando se guarda el producto, se llama a este método del controlador:

// Se colocó el parámetro ID para eso de los errores, ya sé el id se puede recuperar
// a través del modelo, pero lo que yo quiero es que se vea la misma URL para regresar la vista con
// los errores en lugar de hacer un redirect, ya que si hago un redirect, no se muestran los errores del formulario
// y por eso regreso mejor la vista ;)
@PostMapping(value = "/editar/{id}")
public String actualizarProducto(@ModelAttribute @Valid Producto producto, BindingResult bindingResult, RedirectAttributes redirectAttrs) {
    if (bindingResult.hasErrors()) {
        if (producto.getId() != null) {
            return "productos/editar_producto";
        }
        return "redirect:/productos/mostrar";
    }
    Producto posibleProductoExistente = productosRepository.findFirstByCodigo(producto.getCodigo());

    if (posibleProductoExistente != null && !posibleProductoExistente.getId().equals(producto.getId())) {
        redirectAttrs
                .addFlashAttribute("mensaje", "Ya existe un producto con ese código")
                .addFlashAttribute("clase", "warning");
        return "redirect:/productos/agregar";
    }
    productosRepository.save(producto);
    redirectAttrs
            .addFlashAttribute("mensaje", "Editado correctamente")
            .addFlashAttribute("clase", "success");
    return "redirect:/productos/mostrar";
}

Ya sé que el parámetro no se debería recuperar por la URL pero lo hago para mostrar la misma vista en caso de que haya errores de validación, y para que la URL no cambie (ya que al procesar la información se usa el método POST pero para mostrar el formulario se usa GET)

En fin, eso no debería tener mayor importancia. Se hace la validación y se comprueba que no se esté usando un código de barras ya existente, aunque se comprueba de igual forma que, si existe un producto con ese código de barras, no sea este mismo que estamos editando.

Eliminar producto

Para eliminar un producto simplemente se envía un formulario con el id dentro del mismo. El método del controlador es el siguiente:

@PostMapping(value = "/eliminar")
public String eliminarProducto(@ModelAttribute Producto producto, RedirectAttributes redirectAttrs) {
    redirectAttrs
            .addFlashAttribute("mensaje", "Eliminado correctamente")
            .addFlashAttribute("clase", "warning");
    productosRepository.deleteById(producto.getId());
    return "redirect:/productos/mostrar";
}

Invocamos al método deleteById pasándole el id del producto obtenido al llamar getId. Después ponemos un mensaje de que la eliminación fue correcta y redirigimos a la lista de productos.

ProductosController dentro del sistema de ventas con Spring Boot

Entonces el controlador de productos queda así:

package me.parzibyte.sistemaventasspringboot;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

import javax.validation.Valid;

@Controller
@RequestMapping(path = "/productos")
public class ProductosController {
    @Autowired
    private ProductosRepository productosRepository;

    @GetMapping(value = "/agregar")
    public String agregarProducto(Model model) {
        model.addAttribute("producto", new Producto());
        return "productos/agregar_producto";
    }

    @GetMapping(value = "/mostrar")
    public String mostrarProductos(Model model) {
        model.addAttribute("productos", productosRepository.findAll());
        return "productos/ver_productos";
    }

    @PostMapping(value = "/eliminar")
    public String eliminarProducto(@ModelAttribute Producto producto, RedirectAttributes redirectAttrs) {
        redirectAttrs
                .addFlashAttribute("mensaje", "Eliminado correctamente")
                .addFlashAttribute("clase", "warning");
        productosRepository.deleteById(producto.getId());
        return "redirect:/productos/mostrar";
    }

    // Se colocó el parámetro ID para eso de los errores, ya sé el id se puede recuperar
    // a través del modelo, pero lo que yo quiero es que se vea la misma URL para regresar la vista con
    // los errores en lugar de hacer un redirect, ya que si hago un redirect, no se muestran los errores del formulario
    // y por eso regreso mejor la vista ;)
    @PostMapping(value = "/editar/{id}")
    public String actualizarProducto(@ModelAttribute @Valid Producto producto, BindingResult bindingResult, RedirectAttributes redirectAttrs) {
        if (bindingResult.hasErrors()) {
            if (producto.getId() != null) {
                return "productos/editar_producto";
            }
            return "redirect:/productos/mostrar";
        }
        Producto posibleProductoExistente = productosRepository.findFirstByCodigo(producto.getCodigo());

        if (posibleProductoExistente != null && !posibleProductoExistente.getId().equals(producto.getId())) {
            redirectAttrs
                    .addFlashAttribute("mensaje", "Ya existe un producto con ese código")
                    .addFlashAttribute("clase", "warning");
            return "redirect:/productos/agregar";
        }
        productosRepository.save(producto);
        redirectAttrs
                .addFlashAttribute("mensaje", "Editado correctamente")
                .addFlashAttribute("clase", "success");
        return "redirect:/productos/mostrar";
    }

    @GetMapping(value = "/editar/{id}")
    public String mostrarFormularioEditar(@PathVariable int id, Model model) {
        model.addAttribute("producto", productosRepository.findById(id).orElse(null));
        return "productos/editar_producto";
    }

    @PostMapping(value = "/agregar")
    public String guardarProducto(@ModelAttribute @Valid Producto producto, BindingResult bindingResult, RedirectAttributes redirectAttrs) {
        if (bindingResult.hasErrors()) {
            return "productos/agregar_producto";
        }
        if (productosRepository.findFirstByCodigo(producto.getCodigo()) != null) {
            redirectAttrs
                    .addFlashAttribute("mensaje", "Ya existe un producto con ese código")
                    .addFlashAttribute("clase", "warning");
            return "redirect:/productos/agregar";
        }
        productosRepository.save(producto);
        redirectAttrs
                .addFlashAttribute("mensaje", "Agregado correctamente")
                .addFlashAttribute("clase", "success");
        return "redirect:/productos/agregar";
    }
}

El controlador tiene todo para manejar este CRUD de productos con Spring

Interfaz para vender en el Sistema de ventas con Spring Boot

Ahora pasemos a la interfaz que se encarga de agregar productos a un carrito para la venta. Los productos los he almacenado en un ArrayList de tipo ProductoParaVender; ya veremos eso más adelante.

Interfaz para vender

Primero veamos la nueva clase de ProductoParaVender, que extiende de Producto pero agrega otras propiedades como la cantidad (distinta a la existencia, pues se refiere a la cantidad que se vende):

package me.parzibyte.sistemaventasspringboot;

public class ProductoParaVender extends Producto {
    private Float cantidad;

    public ProductoParaVender(String nombre, String codigo, Float precio, Float existencia, Integer id, Float cantidad) {
        super(nombre, codigo, precio, existencia, id);
        this.cantidad = cantidad;
    }

    public ProductoParaVender(String nombre, String codigo, Float precio, Float existencia, Float cantidad) {
        super(nombre, codigo, precio, existencia);
        this.cantidad = cantidad;
    }

    public void aumentarCantidad() {
        this.cantidad++;
    }

    public Float getCantidad() {
        return cantidad;
    }

    public Float getTotal() {
        return this.getPrecio() * this.cantidad;
    }
}

No es una entidad, pues no será guardado en una base de datos, es simplemente un ayudante para la lista de compras.

Ahora veamos dos métodos que nos ayudan a guardar y a obtener el carrito. Son los siguientes y están dentro del controlador:

private ArrayList<ProductoParaVender> obtenerCarrito(HttpServletRequest request) {
    ArrayList<ProductoParaVender> carrito = (ArrayList<ProductoParaVender>) request.getSession().getAttribute("carrito");
    if (carrito == null) {
        carrito = new ArrayList<>();
    }
    return carrito;
}

private void guardarCarrito(ArrayList<ProductoParaVender> carrito, HttpServletRequest request) {
    request.getSession().setAttribute("carrito", carrito);
}

Se trata de un ArrayList como lo dije hace un momento, ambos se guardan en la sesión, por eso los métodos necesitan el parámetro de HttpServletRequest.

Ahora tenemos el método que muestra la vista, pasándole la variable del total del carrito:

@GetMapping(value = "/")
public String interfazVender(Model model, HttpServletRequest request) {
    model.addAttribute("producto", new Producto());
    float total = 0;
    ArrayList<ProductoParaVender> carrito = this.obtenerCarrito(request);
    for (ProductoParaVender p: carrito) total += p.getTotal();
    model.addAttribute("total", total);
    return "vender/vender";
}

Renderiza la vista llamada vender.html que es la siguiente:

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="master">
<body>
<main layout:fragment="contenido">
    <div class="col-12">
        <h2>Vender</h2>
        <div th:classappend="'alert-' + (${clase != null} ? ${clase} : info)" th:if="${mensaje != null}"
             th:text="${mensaje}"
             class="alert">
        </div>
        <form th:object="${producto}" th:action="@{/vender/agregar}" method="post">
            <div class="form-group">
                <label for="codigo">Código de barras</label>
                <input autofocus autocomplete="off" th:field="*{codigo}" id="codigo"
                       placeholder="Escanea el código o escríbelo y presiona Enter"
                       type="text"
                       class="form-control" th:classappend="${#fields.hasErrors('codigo')} ? 'is-invalid' : ''">
                <div class="invalid-feedback" th:errors="*{codigo}"></div>

            </div>
        </form>
        <form class="mb-2" th:action="@{/vender/terminar}" method="post">
            <button type="submit" class="btn btn-success">Terminar&nbsp;<i class="fa fa-check"></i>
            </button>
            <a th:href="@{/vender/limpiar}" class="btn btn-danger">Cancelar venta&nbsp;<i
                    class="fa fa-trash"></i>
            </a>
        </form>
        <h1 th:text="${'Total: ' + total}"></h1>
        <div class="table-responsive">
            <table class="table table-bordered">
                <thead>
                <tr>
                    <th>Nombre</th>
                    <th>Código</th>
                    <th>Precio</th>
                    <th>Cantidad</th>
                    <th>Total</th>
                    <th>Quitar de lista</th>
                </tr>
                </thead>
                <tbody>
                <tr th:each="producto, iterador : ${session.carrito}">
                    <td th:text="${producto.nombre}"></td>
                    <td th:text="${producto.codigo}"></td>
                    <td th:text="${producto.precio}"></td>
                    <td th:text="${producto.cantidad}"></td>
                    <td th:text="${producto.total}"></td>
                    <td>
                        <form th:action="@{/vender/quitar/} + ${iterador.index}" method="post">
                            <button type="submit" class="btn btn-danger"><i class="fa fa-trash"></i>
                            </button>
                        </form>
                    </td>
                </tr>
                </tbody>
            </table>
        </div>
    </div>
</main>
</body>
</html>

Para mostrar la tabla con los productos lee session.carrito, pues las vistas de Thymeleaf tienen acceso a la sesión. Fíjate en que también estoy accediendo al iterador para obtener el índice y ponerlo en el formulario que quita un producto de la lista.

Agregar producto al carrito

Tengo un formulario (que es el único input) que agrega el producto a partir del código de barras; vamos a reutilizar el método que programamos anteriormente para obtener un producto por código de barras.

El controlador queda así:

@PostMapping(value = "/agregar")
public String agregarAlCarrito(@ModelAttribute Producto producto, HttpServletRequest request, RedirectAttributes redirectAttrs) {
    ArrayList<ProductoParaVender> carrito = this.obtenerCarrito(request);
    Producto productoBuscadoPorCodigo = productosRepository.findFirstByCodigo(producto.getCodigo());
    if (productoBuscadoPorCodigo == null) {
        redirectAttrs
                .addFlashAttribute("mensaje", "El producto con el código " + producto.getCodigo() + " no existe")
                .addFlashAttribute("clase", "warning");
        return "redirect:/vender/";
    }
    if (productoBuscadoPorCodigo.sinExistencia()) {
        redirectAttrs
                .addFlashAttribute("mensaje", "El producto está agotado")
                .addFlashAttribute("clase", "warning");
        return "redirect:/vender/";
    }
    boolean encontrado = false;
    for (ProductoParaVender productoParaVenderActual : carrito) {
        if (productoParaVenderActual.getCodigo().equals(productoBuscadoPorCodigo.getCodigo())) {
            productoParaVenderActual.aumentarCantidad();
            encontrado = true;
            break;
        }
    }
    if (!encontrado) {
        carrito.add(new ProductoParaVender(productoBuscadoPorCodigo.getNombre(), productoBuscadoPorCodigo.getCodigo(), productoBuscadoPorCodigo.getPrecio(), productoBuscadoPorCodigo.getExistencia(), productoBuscadoPorCodigo.getId(), 1f));
    }
    this.guardarCarrito(carrito, request);
    return "redirect:/vender/";
}

Se ve un poco complejo, pero no lo es. Comenzamos obteniendo el producto por código de barras, después se valida que el producto exista por código de barras y que tenga existencia.

Más tarde busca dentro del ArrayList y si ya existe, simplemente le aumenta la cantidad. En caso de que no exista, agrega uno nuevo invocando al método add.

Quitar producto de la lista de venta

Es un simple formulario que tiene el índice que ocupa el producto dentro de la lista. El método del controlador que lo maneja es el siguiente:

@PostMapping(value = "/quitar/{indice}")
public String quitarDelCarrito(@PathVariable int indice, HttpServletRequest request) {
    ArrayList<ProductoParaVender> carrito = this.obtenerCarrito(request);
    if (carrito != null && carrito.size() > 0 && carrito.get(indice) != null) {
        carrito.remove(indice);
        this.guardarCarrito(carrito, request);
    }
    return "redirect:/vender/";
}

Se obtiene el carrito y si existe y tiene elementos, se invoca al método remove del ArrayList, que elimina el elemento usando su índice.

Finalmente llamamos al método guardarCarrito y hacemos una redirección a vender.

Cancelar venta

Para cancelar la venta actual solo hay que limpiar el carrito de compras:

private void limpiarCarrito(HttpServletRequest request) {
    this.guardarCarrito(new ArrayList<>(), request);
}

@GetMapping(value = "/limpiar")
public String cancelarVenta(HttpServletRequest request, RedirectAttributes redirectAttrs) {
    this.limpiarCarrito(request);
    redirectAttrs
            .addFlashAttribute("mensaje", "Venta cancelada")
            .addFlashAttribute("clase", "info");
    return "redirect:/vender/";
}

Para limpar o eliminar el carrito, guardamos un ArrayList vacío.

Terminar venta

Este método se encarga de terminar la venta, es decir, crear una nueva venta, restar las existencias de los productos vendidos y agregar los nuevos que se venden junto con la venta.

Queda así:

@PostMapping(value = "/terminar")
public String terminarVenta(HttpServletRequest request, RedirectAttributes redirectAttrs) {
    ArrayList<ProductoParaVender> carrito = this.obtenerCarrito(request);
    // Si no hay carrito o está vacío, regresamos inmediatamente
    if (carrito == null || carrito.size() <= 0) {
        return "redirect:/vender/";
    }
    Venta v = ventasRepository.save(new Venta());
    // Recorrer el carrito
    for (ProductoParaVender productoParaVender : carrito) {
        // Obtener el producto fresco desde la base de datos
        Producto p = productosRepository.findById(productoParaVender.getId()).orElse(null);
        if (p == null) continue; // Si es nulo o no existe, ignoramos el siguiente código con continue
        // Le restamos existencia
        p.restarExistencia(productoParaVender.getCantidad());
        // Lo guardamos con la existencia ya restada
        productosRepository.save(p);
        // Creamos un nuevo producto que será el que se guarda junto con la venta
        ProductoVendido productoVendido = new ProductoVendido(productoParaVender.getCantidad(), productoParaVender.getPrecio(), productoParaVender.getNombre(), productoParaVender.getCodigo(), v);
        // Y lo guardamos
        productosVendidosRepository.save(productoVendido);
    }

    // Al final limpiamos el carrito
    this.limpiarCarrito(request);
    // e indicamos una venta exitosa
    redirectAttrs
            .addFlashAttribute("mensaje", "Venta realizada correctamente")
            .addFlashAttribute("clase", "success");
    return "redirect:/vender/";
}

Los comentarios son explicativos. Todavía no hemos visto la clase ProductoVendido ni Venta, así como los repositorios, pero los veremos a continuación.

Así es como queda esta interfaz.

Productos vendidos en este POS

Los productos vendidos son una entidad distinta. No pueden tener una relación con los productos, ya que los mismos pueden cambiar de precio en el futuro y si esto cambia, se afectaría al historial de ventas.

En fin, la entidad (esta sí es una entidad pues va a una base de datos) queda así:

package me.parzibyte.sistemaventasspringboot;

import javax.persistence.*;

@Entity
public class ProductoVendido {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;
    private Float cantidad, precio;
    private String nombre, codigo;
    @ManyToOne
    @JoinColumn
    private Venta venta;

    public ProductoVendido(Float cantidad, Float precio, String nombre, String codigo, Venta venta) {
        this.cantidad = cantidad;
        this.precio = precio;
        this.nombre = nombre;
        this.codigo = codigo;
        this.venta = venta;
    }

    public ProductoVendido() {
    }

    public Float getTotal() {
        return this.cantidad * this.precio;
    }

    public Venta getVenta() {
        return venta;
    }

    public void setVenta(Venta venta) {
        this.venta = venta;
    }

    public Float getPrecio() {
        return precio;
    }

    public void setPrecio(Float precio) {
        this.precio = precio;
    }

    public Float getCantidad() {
        return cantidad;
    }

    public void setCantidad(Float cantidad) {
        this.cantidad = cantidad;
    }

    public String getNombre() {
        return nombre;
    }

    public void setNombre(String nombre) {
        this.nombre = nombre;
    }

    public String getCodigo() {
        return codigo;
    }

    public void setCodigo(String codigo) {
        this.codigo = codigo;
    }
}

En la línea 12, 13 y 14 establecemos la relación que tiene con la entidad Venta, pues una venta tendrá productos vendidos.

Lleva casi las mismas propiedades que el producto normal. A esta entidad hay que agregarle su respectivo repositorio:

package me.parzibyte.sistemaventasspringboot;

import org.springframework.data.repository.CrudRepository;

public interface ProductosVendidosRepository extends CrudRepository<ProductoVendido, Integer> {

}

Eso es todo lo que se hace con esta entidad. Ahora veamos la última entidad.

Ventas dentro de sistema de ventas con spring

Listado de ventas con productos, además de la fecha y hora

Esto es fácil, pues no tenemos métodos manuales ni cosas por el estilo. Veamos la entidad de Venta:

package me.parzibyte.sistemaventasspringboot;

import javax.persistence.*;
import java.util.Set;

@Entity
public class Venta {
    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Integer id;
    private String fechaYHora;

    @OneToMany(mappedBy = "venta", cascade = CascadeType.ALL)
    private Set<ProductoVendido> productos;

    public Venta() {
        this.fechaYHora = Utiles.obtenerFechaYHoraActual();
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public Float getTotal() {
        Float total = 0f;
        for (ProductoVendido productoVendido : this.productos) {
            total += productoVendido.getTotal();
        }
        return total;
    }

    public String getFechaYHora() {
        return fechaYHora;
    }

    public void setFechaYHora(String fechaYHora) {
        this.fechaYHora = fechaYHora;
    }

    public Set<ProductoVendido> getProductos() {
        return productos;
    }

    public void setProductos(Set<ProductoVendido> productos) {
        this.productos = productos;
    }
}

Tiene la fecha y hora, un id y un conjunto de productos que son de la clase ProductoVendido. La fecha y hora se obtiene de la clase Utiles, la cual se obtiene y formatea como vimos en otro post.

También existe el método getTotal que recorre todo el conjunto de productos para obtener el total de venta.

Finalmente podemos fijarnos en la anotación @OneToMany de la línea 13, la cual relaciona la venta con los productos vendidos.

Debemos crear un repositorio de las ventas:

package me.parzibyte.sistemaventasspringboot;

import org.springframework.data.repository.CrudRepository;

public interface VentasRepository extends CrudRepository<Venta, Integer> {
}

No hacemos nada más que definir la interface. Finalmente veamos el controlador:

package me.parzibyte.sistemaventasspringboot;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping(path = "/ventas")
public class VentasController {
    @Autowired
    VentasRepository ventasRepository;

    @GetMapping(value = "/")
    public String mostrarVentas(Model model) {
        model.addAttribute("ventas", ventasRepository.findAll());
        return "ventas/ver_ventas";
    }
}

Tiene un único método que muestra todas las ventas. No tenemos que preocuparnos por hacer relaciones manuales o algo así, pues al invocar a findAll la venta tendrá los productos que se vendieron.

Después de obtener las ventas las enviamos a la vista de ver_ventas.html que se encargan de renderizarlas y de dibujar las tablas:

<!DOCTYPE html>
<html lang="es" xmlns:th="http://www.thymeleaf.org"
      xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
      layout:decorate="master">
<body>
<main layout:fragment="contenido">
    <div class="col-12">
        <h2>Ventas</h2>
        <div th:classappend="'alert-' + (${clase != null} ? ${clase} : info)" th:if="${mensaje != null}"
             th:text="${mensaje}"
             class="alert">
        </div>
        <a class="btn btn-success mb-2" th:href="@{/vender/}">Agregar</a>
        <div class="table-responsive">
            <table class="table table-bordered">
                <thead>
                <tr>
                    <th>Fecha</th>
                    <th>Total</th>
                    <th>Productos</th>
                </tr>
                </thead>
                <tbody>
                <tr th:each="venta : ${ventas}">
                    <td th:text="${venta.fechaYHora}"></td>
                    <td th:text="${venta.total}"></td>
                    <td>
                        <table class="table table-bordered">
                            <thead>
                            <tr>
                                <th>Nombre</th>
                                <th>Código de barras</th>
                                <th>Cantidad vendida</th>
                                <th>Precio</th>
                                <th>Total</th>
                            </tr>
                            </thead>
                            <tbody>
                            <tr th:each="producto : ${venta.productos}">
                                <td th:text="${producto.nombre}"></td>
                                <td th:text="${producto.codigo}"></td>
                                <td th:text="${producto.cantidad}"></td>
                                <td th:text="${producto.precio}"></td>
                                <td th:text="${producto.total}"></td>
                            </tr>
                            </tbody>
                        </table>
                    </td>
                </tr>
                </tbody>
            </table>
        </div>
    </div>
</main>
</body>
</html>

La view es muy simple, se encarga de dibujar una tabla que tiene otra tabla dentro.

Descargar código del sistema de ventas Spring Boot y compilar

Si quieres puedes clonar el repositorio, he usado gradle para todo así que puedes usar incluso el bloc de notas para programar.

Lo que tienes que hacer es instalar gradle, MySQL y Java; es decir, todo lo necesario para programar con Spring Boot y MySQL.

Después ejecuta el programa con:

gradlew bootRun

Y crea el jar usando:

gradlew build

También puedes importar el proyecto usando IntelliJ IDEA.

Después ejecuta el jar con:

java -jar nombre-del-jar.jar

En ambos casos (ya sea que estés ejecutando el sistema para programar, o ejecutes el jar) visita http://localhost:8080/productos/mostrar.

No olvides que dejo el código fuente en GitHub.

Más información sobre Spring Boot y Gradle aquí.

Tu propio application.properties para mi sistema de ventas con Spring Boot

Si mi contraseña, usuario o configuración no son acorde a tus requisitos recuerda que siempre puedes crear un archivo llamado application.properties en el mismo directorio en donde está el jar, así, será tomado en cuenta ese archivo en lugar del mío.

Conclusión

Te invito a ver otros sistemas de ventas que he programado:

Sublime POS 3 – Este está listo para ser usado

Punto de venta con PHP

Punto de venta con CodeIgniter

Estoy aquí para ayudarte 🤝💻


Estoy aquí para ayudarte en todo lo que necesites. Si requieres alguna modificación en lo presentado en este post, deseas asistencia con tu tarea, proyecto o precisas desarrollar un software a medida, no dudes en contactarme. Estoy comprometido a brindarte el apoyo necesario para que logres tus objetivos. Mi correo es parzibyte(arroba)gmail.com, estoy como@parzibyte en Telegram o en mi página de contacto

No te pierdas ninguno de mis posts 🚀🔔

Suscríbete a mi canal de Telegram para recibir una notificación cuando escriba un nuevo tutorial de programación.
parzibyte

Programador freelancer listo para trabajar contigo. Aplicaciones web, móviles y de escritorio. PHP, Java, Go, Python, JavaScript, Kotlin y más :) https://parzibyte.me/blog/software-creado-por-parzibyte/

Ver comentarios

  • Saludos como puedo modificar para que el precio de compra y precio de venta del producto me coja con decimales... De antemano gracias.

  • Hola! me gusto el articulo y descargué el programa junto con lo demás, hasta vi el video de presentación para ver cómo podia compilarlo y hacer que corriera pero no me deja, y las explicaciones que se muestran no son claras. podrias por favor, colaborarme explicando paso por paso literalmente cómo compilarlo y ejecutarlo de manera clara. es que siguiendo los pasos me causa error y no compila ni puede ver la página web del programa. PSDA: soy nueva en la materia y no tengo experiencia, por eso mi problema al compilarlo. espero puedas ayudar!
    espero tu respuesta lo más pronto posible! por favor

    • Hola. En el repositorio del software se encuentra la documentación de cómo compilar ya sea desde la terminal o con un IDE; es igual a todos los proyectos de Spring Boot. Igualmente si tiene dudas específicas puede contactarme en https://parzibyte.me
      Saludos :)

  • No entiendo nada, estoy viejo para estas cosas me quede en las aplicaciones de escritorio...
    Donde se supone que ponte el proyecto para hacer uso de el ... En Tomcat ?????

    • Hola. Lo tiene que colocar en un servidor con Apache (solo apache, no Apache Tomcat) específicamente en la carpeta pública que en Linux es /var/www/html y en Windows, si usa XAMPP, es C:\xampp\htdocs

  • Hola que tal, al querer correr el programa me arroja el siguiente error:
    "Error: no se ha encontrado o cargado la clase principal me.parzibyte.sistemaventasspringboot.Application" el proyecto lo descargue de
    "https://github.com/parzibyte/sistema-ventas-spring-boot" tendras idea porque puede dar este error?

  • Estoy haciendo algo similar pero estoy trabado en poder modificar una factura existente, ya sea las cantidades o agregado de nuevos ítems. Tienes alguna sugerencia o ejemplo hecho?
    Gracias y excelente explicación?

    • No tengo ejemplo, pero se me ocurre que una factura tendría una relación con una tabla de productos. El producto tendría cantidad. Podría crear un formulario dinámico por cada producto, y así tener una factura modificable.
      Saludos y gracias por sus comentarios :)

  • me gusta mucho lo publicado, tendras algo que permita modificar un comorobante bmya creado donde puedan modificarse itens, cabridades y preciis? gracias

  • Hola otra vez amigo, pudieras poner un pom.xml para los que usamos Maven o listar al menos las dependencias? estuve revisando en github y el pom.xml no tiene las dependencias, saludos

  • Buenos días amigo, muy buen trabajo, me interesa mucho la forma en que trabajas con Thymileaf, saludos

Entradas recientes

Servidor HTTP en Android con Flutter

El día de hoy te mostraré cómo crear un servidor HTTP (servidor web) en Android…

4 días hace

Imprimir automáticamente todos los PDF de una carpeta

En este post te voy a enseñar a designar una carpeta para imprimir todos los…

5 días hace

Guía para imprimir en plugin versión 1 desde Android

En este artículo te voy a enseñar la guía para imprimir en una impresora térmica…

1 semana hace

Añadir tasa de cambio en sistema de información

Hoy te voy a mostrar un ejemplo de programación para agregar un módulo de tasa…

2 semanas hace

Comprobar validez de licencia de plugin ESC POS

Los usuarios del plugin para impresoras térmicas pueden contratar licencias, y en ocasiones me han…

2 semanas hace

Imprimir euro € en impresora térmica

Hoy voy a enseñarte cómo imprimir el € en una impresora térmica. Vamos a ver…

4 semanas hace

Esta web usa cookies.