Sistema de ventas con Laravel, Bootstrap y MySQL – Open source

Hoy voy a presentar un sistema que acabo de hacer con Laravel. Se trata de un sistema de ventas, punto de venta, PDV o como le llames, que sirve para llevar el seguimiento de los productos que se venden, el registro de ventas, etcétera.

Es un sistema totalmente gratuito y además open source; lo que quiere decir que puedes usarlo sin costo, y modificarlo a tus necesidades o personalizarlo. Entre sus opciones encontramos:

  • Utiliza Laravel en su última versión (a la fecha de escribir esto)
  • Bootstrap es usado para los estilos
  • Iconos de FontAwesome
  • Sistema totalmente responsivo, es decir, funciona en móviles, tabletas y computadoras
  • Control de inventario con precio de venta, precio de compra, utilidad, existencia, etcétera
  • Opción para realizar venta, agregando productos
  • Comprobación de existencia de inventario al vender
  • Resta de existencia al vender
  • Impresión de tickets de venta en impresora térmica
  • Reporte de ventas
  • Inicio de sesión y registro de usuarios
  • Gestión de usuarios (novedad)
  • Registro de clientes (novedad)
  • Ticket que incluye el nombre del cliente (novedad)

Ahora veamos cómo está hecho, en dónde obtenerlo, etcétera.

Requisitos

Versión de PHP:

PHP 7.4.30 (cli) (built: Jun 7 2022 16:24:55) ( ZTS Visual C++ 2017 x64 )
Copyright (c) The PHP Group
Zend Engine v3.4.0, Copyright (c) Zend Technologies

Diseño del sistema de ventas

Laravel utiliza Blade para las plantillas, así que he utilizado su sistema de layouts y herencia. Defino la plantilla maestra que se basa en un starter template de Bootstrap 4:

<!doctype html>
<html lang="es">
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="description" content="{{env("APP_NAME")}}">
    <meta name="author" content="Parzibyte">
    <title>@yield("titulo") - {{env("APP_NAME")}}</title>
    <link href="{{url("/css/bootstrap.min.css")}}" rel="stylesheet">
    <link href="{{url("/css/all.min.css")}}" rel="stylesheet">
    <style>
        body {
            padding-top: 70px;
            /*Para la barra inferior fija*/            padding-bottom: 70px;
        }
    </style>
</head>
<body>
<nav class="navbar navbar-expand-md navbar-dark bg-dark fixed-top">
    <a class="navbar-brand" target="_blank" href="//parzibyte.me/blog">{{env("APP_NAME")}}</a>
    <button class="navbar-toggler" type="button" data-toggle="collapse"
            id="botonMenu" aria-label="Mostrar u ocultar menú">
        <span class="navbar-toggler-icon"></span>
    </button>

    <div class="collapse navbar-collapse" id="menu">
        <ul class="navbar-nav mr-auto">
            @guest
                <li class="nav-item">
                    <a class="nav-link" href="{{ route('login') }}">Login</a>
                </li>

                <li class="nav-item">
                    <a class="nav-link" href="{{ route('register') }}">
                        Registro
                    </a>
                </li>
            @else
                <li class="nav-item">
                    <a class="nav-link" href="{{route("home")}}">Inicio&nbsp;<i class="fa fa-home"></i></a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="{{route("productos.index")}}">Productos&nbsp;<i class="fa fa-box"></i></a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="{{route("vender.index")}}">Vender&nbsp;<i class="fa fa-cart-plus"></i></a>
                </li>
                <li class="nav-item">
                    <a class="nav-link" href="{{route("ventas.index")}}">Ventas&nbsp;<i class="fa fa-list"></i></a>
                </li>
            @endguest
        </ul>
        <ul class="navbar-nav ml-auto">
            @auth
                <li class="nav-item">
                    <a href="{{route("logout")}}" class="nav-link">
                        Salir ({{ Auth::user()->name }})
                    </a>
                </li>
            @endauth
            <li class="nav-item">
                <a class="nav-link" href="https://parzibyte.me/blog/contrataciones-ayuda/">Soporte&nbsp;<i
                        class="fa fa-hands-helping"></i></a>
            </li>
            <li class="nav-item">
                <a class="nav-link" href="{{route("acerca_de")}}">Acerca de&nbsp;<i class="fa fa-info"></i></a>
            </li>
        </ul>
    </div>
</nav>
<script type="text/javascript">
    // Tomado de https://parzibyte.me/blog/2019/06/26/menu-responsivo-bootstrap-4-sin-dependencias/
    document.addEventListener("DOMContentLoaded", () => {
        const menu = document.querySelector("#menu"),
            botonMenu = document.querySelector("#botonMenu");
        if (menu) {
            botonMenu.addEventListener("click", () => menu.classList.toggle("show"));
        }
    });
</script>
<main class="container-fluid">
    @yield("contenido")
</main>
<footer class="px-2 py-2 fixed-bottom bg-dark">
    <span class="text-muted">Punto de venta en Laravel
        <i class="fa fa-code text-white"></i>
        con
        <i class="fa fa-heart" style="color: #ff2b56;"></i>
        por
        <a class="text-white" href="//parzibyte.me/blog">Parzibyte</a>
        &nbsp;|&nbsp;
        <a target="_blank" class="text-white" href="//github.com/parzibyte/sistema_ventas_laravel">
            <i class="fab fa-github"></i>
        </a>
    </span>
</footer>
</body>
</html>

De este modo, ahora solo tengo que sobrescribir el contenido. De la plantilla puedes ver que muestro un menú de navegación y que oculto algunos elementos en caso de que el usuario no esté logueado.

Rutas

Tenemos varias rutas configuradas para vender, ver ventas, imprimir tickets, ver productos, etcétera. Todas ellas están protegidas con el middleware auth, por lo que no se permite acceder a ellas si no se ha iniciado sesión.

El archivo de web.php se ve así:

<?php

use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Route;

/*
|--------------------------------------------------------------------------
| Web Routes
|--------------------------------------------------------------------------
|
| Here is where you can register web routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| contains the "web" middleware group. Now create something great!
|
*/
Route::get('/', function () {
    return redirect()->route("home");
});
Route::get("/acerca-de", function () {
    return view("misc.acerca_de");
})->name("acerca_de");


Auth::routes([
    "reset" => false,// no pueden olvidar contraseña
]);

Route::get('/home', 'HomeController@index')->name('home');
// Permitir logout con petición get
Route::get("/logout", function () {
    Auth::logout();
    return redirect()->route("home");
})->name("logout");


Route::middleware("auth")
    ->group(function () {
        Route::resource("productos", "ProductosController");
        Route::get("/ventas/ticket", "VentasController@ticket")->name("ventas.ticket");
        Route::resource("ventas", "VentasController");
        Route::get("/vender", "VenderController@index")->name("vender.index");
        Route::post("/productoDeVenta", "VenderController@agregarProductoVenta")->name("agregarProductoVenta");
        Route::delete("/productoDeVenta", "VenderController@quitarProductoDeVenta")->name("quitarProductoDeVenta");
        Route::post("/cancelarVenta", "VenderController@cancelarVenta")->name("cancelarVenta");
        Route::post("/terminarVenta", "VenderController@terminarVenta")->name("terminarVenta");
    });

En ella se registran los controladores que son resources, además de otras rutas como el login, el registro, agregar producto al carrito de compras, etcétera.

Login al sistema

He usado la autenticación por defecto de Laravel, así que para usar el sistema de ventas hay que iniciar sesión:

Iniciar sesión en sistema de ventas Laravel y Bootstrap

De igual modo se provee una pantalla para registrarse, que se puede deshabilitar al gusto.

Pantalla de bienvenida

Al iniciar sesión se dirige al índice o pantalla de bienvenida. En el mismo se muestran accesos directos a los apartados del sistema:

Bienvenida al sistema de ventas – Índice con opciones para vender, ver productos o ventas

El código del inicio es el siguiente:

@extends('maestra')
@section("titulo", "Inicio")
@section('contenido')
    <div class="col-12 text-center">
        <h1>Bienvenido, {{Auth::user()->name}}</h1>
    </div>
    <div class="card-columns">
        <a href="{{route("productos.index")}}" style="color: black;">
            <div class="card text-center">
                <img src="{{url("/img/order.png")}}" class="card-img-top">
                <div class="card-body">
                    <h1 class="card-title">Productos</h1>
                </div>
            </div>
        </a>

        <a style="color: black;" target="_blank" href="https://parzibyte.me/blog/contrataciones-ayuda/">
            <div class="card text-center">
                <img src="{{url("/img/gamer.png")}}" class="card-img-top">
                <div class="card-body">
                    <h1 class="card-title">Soporte</h1>
                </div>
            </div>
        </a>
        <a style="color: black;" href="{{route("vender.index")}}">
            <div class="card text-center">
                <img src="{{url("/img/sales.png")}}" class="card-img-top">
                <div class="card-body">
                    <h1 class="card-title">Vender</h1>
                </div>
            </div>
        </a>
        <a style="color: black;" href="{{route("acerca_de")}}">
            <div class="card text-center">
                <img src="{{url("/img/about.png")}}" class="card-img-top">
                <div class="card-body">
                    <h1 class="card-title">Acerca de</h1>
                </div>
            </div>
        </a>
        <a style="color: black;" href="{{route("ventas.index")}}">
            <div class="card text-center">
                <img src="{{url("/img/coupon.png")}}" class="card-img-top">
                <div class="card-body">
                    <h1 class="card-title">Ventas</h1>
                </div>
            </div>
        </a>
    </div>
@endsection

Utilizo tarjetas de Bootstrap. Y a partir de eso se puede navegar a los siguientes apartados.

Los productos

Tenemos la gestión de productos con Laravel en donde se registra la existencia, el precio de compra, el precio de venta, el código de barras y la descripción del artículo. La definición del modelo y  sus fillables se ve así:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Producto extends Model
{
    protected $fillable = ["codigo_barras", "descripcion", "precio_compra", "precio_venta", "existencia",
    ];
}

El formulario de registro de producto se ve así:

@extends("maestra")
@section("titulo", "Agregar producto")
@section("contenido")
    <div class="row">
        <div class="col-12">
            <form method="POST" action="{{route("productos.store")}}">
                @csrf
                <div class="form-group">
                    <label class="label">Código de barras</label>
                    <input required autocomplete="off" name="codigo_barras" class="form-control"
                           type="text" placeholder="Código de barras">
                </div>
                <div class="form-group">
                    <label class="label">Descripción</label>
                    <input required autocomplete="off" name="descripcion" class="form-control"
                           type="text" placeholder="Descripción">
                </div>
                <div class="form-group">
                    <label class="label">Precio de compra</label>
                    <input required autocomplete="off" name="precio_compra" class="form-control"
                           type="decimal(9,2)" placeholder="Precio de compra">
                </div>
                <div class="form-group">
                    <label class="label">Precio de venta</label>
                    <input required autocomplete="off" name="precio_venta" class="form-control"
                           type="decimal(9,2)" placeholder="Precio de venta">
                </div>
                <div class="form-group">
                    <label class="label">Existencia</label>
                    <input required autocomplete="off" name="existencia" class="form-control"
                           type="decimal(9,2)" placeholder="Existencia">
                </div>

                @include("notificacion")
                <button class="btn btn-success">Guardar</button>
                <a class="btn btn-primary" href="{{route("productos.index")}}">Volver al listado</a>
            </form>
        </div>
    </div>
@endsection

Es un simple formulario que llevará al siguiente controlador o controller de productos, que a su vez es un resource:

<?php

namespace App\Http\Controllers;

use App\Producto;
use Illuminate\Http\Request;

class ProductosController extends Controller
{
    /**
     * Display a listing of the resource.
     *
     * @return \Illuminate\Http\Response
     */    public function index()
    {
        return view("productos.productos_index", ["productos" => Producto::all()]);
    }

    /**
     * Show the form for creating a new resource.
     *
     * @return \Illuminate\Http\Response
     */    public function create()
    {
        return view("productos.productos_create");
    }

    /**
     * Store a newly created resource in storage.
     *
     * @param \Illuminate\Http\Request $request
     * @return \Illuminate\Http\Response
     */    public function store(Request $request)
    {
        $producto = new Producto($request->input());
        $producto->saveOrFail();
        return redirect()->route("productos.index")->with("mensaje", "Producto guardado");
    }

    /**
     * Display the specified resource.
     *
     * @param \App\Producto $producto
     * @return \Illuminate\Http\Response
     */    public function show(Producto $producto)
    {
        //
    }

    /**
     * Show the form for editing the specified resource.
     *
     * @param \App\Producto $producto
     * @return \Illuminate\Http\Response
     */    public function edit(Producto $producto)
    {
        return view("productos.productos_edit", ["producto" => $producto,
        ]);
    }

    /**
     * Update the specified resource in storage.
     *
     * @param \Illuminate\Http\Request $request
     * @param \App\Producto $producto
     * @return \Illuminate\Http\Response
     */    public function update(Request $request, Producto $producto)
    {
        $producto->fill($request->input());
        $producto->saveOrFail();
        return redirect()->route("productos.index")->with("mensaje", "Producto actualizado");
    }

    /**
     * Remove the specified resource from storage.
     *
     * @param \App\Producto $producto
     * @return \Illuminate\Http\Response
     */    public function destroy(Producto $producto)
    {
        $producto->delete();
        return redirect()->route("productos.index")->with("mensaje", "Producto eliminado");
    }
}

El formulario de editar es muy parecido al de insertar. Para mostrar los productos se utiliza una tabla responsiva de Bootstrap además de usar @foreach de Blade:

@extends("maestra")
@section("titulo", "Productos")
@section("contenido")
    <div class="row">
        <div class="col-12">
            <h1>Productos <i class="fa fa-box"></i></h1>
            <a href="{{route("productos.create")}}" class="btn btn-success mb-2">Agregar</a>
            @include("notificacion")
            <div class="table-responsive">
                <table class="table table-bordered">
                    <thead>
                    <tr>
                        <th>Código de barras</th>
                        <th>Descripción</th>
                        <th>Precio de compra</th>
                        <th>Precio de venta</th>
                        <th>Utilidad</th>
                        <th>Existencia</th>

                        <th>Editar</th>
                        <th>Eliminar</th>
                    </tr>
                    </thead>
                    <tbody>
                    @foreach($productos as $producto)
                        <tr>
                            <td>{{$producto->codigo_barras}}</td>
                            <td>{{$producto->descripcion}}</td>
                            <td>{{$producto->precio_compra}}</td>
                            <td>{{$producto->precio_venta}}</td>
                            <td>{{$producto->precio_venta - $producto->precio_compra}}</td>
                            <td>{{$producto->existencia}}</td>
                            <td>
                                <a class="btn btn-warning" href="{{route("productos.edit",[$producto])}}">
                                    <i class="fa fa-edit"></i>
                                </a>
                            </td>
                            <td>
                                <form action="{{route("productos.destroy", [$producto])}}" method="post">
                                    @method("delete")
                                    @csrf
                                    <button type="submit" class="btn btn-danger">
                                        <i class="fa fa-trash"></i>
                                    </button>
                                </form>
                            </td>
                        </tr>
                    @endforeach
                    </tbody>
                </table>
            </div>
        </div>
    </div>
@endsection
 b

Con el siguiente resultado:

Listado de productos con existencia y utilidad

Recuerda que el código completo lo puedes ver en GitHub; al final dejaré el enlace. A partir de aquí no explicaré a fondo el código repetitivo, pues ya lo hice con los productos. Veamos las cosas que son más importantes.

Realizar ventas

Interfaz para vender

En la interfaz tenemos un campo que sirve para escanear el código de barras, ya sea que se use un lector o que se escriba el código y se presione la tecla Enter.

Cuando hay productos, se muestra la lista de los mismos así como algunos botones para cancelar la venta, terminar la venta o quitar un producto de la lista de venta.

Realizar venta – Agregar productos al carrito por código de barras

Además de eso, se muestra el total de la venta formateado como moneda.

Lista de productos que se venden

El carrito de compras o lista de productos para la venta se guarda en la sesión como un arreglo, de este modo no se pierde aunque el usuario refresque la página o navegue a otro lugar; permitiendo así un mejor control.

He implementado un simple carrito de compras con un arreglo, las funciones que lo manejan son:

<?php
private function obtenerProductos()
{
    $productos = session("productos");
    if (!$productos) {
        $productos = [];
    }
    return $productos;
}

private function vaciarProductos()
{
    $this->guardarProductos(null);
}

private function guardarProductos($productos)
{
    session(["productos" => $productos,
    ]);
}

public function cancelarVenta()
{
    $this->vaciarProductos();
    return redirect()
        ->route("vender.index")
        ->with("mensaje", "Venta cancelada");
}

public function quitarProductoDeVenta(Request $request)
{
    $indice = $request->post("indice");
    $productos = $this->obtenerProductos();
    array_splice($productos, $indice, 1);
    $this->guardarProductos($productos);
    return redirect()
        ->route("vender.index");
}

public function agregarProductoVenta(Request $request)
{
    $codigo = $request->post("codigo");
    $producto = Producto::where("codigo_barras", "=", $codigo)->first();
    if (!$producto) {
        return redirect()
            ->route("vender.index")
            ->with("mensaje", "Producto no encontrado");
    }
    $this->agregarProductoACarrito($producto);
    return redirect()
        ->route("vender.index");
}

private function agregarProductoACarrito($producto)
{
    if ($producto->existencia <= 0) {
        return redirect()->route("vender.index")
            ->with([
                "mensaje" => "No hay existencias del producto",
                "tipo" => "danger"
            ]);
    }
    $productos = $this->obtenerProductos();
    $posibleIndice = $this->buscarIndiceDeProducto($producto->codigo_barras, $productos);
    // Es decir, producto no fue encontrado
    if ($posibleIndice === -1) {
        $producto->cantidad = 1;
        array_push($productos, $producto);
    } else {
        if ($productos[$posibleIndice]->cantidad + 1 > $producto->existencia) {
            return redirect()->route("vender.index")
                ->with([
                    "mensaje" => "No se pueden agregar más productos de este tipo, se quedarían sin existencia",
                    "tipo" => "danger"
                ]);
        }
        $productos[$posibleIndice]->cantidad++;
    }
    $this->guardarProductos($productos);
}

private function buscarIndiceDeProducto(string $codigo, array &$productos)
{
    foreach ($productos as $indice => $producto) {
        if ($producto->codigo_barras === $codigo) {
            return $indice;
        }
    }
    return -1;
}

Maneja varias opciones. Por ejemplo, cuando se agrega un producto se envía el código de barras y se busca un producto por ese código; después se hacen dos verificaciones:

  1. Que haya existencia del producto
  2. Que al agregar el producto a la lista de venta no se vaya a quedar sin existencia

Por poner un ejemplo, si se quiere vender más de lo que existe, se muestra una advertencia:

Comprobar existencia de productos antes de vender

Además, si el producto ya existe, no se agrega, sino que se aumenta su cantidad.

Terminar venta

Al terminar la venta se involucran varias cosas. Comenzando con que tenemos un modelo distinto llamado ProductoVendido que se encarga de encapsular cómo se comporta un producto vendido.

Lo he separado de este modo porque el precio de venta no siempre será el mismo; es decir, hoy puedes vender un producto a 100 pesos que mañana costará 112 por ejemplo; entonces se debe guardar el precio de venta en el que se vendió.

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class ProductoVendido extends Model
{
    protected $table = "productos_vendidos";
    protected $fillable = ["id_venta", "descripcion", "codigo_barras", "precio", "cantidad"];
}

Además de eso, el producto lleva una relación con la tabla Ventas, mismas que tienen una fecha de creación (los timestamps) y un id:

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Venta extends Model
{
    public function productos()
    {
        return $this->hasMany("App\ProductoVendido", "id_venta");
    }
}

Aquí hay un punto interesante, pues tenemos una relación entre productos y ventas, usando las relaciones que Laravel provee. Finalmente, para terminar la venta se hace lo siguiente:

<?php
public function terminarVenta()
{
    // Crear una venta
    $venta = new Venta();
    $venta->saveOrFail();
    $idVenta = $venta->id;
    $productos = $this->obtenerProductos();
    // Recorrer carrito de compras
    foreach ($productos as $producto) {
        // El producto que se vende...
        $productoVendido = new ProductoVendido();
        $productoVendido->fill([
            "id_venta" => $idVenta,
            "descripcion" => $producto->descripcion,
            "codigo_barras" => $producto->codigo_barras,
            "precio" => $producto->precio_venta,
            "cantidad" => $producto->cantidad,
        ]);
        // Lo guardamos
        $productoVendido->saveOrFail();
        // Y restamos la existencia del original
        $productoActualizado = Producto::find($producto->id);
        $productoActualizado->existencia -= $productoVendido->cantidad;
        $productoActualizado->saveOrFail();
    }
    $this->vaciarProductos();
    return redirect()
        ->route("vender.index")
        ->with("mensaje", "Venta terminada");
}

Y así es como se guardan los productos vendidos junto con la venta y la fecha de creación. Más tarde vamos a usar esto para ver el reporte de ventas, el detalle de una venta y para imprimir tickets.

Reporte de ventas

El reporte de ventas muestra el total de la venta, la fecha de venta y botones para ver los detalles, imprimir el ticket o eliminar la venta. La consulta para listar las ventas con el total es:

<?php
/**
 * Display a listing of the resource.
 *
 * @return \Illuminate\Http\Response
 */public function index()
{
    $ventasConTotales = Venta::join("productos_vendidos", "productos_vendidos.id_venta", "=", "ventas.id")
        ->select("ventas.*", DB::raw("sum(productos_vendidos.cantidad * productos_vendidos.precio) as total"))
        ->groupBy("ventas.id", "ventas.created_at", "ventas.updated_at")
        ->get();
    return view("ventas.ventas_index", ["ventas" => $ventasConTotales,]);
}

Es un poco compleja y me llevó algo de tiempo, sobre todo porque quise hacerla con el Query builder en lugar de hacer la consulta raw. Esas ventas son mostradas en una tabla, así:

Reporte de ventas con total y fecha

Cuando se muestra el detalle de la venta utilizamos el método show del controlador, que muestra una venta. Ahí, gracias a la relación que definimos anteriormente podemos obtener la tabla de productos y dibujar el detalle como el total:

Detalle de venta con opción para imprimir ticket

Por cierto, la vista se ve así:

@extends("maestra")
@section("titulo", "Detalle de venta ")
@section("contenido")
    <div class="row">
        <div class="col-12">
            <h1>Detalle de venta #{{$venta->id}}</h1>
            @include("notificacion")
            <a class="btn btn-info" href="{{route("ventas.index")}}">
                <i class="fa fa-arrow-left"></i>&nbsp;Volver
            </a>
            <a class="btn btn-success" href="{{route("ventas.ticket", ["id" => $venta->id])}}">
                <i class="fa fa-print"></i>&nbsp;Ticket
            </a>
            <h2>Productos</h2>
            <table class="table table-bordered">
                <thead>
                <tr>
                    <th>Descripción</th>
                    <th>Código de barras</th>
                    <th>Precio</th>
                    <th>Cantidad</th>
                    <th>Subtotal</th>
                </tr>
                </thead>
                <tbody>
                @foreach($venta->productos as $producto)
                    <tr>
                        <td>{{$producto->descripcion}}</td>
                        <td>{{$producto->codigo_barras}}</td>
                        <td>${{number_format($producto->precio)}}</td>
                        <td>{{$producto->cantidad}}</td>
                        <td>${{number_format($producto->cantidad * $producto->precio)}}</td>
                    </tr>
                @endforeach
                </tbody>
                <tfoot>
                <tr>
                    <td colspan="3"></td>
                    <td><strong>Total</strong></td>
                    <td>${{number_format($total, 2)}}</td>
                </tr>
                </tfoot>
            </table>

        </div>
    </div>
@endsection

Eso es lo que se encarga de dibujar la tabla, colocar los enlaces, etcétera.

Ticket de venta

Nota: si quieres saber cómo configurar tu impresora mira el post sobre cómo imprimir un ticket en PHP.

Veamos cómo es que se imprime el ticket en una impresora térmica. Lo que hay que hacer es instalar la librería de mike42; me basé en mi tutorial para imprimir en impresora térmica con Laravel.

La función del controlador es la siguiente:

<?php
public function ticket(Request $request)
{
    $venta = Venta::findOrFail($request->get("id"));
    $nombreImpresora = env("NOMBRE_IMPRESORA");
    $connector = new WindowsPrintConnector($nombreImpresora);
    $impresora = new Printer($connector);
    $impresora->setJustification(Printer::JUSTIFY_CENTER);
    $impresora->setEmphasis(true);
    $impresora->text("Ticket de venta\n");
    $impresora->text($venta->created_at . "\n");
    $impresora->text("https://parzibyte.me/blog\n");
    $impresora->setEmphasis(false);
    $impresora->text("\n===============================\n");
    $total = 0;
    foreach ($venta->productos as $producto) {
        $subtotal = $producto->cantidad * $producto->precio;
        $total += $subtotal;
        $impresora->setJustification(Printer::JUSTIFY_LEFT);
        $impresora->text(sprintf("%.2fx%s\n", $producto->cantidad, $producto->descripcion));
        $impresora->setJustification(Printer::JUSTIFY_RIGHT);
        $impresora->text('$' . number_format($subtotal, 2) . "\n");
    }
    $impresora->setJustification(Printer::JUSTIFY_CENTER);
    $impresora->text("\n===============================\n");
    $impresora->setJustification(Printer::JUSTIFY_RIGHT);
    $impresora->setEmphasis(true);
    $impresora->text("Total: $" . number_format($total, 2) . "\n");
    $impresora->setJustification(Printer::JUSTIFY_CENTER);
    $impresora->setTextSize(1, 1);
    $impresora->text("Gracias por su compra\n");
    $impresora->text("https://parzibyte.me/blog");
    $impresora->feed(5);
    $impresora->close();
    return redirect()->back()->with("mensaje", "Ticket impreso");
}

Comenzamos obteniendo la venta por ID, misma variable que es pasada a través de la URL. El nombre de la impresora está en el archivo .env, así se puede configurar de manera sencilla.

Imprimimos el encabezado, la fecha de la venta y luego recorremos los productos de la venta (previamente registrados en la base de datos). Por cada uno dibujamos la cantidad, la descripción y el subtotal.

Al pie del ticket imprimimos el total y un mensaje de agradecimiento. Más tarde se redirecciona a la página de donde se venía y si todo está bien, el ticket se debería ver parecido al de la siguiente imagen:

Ticket de venta impreso desde PDV con Laravel

Obviamente el ticket cambiará dependiendo de la lista de productos, y es totalmente dinámico.

Sistema de ventas responsivo

Quiero mostrar que este sistema se adapta a cualquier tamaño de pantalla. Por ejemplo, en un teléfono móvil se ve así:

Sistema de ventas responsivo – Mostrado en iPhone

Por lo tanto puedes usarlo en una red de área local o subirlo a un hosting; puedes visitarlo desde cualquier lugar.

Descargar software y ver código fuente

El código fuente del programa está en mi repositorio de GitHub. Si quieres descargar el código haz click en Clone or download > Download ZIP dentro del repositorio. Para usarlo configura el archivo env, instala las dependencias con composer y realiza las migraciones pertinentes.

Los pasos son:

  1. Descargar y colocar en la carpeta pública de Apache.
  2. Habilitar la extensión intl en caso de no estar instalada.
  3. Ejecutar composer install
  4. Configurar .env copiando .env.example y colocando las credenciales para la base de datos
  5. Hacer la migración con php artisan migrate
  6. Generar la clave con php artisan key:generate

Solucionando Undefined index: name

En algunos casos el sistema da el siguiente error:

> Illuminate\Foundation\ComposerScripts::postAutoloadDump
> @php artisan package:discover --ansi

In PackageManifest.php line 131:

  Undefined index: name

Para arreglarlo hice un composer self-update, luego un composer update laravel/framework y finalmente un composer install

Ya que parece que es un bug que ocurría en el framework.

Instalación del sistema

He creado un vídeo en YouTube para mostrar cómo instalar este sistema, en caso de que se te complique.

Demostración

Puedes ver un vídeo de demostración del sistema:

Conclusión

Para una clase que estoy llevando actualmente en la universidad tenía que realizar un CRUD con Laravel, pero tenía ganas (desde hace mucho) de hacer un punto de venta con Laravel y agregarle algunas cosas como la impresión de tickets.

Si te gusta el framework, te invito a leer más sobre Laravel en mi blog. También te invito a ver mis otros proyectos, algunos de ellos open source.

Además, he realizado varios puntos de venta. Entre ellos:

Nota: he agregado más módulos a este sistema, entre ellos el módulo de clientes y el módulo de usuarios.

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

  • Hola amigo gran proyecto pero me da un error al momento de instalarlo, me dice:
    -laravel/passport require php 7.2 ,Your version 8.0.2 does satisfy that requirem,
    Me podes ayudar?:( ,Muchas gracias!!

    • Ahí mismo le está diciendo el error. "laravel/passport require php 7.2 ,Your version 8.0.2 does satisfy that requirem..." es decir, laravel/passport necesita la versión 7.2 y usted tiene la 8.0.2. Ahí recomiendo actualizar la dependencia a una versión que soporte PHP 8.0.2 y ya como última opción cambiar de PHP 8 a 7

  • BUenas noches amigo, estuve revisando el codigo y al momento de eliminar una venta ya realizada el stock deberia de regresar al stock inicial, ejemplo si tengo un producto con 10 en stock al vender 5 el stock queda en 5 pero al eliminar la venta el stock deberia de regresar al 10 si es que no me equivoco

    • Hola, buenas noches. Entiendo, gracias por revisar. Recuerde que el programa es open source, espero su pull request para mejorar la parte que me menciona.
      Saludos!

  • Buenas noches, al subirlo a un Hosting no imprime, sale error con la clase WindowsPrintConnector, me podrías orientar como se soluciona cuando lo subes a un CPanel por favor.

  • Buenas noches, excelente el sistema. Tendrás el código de la apk? me interesa sobre todo ver como consume la API. Gracias

  • Buenas tardes amigo como esta.?
    Querìa instalar dicho sistema para probarlo.
    pero me genera el siguiente error.
    In PackageManifest.php line 131:

    Undefined index: name

    Script @php artisan package:discover --ansi handling the post-autoload-dump event returned with error code 1

    alguna soluciòn

  • amigo excelente trabajo una pregunta algun tutorial de como instalar este sistema en un hosting con cpanel? tu respuesta sera muy agradecida, me gustaria instalar este sistema pero online

  • Se que las ultimas versiones son para 64bit, pero necesito que funcione en 32bit por un tema de portabilidad. Ya he intentado actualizar PHP a 7.4 pero no logro hacerlo fucionar con 32bit esta disponible hasta la ver. 7.3.2 busque tutoriales pero lo logro hacerlo funcionar. Agradeceria que me des una ayuda con esto por que parece muy interesante el sistema. Muchas gracias.

  • Estoy utilizando la ultima version para windows en 32bit (por necesidad) y el SQL es 7.3.2 ya he probado otras proyectos con laravel y migra todo perfectamente. Por eso consulto.

    • Hola. Mi versión es: mysql Ver 15.1 Distrib 10.4.11-MariaDB, for Win64 (AMD64),
      He probado el proyecto y funciona de maravilla
      Saludos

Entradas recientes

Creador de credenciales web – Aplicación gratuita

Hoy te voy a presentar un creador de credenciales que acabo de programar y que…

17 horas hace

Desplegar PWA creada con Vue 3, Vite y SQLite3 en Apache

Ya te enseñé cómo convertir una aplicación web de Vue 3 en una PWA. Al…

1 semana hace

Arquitectura para wasm con Go, Vue 3, Pinia y Vite

En este artículo voy a documentar la arquitectura que yo utilizo al trabajar con WebAssembly…

1 semana hace

Vue 3 y Vite: crear PWA (Progressive Web App)

En un artículo anterior te enseñé a crear un PWA. Al final, cualquier aplicación que…

1 semana hace

Errores de Comlink y algunas soluciones

Al usar Comlink para trabajar con los workers usando JavaScript me han aparecido algunos errores…

1 semana hace

Esperar promesa para inicializar Store de Pinia con Vue 3

En este artículo te voy a enseñar cómo usar un "top level await" esperando a…

1 semana hace

Esta web usa cookies.