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
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 );
}
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' ],
// ...
]);
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
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 Documento Tipo Movimiento Origen ventasalida VentasController al crear venta compraentrada CompraController al registrar compra ajusteentrada/salida Ajuste manual de inventario anulacion_ventaentrada VentasController al anular venta anulacion_comprasalida CompraController al anular compra descuento_almacensalida VentasController 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
Tabla Descripción Campos Clave productosCatálogo de productos id_producto, codigo, nombre, cantidad, almacen, id_empresamovimientos_stockHistorial de movimientos id_producto, tipo_movimiento, cantidad, stock_anterior, stock_nuevocategoriasCategorías de productos id, nombre, descripcionunidadesUnidades de medida SUNAT id, 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