Skip to main content

Módulo de Inventario

El módulo de inventario permite gestionar el catálogo de productos con control automático de stock, trazabilidad completa de movimientos, alertas de stock bajo y soporte para dos almacenes (virtual y real).

Características Principales

Catálogo de Productos

Registro completo con códigos, precios, categorías y unidades

Dos Almacenes

Almacén virtual (1) para ventas y real (2) para stock físico

Movimientos Automáticos

Stock se actualiza con ventas, compras y ajustes

Trazabilidad Completa

Historial de cada entrada/salida con usuario y motivo

Estructura de Producto

Cada producto tiene los siguientes datos:
  • Código: Identificador único (ej: PROD-001)
  • Código de barras: Para escáner
  • Código SUNAT: Clasificación tributaria
  • Nombre: Descripción corta
  • Descripción: Detalles del producto
  • Costo: Precio de compra
  • Precio: Precio de venta al público
  • Precio mayor: Precio por mayor
  • Precio menor: Precio al por menor
  • Precio unidad: Precio por unidad fraccionada
  • Moneda: PEN o USD
  • Cantidad: Stock actual
  • Stock mínimo: Alerta de reposición
  • Stock máximo: Límite de almacenamiento
  • Almacén: 1 (virtual) o 2 (real)
  • Última salida: Fecha de última venta
  • Último ingreso: Fecha de última compra
  • Categoría: Grupo al que pertenece
  • Unidad de medida: NIU, ZZ, KGM, etc.
  • Imagen: Foto del producto

Sistema de Almacenes

El sistema maneja dos almacenes independientes:

Almacén Virtual (1)

Uso: Stock disponible para ventas
  • Se descuenta automáticamente al crear ventas
  • Usado para cálculos de disponibilidad
  • Puede ser diferente del stock físico

Almacén Real (2)

Uso: Stock físico en bodega
  • Se descuenta manualmente después del despacho
  • Refleja el inventario real
  • Usado para control de almacén
Flujo típico: Al crear una venta, se descuenta del almacén virtual (1). Después del despacho, se descuenta manualmente del almacén real (2).

Flujo de Trabajo

1

Crear Producto

Registra el producto con todos sus datos:
// ProductoController.php:56-107
public function store(ProductoRequest $request)
{
    $user = $request->user();
    $data = $request->validated();
    $idEmpresa = $user->id_empresa;

    if ($request->hasFile('imagen')) {
        $data['imagen'] = $this->productoService->subirImagen(
            $request->file('imagen')
        );
    }

    $producto = $this->productoService->crear($data, $idEmpresa);

    return response()->json([
        'success' => true,
        'message' => 'Producto creado en ambos almacenes',
        'data' => $producto,
    ], 201);
}
2

Sincronización Automática

El ProductoService crea el producto en ambos almacenes:
// ProductoService::crear()
$productoAlmacen1 = Producto::create([
    'almacen' => '1',
    'codigo' => $data['codigo'],
    'nombre' => $data['nombre'],
    // ...
]);

// Crear copia en almacén 2
$productoAlmacen2 = Producto::create([
    'almacen' => '2',
    'codigo' => $data['codigo'],
    'nombre' => $data['nombre'],
    // ...
]);
3

Movimientos de Stock

El stock se actualiza automáticamente con:
  • Ventas: Descuenta del almacén virtual
  • Compras: Aumenta en ambos almacenes
  • Ajustes: Corrección manual de inventario
  • Anulaciones: Revierte movimientos
4

Trazabilidad

Cada movimiento se registra en movimientos_stock:
MovimientoStock::create([
    'id_producto' => $producto->id_producto,
    'tipo_movimiento' => 'salida',  // o 'entrada'
    'cantidad' => $cantidad,
    'stock_anterior' => $stockAnterior,
    'stock_nuevo' => $stockNuevo,
    'tipo_documento' => 'venta',  // o 'compra', 'ajuste'
    'id_documento' => $venta->id_venta,
    'documento_referencia' => 'F001-000123',
    'motivo' => 'Venta realizada',
    'id_almacen' => 1,
    'id_empresa' => $idEmpresa,
    'id_usuario' => $userId,
    'fecha_movimiento' => now()
]);

Implementación Backend

ProductoController

// app/Http/Controllers/ProductoController.php

class ProductoController extends Controller
{
    public function __construct(
        private ProductoService $productoService
    ) {}

    public function index(Request $request)
    {
        $user = $request->user();
        $idEmpresa = $user->id_empresa;
        
        $productos = $this->productoService->listar(
            $idEmpresa,
            $request->get('almacen', '1'),  // Por defecto almacén virtual
            $request->get('search'),
            $request->boolean('solo_con_stock', false)
        );

        return response()->json([
            'success' => true, 
            'data' => $productos
        ]);
    }

    public function store(ProductoRequest $request)
    {
        $user = $request->user();
        $data = $request->validated();
        $idEmpresa = $user->id_empresa;

        if ($request->hasFile('imagen')) {
            $data['imagen'] = $this->productoService->subirImagen(
                $request->file('imagen')
            );
        }

        $replicar = filter_var(
            $request->input('replicar_empresas', false), 
            FILTER_VALIDATE_BOOLEAN
        );
        
        $producto = $this->productoService->crear($data, $idEmpresa);

        // Replicar a otras empresas si se solicitó
        if ($replicar) {
            $otrasEmpresas = Empresa::where('id_empresa', '!=', $idEmpresa)
                ->where('estado', '1')
                ->pluck('id_empresa');

            foreach ($otrasEmpresas as $empId) {
                $existe = Producto::where('id_empresa', $empId)
                    ->where('nombre', $data['nombre'])
                    ->exists();
                    
                if (!$existe) {
                    $this->productoService->crear($data, $empId);
                }
            }
        }

        return response()->json([
            'success' => true,
            'message' => $replicar
                ? 'Producto creado y replicado a todas las empresas'
                : 'Producto creado en ambos almacenes',
            'data' => $producto,
        ], 201);
    }

    public function update(ProductoRequest $request, $id)
    {
        $producto = Producto::findOrFail($id);
        $data = $request->except(['imagen']);

        if ($request->hasFile('imagen')) {
            $data['imagen'] = $this->productoService->subirImagen(
                $request->file('imagen'),
                $producto->imagen  // Reemplazar imagen anterior
            );
        }

        $resultado = $this->productoService->actualizar($producto, $data);

        return response()->json([
            'success' => true,
            'message' => 'Producto actualizado' . 
                ($resultado['sincronizado'] ? ' (sincronizado en ambos almacenes)' : ''),
            'data' => $resultado['producto'],
            'sincronizado' => $resultado['sincronizado'],
        ]);
    }
}

ProductoService

El servicio maneja la lógica de negocio:
// app/Services/ProductoService.php

class ProductoService
{
    public function crear(array $data, int $idEmpresa): Producto
    {
        $data['id_empresa'] = $idEmpresa;
        $data['estado'] = '1';
        $data['fecha_registro'] = now();

        // Crear en almacén 1 (virtual)
        $data['almacen'] = '1';
        $productoAlmacen1 = Producto::create($data);

        // Crear copia en almacén 2 (real)
        $data['almacen'] = '2';
        $productoAlmacen2 = Producto::create($data);

        return $productoAlmacen1;
    }

    public function actualizar(Producto $producto, array $data): array
    {
        $producto->update($data);

        // Buscar producto en el otro almacén por código
        $otroAlmacen = $producto->almacen == '1' ? '2' : '1';
        $productoOtro = Producto::where('id_empresa', $producto->id_empresa)
            ->where('almacen', $otroAlmacen)
            ->where('codigo', $producto->codigo)
            ->first();

        $sincronizado = false;
        if ($productoOtro) {
            // Actualizar datos excepto cantidad
            $dataOtro = $data;
            unset($dataOtro['cantidad']);
            $productoOtro->update($dataOtro);
            $sincronizado = true;
        }

        return [
            'producto' => $producto->fresh(),
            'sincronizado' => $sincronizado
        ];
    }

    public function listar(
        int $idEmpresa, 
        string $almacen = '1', 
        ?string $search = null,
        bool $soloConStock = false
    )
    {
        $query = Producto::where('id_empresa', $idEmpresa)
            ->where('almacen', $almacen)
            ->where('estado', '1');

        if ($search) {
            $query->where(function($q) use ($search) {
                $q->where('nombre', 'like', "%{$search}%")
                  ->orWhere('codigo', 'like', "%{$search}%")
                  ->orWhere('cod_barra', 'like', "%{$search}%");
            });
        }

        if ($soloConStock) {
            $query->where('cantidad', '>', 0);
        }

        return $query->with(['categoria', 'unidad'])
            ->orderBy('nombre')
            ->get();
    }
}

Modelo: Producto

// app/Models/Producto.php

class Producto extends Model
{
    protected $table = 'productos';
    protected $primaryKey = 'id_producto';
    
    protected $fillable = [
        'codigo', 'cod_barra', 'nombre', 'descripcion',
        'precio', 'costo', 'precio_mayor', 'precio_menor', 'precio_unidad',
        'cantidad', 'stock_minimo', 'stock_maximo',
        'id_empresa', 'categoria_id', 'unidad_id', 'almacen',
        'codsunat', 'usar_barra', 'usar_multiprecio',
        'moneda', 'estado', 'imagen',
        'ultima_salida', 'fecha_ultimo_ingreso',
    ];

    protected $casts = [
        'precio' => 'decimal:2',
        'costo' => 'decimal:2',
        'cantidad' => 'integer',
        'ultima_salida' => 'date',
        'fecha_ultimo_ingreso' => 'datetime',
    ];

    // Relaciones
    public function empresa()
    {
        return $this->belongsTo(Empresa::class, 'id_empresa');
    }

    public function categoria()
    {
        return $this->belongsTo(Categoria::class, 'categoria_id');
    }

    public function unidad()
    {
        return $this->belongsTo(Unidad::class, 'unidad_id');
    }

    // Scopes
    public function scopeAlmacen($query, $almacen)
    {
        return $query->where('almacen', $almacen);
    }

    public function scopeActivo($query)
    {
        return $query->where('estado', '1');
    }

    public function scopeEmpresa($query, $idEmpresa)
    {
        return $query->where('id_empresa', $idEmpresa);
    }
}

Movimientos de Stock

Cada cambio en el inventario se registra:
// Estructura de MovimientoStock
[
    'id_producto' => 42,
    'tipo_movimiento' => 'entrada',  // o 'salida'
    'cantidad' => 10,
    'stock_anterior' => 50,
    'stock_nuevo' => 60,
    'tipo_documento' => 'compra',  // venta, compra, ajuste, anulacion_venta, etc.
    'id_documento' => 123,  // ID del documento relacionado
    'documento_referencia' => 'F001-000123',
    'motivo' => 'Compra a proveedor XYZ',
    'id_almacen' => 1,
    'id_empresa' => 1,
    'id_usuario' => 5,
    'fecha_movimiento' => '2026-03-06 10:30:00'
]

Tipos de Movimientos

Tipo DocumentoTipo MovimientoOrigen
ventasalidaVentasController al crear venta
compraentradaCompraController al registrar compra
ajusteentrada/salidaAjuste manual de inventario
anulacion_ventaentradaVentasController al anular venta
anulacion_comprasalidaCompraController al anular compra
descuento_almacensalidaVentasController al descontar almacén real

Replicación Masiva

Para distribuir productos entre varias empresas:
// ProductoController.php:141-273
public function replicarMasivo(Request $request)
{
    set_time_limit(180);  // Incrementar tiempo de ejecución

    $user = $request->user();
    $idEmpresaOrigen = $user->id_empresa;
    $almacen = $request->input('almacen', '1');
    $idEmpresaDestino = $request->input('id_empresa_destino');  // null = todas

    // Empresas destino
    $queryEmpresas = Empresa::where('id_empresa', '!=', $idEmpresaOrigen)
        ->where('estado', '1');
        
    if ($idEmpresaDestino) {
        $queryEmpresas->where('id_empresa', $idEmpresaDestino);
    }
    
    $empresasDestino = $queryEmpresas->pluck('id_empresa');

    if ($empresasDestino->isEmpty()) {
        return response()->json([
            'success' => false, 
            'message' => 'No hay empresas destino'
        ], 422);
    }

    // Obtener productos de origen
    $productos = Producto::where('id_empresa', $idEmpresaOrigen)
        ->where('almacen', $almacen)
        ->where('estado', '1')
        ->get();

    $creados = 0;
    $omitidos = 0;

    foreach ($empresasDestino as $empId) {
        // Nombres ya existentes
        $nombresExistentes = Producto::where('id_empresa', $empId)
            ->where('almacen', $almacen)
            ->pluck('nombre')
            ->map(fn($n) => strtolower(trim($n)))
            ->flip()
            ->all();

        $batch = [];
        foreach ($productos as $producto) {
            $nombreLower = strtolower(trim($producto->nombre));
            
            if (isset($nombresExistentes[$nombreLower])) {
                $omitidos++;
                continue;
            }

            $batch[] = [
                'id_empresa' => $empId,
                'nombre' => $producto->nombre,
                'codigo' => $producto->codigo,
                'precio' => $producto->precio,
                'costo' => $producto->costo,
                'cantidad' => $producto->cantidad ?? 0,
                'almacen' => $producto->almacen,
                // ... otros campos
                'created_at' => now(),
                'updated_at' => now(),
            ];

            $creados++;
        }

        // Insertar en lotes de 100
        foreach (array_chunk($batch, 100) as $chunk) {
            Producto::insert($chunk);
        }
    }

    return response()->json([
        'success' => true,
        'message' => "{$creados} producto(s) creado(s), {$omitidos} omitido(s)",
        'creados' => $creados,
        'omitidos' => $omitidos,
    ]);
}

Importación desde Excel

// app/Http/Controllers/Imports/ProductoImportController.php

public function importar(Request $request)
{
    $request->validate([
        'archivo' => 'required|file|mimes:xlsx,xls'
    ]);

    $archivo = $request->file('archivo');
    $spreadsheet = IOFactory::load($archivo->getPathname());
    $sheet = $spreadsheet->getActiveSheet();
    
    $productos = [];
    foreach ($sheet->getRowIterator(2) as $row) {  // Desde fila 2 (skip header)
        $cellIterator = $row->getCellIterator();
        $cellIterator->setIterateOnlyExistingCells(false);
        
        $cells = [];
        foreach ($cellIterator as $cell) {
            $cells[] = $cell->getValue();
        }
        
        $productos[] = [
            'codigo' => $cells[0],
            'nombre' => $cells[1],
            'precio' => $cells[2],
            'costo' => $cells[3],
            'cantidad' => $cells[4],
            // ...
        ];
    }
    
    // Insertar productos
    foreach ($productos as $data) {
        Producto::create($data);
    }
    
    return response()->json([
        'success' => true,
        'message' => count($productos) . ' productos importados'
    ]);
}

Tablas de Base de Datos

TablaDescripciónCampos Clave
productosCatálogo de productosid_producto, codigo, nombre, cantidad, almacen, id_empresa
movimientos_stockHistorial de movimientosid_producto, tipo_movimiento, cantidad, stock_anterior, stock_nuevo
categoriasCategorías de productosid, nombre, descripcion
unidadesUnidades de medida SUNATid, codigo, descripcion

Endpoints API

Listar Productos

GET /api/productos?almacen=1&search=laptop

Ver Producto

GET /api/productos/:id

Crear Producto

POST /api/productos

Actualizar

PUT /api/productos/:id

Eliminar

DELETE /api/productos/:id

Movimientos

GET /api/productos/:id/movimientos

Buenas Prácticas

Recomendación: Realiza inventarios físicos periódicos y ajusta las diferencias en el sistema para mantener datos precisos.
Al eliminar un producto, verifica que NO tenga:
  • Ventas asociadas
  • Compras pendientes
  • Stock diferente de cero
Los productos se crean automáticamente en ambos almacenes con el mismo código. Usa el código para vincularlos.

Próximos Pasos

Compras

Registra compras para aumentar stock

Facturación

Ventas que descuentan stock

Reportes de Inventario

Kardex, valorizado y movimientos

Importar Excel

Carga masiva de productos

Build docs developers (and LLMs) love