Refactorizar gestión de inventario a sistema multi-almacén

- Migrar manejo de stock de  a .
- Implementar  para centralizar lógica de entradas, salidas y transferencias.
- Añadir  (CRUD) y Requests de validación.
- Actualizar reportes, cálculos de valor y migraciones para la nueva estructura.
- Agregar campo  para rastreo de movimientos.
This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-05 23:59:35 -06:00
parent 3cac336e10
commit 5a646d84d5
22 changed files with 642 additions and 105 deletions

View File

@ -480,7 +480,9 @@ public function inventoryReport(Request $request)
$q->where('track_serials', true); $q->where('track_serials', true);
}) })
->when($request->low_stock_threshold, function($q) use ($request) { ->when($request->low_stock_threshold, function($q) use ($request) {
$q->where('stock', '<=', $request->low_stock_threshold); $q->whereHas('warehouses', function($wq) use ($request) {
$wq->where('inventory_warehouse.stock', '<=', $request->low_stock_threshold);
});
}); });
$inventories = $query->orderBy('name')->get(); $inventories = $query->orderBy('name')->get();

View File

@ -43,9 +43,11 @@ public function index(Request $request)
} }
// Calcular el valor total del inventario // Calcular el valor total del inventario
$totalInventoryValue = Inventory::join('prices', 'inventories.id', '=', 'prices.inventory_id') $totalInventoryValue = DB::table('inventory_warehouse')
->join('prices', 'inventory_warehouse.inventory_id', '=', 'prices.inventory_id')
->join('inventories', 'inventory_warehouse.inventory_id', '=', 'inventories.id')
->where('inventories.is_active', true) ->where('inventories.is_active', true)
->sum(DB::raw('inventories.stock * prices.cost')); ->sum(DB::raw('inventory_warehouse.stock * prices.cost'));
$products = $products->orderBy('name') $products = $products->orderBy('name')
->paginate(config('app.pagination')); ->paginate(config('app.pagination'));

View File

@ -1,37 +1,135 @@
<?php namespace App\Http\Controllers\App; <?php namespace App\Http\Controllers\App;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\App\InventoryEntryRequest;
use App\Http\Requests\App\InventoryExitRequest;
use App\Http\Requests\App\InventoryTransferRequest;
use App\Models\InventoryMovement;
use App\Services\InventoryMovementService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/** /**
* Descripción * Controlador para movimientos de inventario
* *
* @author Moisés Cortés C. <moises.cortes@notsoweb.com> * @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0 * @version 1.0.0
*/ */
class InventoryMovementController extends Controller class InventoryMovementController extends Controller
{ {
public function __construct(
protected InventoryMovementService $movementService
) {}
/**
* Listar movimientos con filtros
*/
public function index(Request $request) public function index(Request $request)
{ {
// $query = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user'])
->orderBy('created_at', 'desc');
if ($request->has('movement_type')) {
$query->where('movement_type', $request->movement_type);
}
if ($request->has('inventory_id')) {
$query->where('inventory_id', $request->inventory_id);
}
if ($request->has('warehouse_id')) {
$query->where(function ($q) use ($request) {
$q->where('warehouse_from_id', $request->warehouse_id)
->orWhere('warehouse_to_id', $request->warehouse_id);
});
}
if ($request->has('from_date')) {
$query->whereDate('created_at', '>=', $request->from_date);
}
if ($request->has('to_date')) {
$query->whereDate('created_at', '<=', $request->to_date);
}
$movements = $query->paginate($request->get('per_page', 15));
return ApiResponse::OK->response(['movements' => $movements]);
} }
public function store(Request $request) /**
* Ver detalle de un movimiento
*/
public function show(int $id)
{ {
// $movement = InventoryMovement::with(['inventory', 'warehouseFrom', 'warehouseTo', 'user'])
->find($id);
if (!$movement) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Movimiento no encontrado'
]);
}
return ApiResponse::OK->response([
'movement' => $movement
]);
} }
public function update(Request $request, $id) /**
* Registrar entrada de inventario
*/
public function entry(InventoryEntryRequest $request)
{ {
// try {
$movement = $this->movementService->entry($request->validated());
return ApiResponse::CREATED->response([
'message' => 'Entrada registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseTo']),
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
} }
public function inventory() /**
* Registrar salida de inventario
*/
public function exit(InventoryExitRequest $request)
{ {
// try {
$movement = $this->movementService->exit($request->validated());
return ApiResponse::CREATED->response([
'message' => 'Salida registrada correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom']),
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
/**
* Registrar traspaso entre almacenes
*/
public function transfer(InventoryTransferRequest $request)
{
try {
$movement = $this->movementService->transfer($request->validated());
return ApiResponse::CREATED->response([
'message' => 'Traspaso registrado correctamente',
'movement' => $movement->load(['inventory', 'warehouseFrom', 'warehouseTo']),
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
} }
} }

View File

@ -6,6 +6,7 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Inventory; use App\Models\Inventory;
use App\Models\InventorySerial; use App\Models\InventorySerial;
use App\Services\InventoryMovementService;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse; use Notsoweb\ApiResponse\Enums\ApiResponse;
@ -81,11 +82,15 @@ public function store(Inventory $inventario, Request $request)
{ {
$request->validate([ $request->validate([
'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'], 'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'],
'warehouse_id' => ['nullable', 'exists:warehouses,id'],
'notes' => ['nullable', 'string'], 'notes' => ['nullable', 'string'],
]); ]);
$warehouseId = $request->warehouse_id ?? app(InventoryMovementService::class)->getMainWarehouseId();
$serial = InventorySerial::create([ $serial = InventorySerial::create([
'inventory_id' => $inventario->id, 'inventory_id' => $inventario->id,
'warehouse_id' => $warehouseId,
'serial_number' => $request->serial_number, 'serial_number' => $request->serial_number,
'status' => 'disponible', 'status' => 'disponible',
'notes' => $request->notes, 'notes' => $request->notes,

View File

@ -1,18 +1,130 @@
<?php namespace App\Http\Controllers; <?php
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\WarehouseStoreRequest;
use App\Http\Requests\App\WarehouseUpdateRequest;
use App\Models\Warehouse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/** /**
* Descripción * Controlador para gestión de almacenes
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/ */
class WarehouseController extends Controller class WarehouseController extends Controller
{ {
// /**
* Listar almacenes
*/
public function index(Request $request)
{
$warehouse = Warehouse::query();
if ($request->has('active')) {
$warehouse->where('is_active', $request->boolean('active'));
}
if ($request->has('q') && $request->q) {
$warehouse->where(function($query) use ($request) {
$query->where('name', 'like', "%{$request->q}%")
->orWhere('code', 'like', "%{$request->q}%");
});
}
$warehouse->orderBy('id', 'ASC');
$warehouses = $request->boolean('all')
? $warehouse->get()
: $warehouse->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'warehouses' => $warehouses,
]);
}
/**
* Ver detalle de un almacén con su inventario
*/
public function show(int $id)
{
$warehouse = Warehouse::find($id);
if (!$warehouse) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Almacén no encontrado'
]);
}
$inventories = $warehouse->inventories()
->wherePivot('stock', '>', 0)
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'warehouse' => $warehouse,
'inventories' => $inventories,
]);
}
/**
* Crear almacén
*/
public function store(WarehouseStoreRequest $request)
{
$warehouse = Warehouse::create($request->validated());
return ApiResponse::CREATED->response([
'message' => 'Almacén creado correctamente',
'warehouse' => $warehouse,
]);
}
/**
* Actualizar almacén
*/
public function update(WarehouseUpdateRequest $request, int $id)
{
$warehouse = Warehouse::find($id);
if (!$warehouse) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Almacén no encontrado'
]);
}
$warehouse->update($request->validated());
return ApiResponse::OK->response([
'message' => 'Almacén actualizado correctamente',
'warehouse' => $warehouse,
]);
}
/**
* Eliminar almacén
*/
public function destroy(int $id)
{
$warehouse = Warehouse::find($id);
if (!$warehouse) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Almacén no encontrado'
]);
}
// Verificar si tiene stock
$hasStock = $warehouse->inventories()->wherePivot('stock', '>', 0)->exists();
if ($hasStock) {
return ApiResponse::BAD_REQUEST->response([
'message' => 'No se puede eliminar un almacén con stock'
]);
}
$warehouse->delete();
return ApiResponse::OK->response([
'message' => 'Almacén eliminado correctamente'
]);
}
} }

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InventoryEntryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'inventory_id' => 'required|exists:inventories,id',
'warehouse_id' => 'required|exists:warehouses,id',
'quantity' => 'required|integer|min:1',
'invoice_reference' => 'nullable|string|max:255',
'notes' => 'nullable|string|max:1000',
];
}
public function messages(): array
{
return [
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
'warehouse_id.required' => 'El almacén es requerido',
'warehouse_id.exists' => 'El almacén no existe',
'quantity.required' => 'La cantidad es requerida',
'quantity.min' => 'La cantidad debe ser al menos 1',
];
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InventoryExitRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'inventory_id' => 'required|exists:inventories,id',
'warehouse_id' => 'required|exists:warehouses,id',
'quantity' => 'required|integer|min:1',
'notes' => 'nullable|string|max:1000',
];
}
public function messages(): array
{
return [
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
'warehouse_id.required' => 'El almacén es requerido',
'warehouse_id.exists' => 'El almacén no existe',
'quantity.required' => 'La cantidad es requerida',
'quantity.min' => 'La cantidad debe ser al menos 1',
];
}
}

View File

@ -24,7 +24,6 @@ public function rules(): array
'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'], 'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'], 'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
'category_id' => ['required', 'exists:categories,id'], 'category_id' => ['required', 'exists:categories,id'],
'stock' => ['nullable', 'integer', 'min:0'],
'track_serials' => ['nullable', 'boolean'], 'track_serials' => ['nullable', 'boolean'],
// Campos de Price // Campos de Price
@ -48,8 +47,6 @@ public function messages(): array
'barcode.unique' => 'El código de barras ya está registrado en otro producto.', 'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
'category_id.required' => 'La categoría es obligatoria.', 'category_id.required' => 'La categoría es obligatoria.',
'category_id.exists' => 'La categoría seleccionada no es válida.', 'category_id.exists' => 'La categoría seleccionada no es válida.',
'stock.min' => 'El stock no puede ser negativo.',
// Mensajes de Price // Mensajes de Price
'cost.required' => 'El costo es obligatorio.', 'cost.required' => 'El costo es obligatorio.',
'cost.numeric' => 'El costo debe ser un número.', 'cost.numeric' => 'El costo debe ser un número.',

View File

@ -0,0 +1,39 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class InventoryTransferRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'inventory_id' => 'required|exists:inventories,id',
'warehouse_from_id' => 'required|exists:warehouses,id',
'warehouse_to_id' => 'required|exists:warehouses,id|different:warehouse_from_id',
'quantity' => 'required|integer|min:1',
'notes' => 'nullable|string|max:1000',
];
}
public function messages(): array
{
return [
'inventory_id.required' => 'El producto es requerido',
'inventory_id.exists' => 'El producto no existe',
'warehouse_from_id.required' => 'El almacén origen es requerido',
'warehouse_from_id.exists' => 'El almacén origen no existe',
'warehouse_to_id.required' => 'El almacén destino es requerido',
'warehouse_to_id.exists' => 'El almacén destino no existe',
'warehouse_to_id.different' => 'El almacén destino debe ser diferente al origen',
'quantity.required' => 'La cantidad es requerida',
'quantity.min' => 'La cantidad debe ser al menos 1',
];
}
}

View File

@ -26,7 +26,6 @@ public function rules(): array
'sku' => ['nullable', 'string', 'max:50'], 'sku' => ['nullable', 'string', 'max:50'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId], 'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
'category_id' => ['nullable', 'exists:categories,id'], 'category_id' => ['nullable', 'exists:categories,id'],
'stock' => ['nullable', 'integer', 'min:0'],
'track_serials' => ['nullable', 'boolean'], 'track_serials' => ['nullable', 'boolean'],
// Campos de Price // Campos de Price
@ -48,8 +47,6 @@ public function messages(): array
'barcode.string' => 'El código de barras debe ser una cadena de texto.', 'barcode.string' => 'El código de barras debe ser una cadena de texto.',
'barcode.unique' => 'El código de barras ya está registrado en otro producto.', 'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
'category_id.exists' => 'La categoría seleccionada no es válida.', 'category_id.exists' => 'La categoría seleccionada no es válida.',
'stock.min' => 'El stock no puede ser negativo.',
// Mensajes de Price // Mensajes de Price
'cost.numeric' => 'El costo debe ser un número.', 'cost.numeric' => 'El costo debe ser un número.',
'cost.min' => 'El costo no puede ser negativo.', 'cost.min' => 'El costo no puede ser negativo.',

View File

@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class WarehouseStoreRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => 'required|string|max:50|unique:warehouses,code',
'name' => 'required|string|max:255',
'is_active' => 'boolean',
'is_main' => 'boolean',
];
}
public function messages(): array
{
return [
'code.required' => 'El código es requerido',
'code.unique' => 'El código ya existe',
'name.required' => 'El nombre es requerido',
];
}
}

View File

@ -0,0 +1,36 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class WarehouseUpdateRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'code' => [
'sometimes',
'string',
'max:50',
Rule::unique('warehouses', 'code')->ignore($this->route('id')),
],
'name' => 'sometimes|string|max:255',
'is_active' => 'boolean',
'is_main' => 'boolean',
];
}
public function messages(): array
{
return [
'code.unique' => 'El código ya existe',
];
}
}

View File

@ -7,6 +7,7 @@
use App\Models\Category; use App\Models\Category;
use App\Http\Requests\App\InventoryImportRequest; use App\Http\Requests\App\InventoryImportRequest;
use App\Models\InventorySerial; use App\Models\InventorySerial;
use App\Services\InventoryMovementService;
use Maatwebsite\Excel\Concerns\ToModel; use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation; use Maatwebsite\Excel\Concerns\WithValidation;
@ -35,6 +36,12 @@ class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChu
private $imported = 0; private $imported = 0;
private $updated = 0; private $updated = 0;
private $skipped = 0; private $skipped = 0;
private InventoryMovementService $movementService;
public function __construct()
{
$this->movementService = app(InventoryMovementService::class);
}
/** /**
* Mapea y transforma los datos de cada fila antes de la validación * Mapea y transforma los datos de cada fila antes de la validación
@ -99,13 +106,12 @@ public function model(array $row)
$categoryId = $category->id; $categoryId = $category->id;
} }
// Crear el producto en inventario // Crear el producto en inventario (sin stock, vive en inventory_warehouse)
$inventory = new Inventory(); $inventory = new Inventory();
$inventory->name = trim($row['nombre']); $inventory->name = trim($row['nombre']);
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null; $inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null; $inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
$inventory->category_id = $categoryId; $inventory->category_id = $categoryId;
$inventory->stock = 0;
$inventory->is_active = true; $inventory->is_active = true;
$inventory->save(); $inventory->save();
@ -137,6 +143,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
{ {
$serialsAdded = 0; $serialsAdded = 0;
$serialsSkipped = 0; $serialsSkipped = 0;
$mainWarehouseId = $this->movementService->getMainWarehouseId();
// Agregar seriales nuevos (ignorar duplicados) // Agregar seriales nuevos (ignorar duplicados)
if (!empty($row['numeros_serie'])) { if (!empty($row['numeros_serie'])) {
@ -152,6 +159,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
if (!$exists) { if (!$exists) {
InventorySerial::create([ InventorySerial::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial, 'serial_number' => $serial,
'status' => 'disponible', 'status' => 'disponible',
]); ]);
@ -164,9 +172,11 @@ private function updateExistingProduct(Inventory $inventory, array $row)
// Sincronizar stock basado en seriales disponibles // Sincronizar stock basado en seriales disponibles
$inventory->syncStock(); $inventory->syncStock();
} else { } else {
// Producto sin seriales: sumar stock // Producto sin seriales: sumar stock en almacén principal
$stockToAdd = (int) ($row['stock'] ?? 0); $stockToAdd = (int) ($row['stock'] ?? 0);
$inventory->increment('stock', $stockToAdd); if ($stockToAdd > 0) {
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockToAdd);
}
} }
$this->updated++; $this->updated++;
@ -183,6 +193,8 @@ private function updateExistingProduct(Inventory $inventory, array $row)
*/ */
private function addSerials(Inventory $inventory, ?string $serialsString, int $stockFromExcel) private function addSerials(Inventory $inventory, ?string $serialsString, int $stockFromExcel)
{ {
$mainWarehouseId = $this->movementService->getMainWarehouseId();
if (!empty($serialsString)) { if (!empty($serialsString)) {
$serials = explode(',', $serialsString); $serials = explode(',', $serialsString);
@ -191,6 +203,7 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
if (!empty($serial)) { if (!empty($serial)) {
InventorySerial::create([ InventorySerial::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial, 'serial_number' => $serial,
'status' => 'disponible', 'status' => 'disponible',
]); ]);
@ -198,9 +211,10 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
} }
$inventory->syncStock(); $inventory->syncStock();
} else { } else {
// Producto sin seriales // Producto sin seriales: registrar stock en almacén principal
$inventory->stock = $stockFromExcel; if ($stockFromExcel > 0) {
$inventory->save(); $this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockFromExcel);
}
} }
} }

View File

@ -4,10 +4,13 @@
*/ */
use App\Services\InventoryMovementService;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/** /**
* Descripción * Modelo de Inventario (Catálogo de productos)
*
* El stock NO vive aquí, vive en inventory_warehouse
* *
* @author Moisés Cortés C. <moises.cortes@notsoweb.com> * @author Moisés Cortés C. <moises.cortes@notsoweb.com>
* *
@ -20,7 +23,6 @@ class Inventory extends Model
'name', 'name',
'sku', 'sku',
'barcode', 'barcode',
'stock',
'track_serials', 'track_serials',
'is_active', 'is_active',
]; ];
@ -30,24 +32,40 @@ class Inventory extends Model
'track_serials' => 'boolean', 'track_serials' => 'boolean',
]; ];
protected $appends = ['has_serials', 'inventory_value']; protected $appends = ['has_serials', 'inventory_value', 'stock'];
public function warehouses() { public function warehouses()
{
return $this->belongsToMany(Warehouse::class, 'inventory_warehouse') return $this->belongsToMany(Warehouse::class, 'inventory_warehouse')
->withPivot('stock', 'min_stock', 'max_stock') ->withPivot('stock', 'min_stock', 'max_stock')
->withTimestamps(); ->withTimestamps();
} }
// Obtener stock total en todos los almacenes /**
public function getTotalStockAttribute(): int { * Stock total en todos los almacenes
*/
public function getStockAttribute(): int
{
return $this->warehouses()->sum('inventory_warehouse.stock'); return $this->warehouses()->sum('inventory_warehouse.stock');
} }
// Sincronizar stock global /**
public function syncGlobalStock(): void { * Alias para compatibilidad
$this->update(['stock' => $this->total_stock]); */
public function getTotalStockAttribute(): int
{
return $this->stock;
} }
/**
* Stock en un almacén específico
*/
public function stockInWarehouse(int $warehouseId): int
{
return $this->warehouses()
->where('warehouse_id', $warehouseId)
->value('inventory_warehouse.stock') ?? 0;
}
public function category() public function category()
{ {
@ -74,33 +92,32 @@ public function availableSerials()
} }
/** /**
* Calcular stock basado en seriales disponibles * Stock basado en seriales disponibles (para productos con track_serials)
*/ */
public function getAvailableStockAttribute(): int public function getAvailableStockAttribute(): int
{ {
return $this->availableSerials()->count(); return $this->availableSerials()->count();
} }
/**
* Sincronizar el campo stock con los seriales disponibles
*/
public function syncStock(): void
{
if($this->track_serials) {
$this->update(['stock' => $this->getAvailableStockAttribute()]);
}
}
public function getHasSerialsAttribute(): bool public function getHasSerialsAttribute(): bool
{ {
return isset($this->attributes['serials_count']) && $this->attributes['serials_count'] > 0; return isset($this->attributes['serials_count']) && $this->attributes['serials_count'] > 0;
} }
/** /**
* Calcular el valor total del inventario para este producto (stock * costo) * Valor total del inventario (stock * costo)
*/ */
public function getInventoryValueAttribute(): float public function getInventoryValueAttribute(): float
{ {
return $this->total_stock * ($this->price?->cost ?? 0); return $this->stock * ($this->price?->cost ?? 0);
}
/**
* Sincronizar stock basado en seriales disponibles
* Delega al servicio para mantener la lógica centralizada
*/
public function syncStock(): void
{
app(InventoryMovementService::class)->syncStockFromSerials($this);
} }
} }

View File

@ -16,6 +16,7 @@ class InventoryMovement extends Model
'reference_id', 'reference_id',
'user_id', 'user_id',
'notes', 'notes',
'invoice_reference',
]; ];
protected $casts = [ protected $casts = [

View File

@ -8,6 +8,8 @@
/** /**
* Servicio para gestión de movimientos de inventario * Servicio para gestión de movimientos de inventario
*
* El stock vive en inventory_warehouse, no en inventories
*/ */
class InventoryMovementService class InventoryMovementService
{ {
@ -24,26 +26,22 @@ public function entry(array $data): InventoryMovement
// Actualizar stock en inventory_warehouse // Actualizar stock en inventory_warehouse
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
// Sincronizar stock global
$inventory->syncGlobalStock();
// Registrar movimiento // Registrar movimiento
return InventoryMovement::create([ return InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => null, // Entrada externa 'warehouse_from_id' => null,
'warehouse_to_id' => $warehouse->id, 'warehouse_to_id' => $warehouse->id,
'movement_type' => $data['movement_type'] ?? 'entry', 'movement_type' => 'entry',
'quantity' => $quantity, 'quantity' => $quantity,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
'reference_type' => $data['reference_type'] ?? null, 'invoice_reference' => $data['invoice_reference'] ?? null,
'reference_id' => $data['reference_id'] ?? null,
]); ]);
}); });
} }
/** /**
* Salida de inventario (merma, ajuste negativo, robo) * Salida de inventario (merma, ajuste negativo, robo, daño)
*/ */
public function exit(array $data): InventoryMovement public function exit(array $data): InventoryMovement
{ {
@ -58,20 +56,15 @@ public function exit(array $data): InventoryMovement
// Decrementar stock en inventory_warehouse // Decrementar stock en inventory_warehouse
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity); $this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
// Sincronizar stock global
$inventory->syncGlobalStock();
// Registrar movimiento // Registrar movimiento
return InventoryMovement::create([ return InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouse->id, 'warehouse_from_id' => $warehouse->id,
'warehouse_to_id' => null, // Salida externa 'warehouse_to_id' => null,
'movement_type' => $data['movement_type'] ?? 'exit', 'movement_type' => 'exit',
'quantity' => $quantity, 'quantity' => $quantity,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null, 'notes' => $data['notes'] ?? null,
'reference_type' => $data['reference_type'] ?? null,
'reference_id' => $data['reference_id'] ?? null,
]); ]);
}); });
} }
@ -101,8 +94,6 @@ public function transfer(array $data): InventoryMovement
// Incrementar en destino // Incrementar en destino
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity); $this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
// Stock global no cambia, no es necesario sincronizar
// Registrar movimiento // Registrar movimiento
return InventoryMovement::create([ return InventoryMovement::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
@ -119,7 +110,7 @@ public function transfer(array $data): InventoryMovement
/** /**
* Actualizar stock en inventory_warehouse * Actualizar stock en inventory_warehouse
*/ */
protected function updateWarehouseStock(int $inventoryId, int $warehouseId, int $quantityChange): void public function updateWarehouseStock(int $inventoryId, int $warehouseId, int $quantityChange): void
{ {
$record = InventoryWarehouse::firstOrCreate( $record = InventoryWarehouse::firstOrCreate(
[ [
@ -131,7 +122,6 @@ protected function updateWarehouseStock(int $inventoryId, int $warehouseId, int
$newStock = $record->stock + $quantityChange; $newStock = $record->stock + $quantityChange;
// No permitir stock negativo
if ($newStock < 0) { if ($newStock < 0) {
throw new \Exception('Stock insuficiente en el almacén.'); throw new \Exception('Stock insuficiente en el almacén.');
} }
@ -142,7 +132,7 @@ protected function updateWarehouseStock(int $inventoryId, int $warehouseId, int
/** /**
* Validar stock disponible * Validar stock disponible
*/ */
protected function validateStock(int $inventoryId, int $warehouseId, int $requiredQuantity): void public function validateStock(int $inventoryId, int $warehouseId, int $requiredQuantity): void
{ {
$record = InventoryWarehouse::where('inventory_id', $inventoryId) $record = InventoryWarehouse::where('inventory_id', $inventoryId)
->where('warehouse_id', $warehouseId) ->where('warehouse_id', $warehouseId)
@ -188,4 +178,53 @@ public function recordReturn(int $inventoryId, int $warehouseId, int $quantity,
'user_id' => auth()->id(), 'user_id' => auth()->id(),
]); ]);
} }
/**
* Obtener el almacén principal
*/
public function getMainWarehouseId(): int
{
$warehouse = Warehouse::where('is_main', true)->first();
if (!$warehouse) {
throw new \Exception('No existe un almacén principal configurado.');
}
return $warehouse->id;
}
/**
* Sincronizar stock en inventory_warehouse basado en seriales disponibles
* Solo aplica para productos con track_serials = true
*/
public function syncStockFromSerials(Inventory $inventory): void
{
if (!$inventory->track_serials) {
return;
}
// Contar seriales disponibles por almacén
$stockByWarehouse = $inventory->serials()
->where('status', 'disponible')
->whereNotNull('warehouse_id')
->selectRaw('warehouse_id, COUNT(*) as total')
->groupBy('warehouse_id')
->pluck('total', 'warehouse_id');
// Actualizar stock en cada almacén
foreach ($stockByWarehouse as $warehouseId => $count) {
InventoryWarehouse::updateOrCreate(
[
'inventory_id' => $inventory->id,
'warehouse_id' => $warehouseId,
],
['stock' => $count]
);
}
// Poner en 0 los almacenes que ya no tienen seriales disponibles
InventoryWarehouse::where('inventory_id', $inventory->id)
->whereNotIn('warehouse_id', $stockByWarehouse->keys())
->update(['stock' => 0]);
}
} }

View File

@ -14,11 +14,10 @@ public function createProduct(array $data)
'sku' => $data['sku'], 'sku' => $data['sku'],
'barcode' => $data['barcode'] ?? null, 'barcode' => $data['barcode'] ?? null,
'category_id' => $data['category_id'], 'category_id' => $data['category_id'],
'stock' => $data['stock'] ?? 0,
'track_serials' => $data['track_serials'] ?? false, 'track_serials' => $data['track_serials'] ?? false,
]); ]);
$price = Price::create([ Price::create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'cost' => $data['cost'], 'cost' => $data['cost'],
'retail_price' => $data['retail_price'], 'retail_price' => $data['retail_price'],
@ -38,7 +37,6 @@ public function updateProduct(Inventory $inventory, array $data)
'sku' => $data['sku'] ?? null, 'sku' => $data['sku'] ?? null,
'barcode' => $data['barcode'] ?? null, 'barcode' => $data['barcode'] ?? null,
'category_id' => $data['category_id'] ?? null, 'category_id' => $data['category_id'] ?? null,
'stock' => $data['stock'] ?? null,
'track_serials' => $data['track_serials'] ?? null, 'track_serials' => $data['track_serials'] ?? null,
], fn($value) => $value !== null); ], fn($value) => $value !== null);

View File

@ -13,12 +13,10 @@
class ReturnService class ReturnService
{ {
protected ClientTierService $clientTierService; public function __construct(
protected ClientTierService $clientTierService,
public function __construct(ClientTierService $clientTierService) protected InventoryMovementService $movementService
{ ) {}
$this->clientTierService = $clientTierService;
}
/** /**
* Crear una nueva devolución con sus detalles * Crear una nueva devolución con sus detalles
*/ */
@ -165,7 +163,9 @@ public function createReturn(array $data): Returns
// Sincronizar el stock del inventario // Sincronizar el stock del inventario
$saleDetail->inventory->syncStock(); $saleDetail->inventory->syncStock();
} else { } else {
$inventory->increment('stock', $item['quantity_returned']); // Restaurar stock en el almacén
$warehouseId = $saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId();
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, $item['quantity_returned']);
} }
} }
@ -230,8 +230,9 @@ public function cancelReturn(Returns $return): Returns
// Sincronizar stock // Sincronizar stock
$detail->inventory->syncStock(); $detail->inventory->syncStock();
} else { } else {
// Revertir stock numérico (la devolución lo había incrementado) // Revertir stock (la devolución lo había incrementado)
$detail->inventory->decrement('stock', $detail->quantity_returned); $warehouseId = $detail->saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId();
$this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, -$detail->quantity_returned);
} }
} }

View File

@ -10,12 +10,10 @@
class SaleService class SaleService
{ {
protected ClientTierService $clientTierService; public function __construct(
protected ClientTierService $clientTierService,
public function __construct(ClientTierService $clientTierService) protected InventoryMovementService $movementService
{ ) {}
$this->clientTierService = $clientTierService;
}
/** /**
* Crear una nueva venta con sus detalles * Crear una nueva venta con sus detalles
* *
@ -131,11 +129,11 @@ public function createSale(array $data)
// Sincronizar el stock // Sincronizar el stock
$inventory->syncStock(); $inventory->syncStock();
} else { } else {
if ($inventory->stock < $item['quantity']) { // Obtener almacén (del item o el principal)
throw new \Exception("Stock insuficiente para {$item['product_name']}"); $warehouseId = $item['warehouse_id'] ?? $this->movementService->getMainWarehouseId();
}
$inventory->decrement('stock', $item['quantity']); $this->movementService->validateStock($inventory->id, $warehouseId, $item['quantity']);
$this->movementService->updateWarehouseStock($inventory->id, $warehouseId, -$item['quantity']);
} }
} }
@ -184,8 +182,9 @@ public function cancelSale(Sale $sale)
} }
$detail->inventory->syncStock(); $detail->inventory->syncStock();
} else { } else {
// Restaurar stock numérico // Restaurar stock en el almacén
$detail->inventory->increment('stock', $detail->quantity); $warehouseId = $detail->warehouse_id ?? $this->movementService->getMainWarehouseId();
$this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, $detail->quantity);
} }
} }

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// Agregar invoice_reference a inventory_movements
Schema::table('inventory_movements', function (Blueprint $table) {
$table->string('invoice_reference')->nullable()->after('notes');
});
// Eliminar stock de inventories (ahora vive en inventory_warehouse)
Schema::table('inventories', function (Blueprint $table) {
$table->dropColumn('stock');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('inventory_movements', function (Blueprint $table) {
$table->dropColumn('invoice_reference');
});
Schema::table('inventories', function (Blueprint $table) {
$table->integer('stock')->default(0)->after('sku');
});
}
};

View File

@ -141,6 +141,23 @@ public function run(): void
$invoiceRequestUpload = $this->onPermission('invoice-requests.upload', 'Subir archivos de factura', $invoiceRequestsType, 'api'); $invoiceRequestUpload = $this->onPermission('invoice-requests.upload', 'Subir archivos de factura', $invoiceRequestsType, 'api');
$invoiceRequestStats = $this->onPermission('invoice-requests.stats', 'Ver estadísticas', $invoiceRequestsType, 'api'); $invoiceRequestStats = $this->onPermission('invoice-requests.stats', 'Ver estadísticas', $invoiceRequestsType, 'api');
$warehouseType = PermissionType::firstOrCreate([
'name' => 'Almacenes'
]);
$warehouseIndex = $this->onIndex('warehouses', 'Mostrar datos', $warehouseType, 'api');
$warehouseCreate = $this->onCreate('warehouses', 'Crear registros', $warehouseType, 'api');
$warehouseEdit = $this->onEdit('warehouses', 'Actualizar registro', $warehouseType, 'api');
$warehouseDestroy = $this->onDestroy('warehouses', 'Eliminar registro', $warehouseType, 'api');
$movementsType = PermissionType::firstOrCreate([
'name' => 'Movimientos de inventario'
]);
$movementsIndex = $this->onIndex('movements', 'Mostrar datos', $movementsType, 'api');
$movementsCreate = $this->onCreate('movements', 'Crear registros', $movementsType, 'api');
$movementsEdit = $this->onEdit('movements', 'Actualizar registro', $movementsType, 'api');
$movementsDestroy = $this->onDestroy('movements', 'Eliminar registro', $movementsType, 'api');
// ==================== ROLES ==================== // ==================== ROLES ====================
@ -195,7 +212,15 @@ public function run(): void
$invoiceRequestProcess, $invoiceRequestProcess,
$invoiceRequestReject, $invoiceRequestReject,
$invoiceRequestUpload, $invoiceRequestUpload,
$invoiceRequestStats $invoiceRequestStats,
$warehouseIndex,
$warehouseCreate,
$warehouseEdit,
$warehouseDestroy,
$movementsIndex,
$movementsCreate,
$movementsEdit,
$movementsDestroy
); );
//Operador PDV (solo permisos de operación de caja y ventas) //Operador PDV (solo permisos de operación de caja y ventas)

View File

@ -13,6 +13,8 @@
use App\Http\Controllers\App\InvoiceController; use App\Http\Controllers\App\InvoiceController;
use App\Http\Controllers\App\InventorySerialController; use App\Http\Controllers\App\InventorySerialController;
use App\Http\Controllers\App\InvoiceRequestController; use App\Http\Controllers\App\InvoiceRequestController;
use App\Http\Controllers\App\InventoryMovementController;
use App\Http\Controllers\App\WarehouseController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/** /**
@ -42,6 +44,18 @@
Route::resource('inventario.serials', InventorySerialController::class); Route::resource('inventario.serials', InventorySerialController::class);
Route::get('serials/search', [InventorySerialController::class, 'search']); Route::get('serials/search', [InventorySerialController::class, 'search']);
// ALMACENES
Route::resource('almacenes', WarehouseController::class)->except(['create', 'edit']);
// MOVIMIENTOS DE INVENTARIO
Route::prefix('movimientos')->group(function () {
Route::get('/', [InventoryMovementController::class, 'index']);
Route::get('/{id}', [InventoryMovementController::class, 'show']);
Route::post('/entrada', [InventoryMovementController::class, 'entry']);
Route::post('/salida', [InventoryMovementController::class, 'exit']);
Route::post('/traspaso', [InventoryMovementController::class, 'transfer']);
});
//CATEGORIAS //CATEGORIAS
Route::resource('categorias', CategoryController::class); Route::resource('categorias', CategoryController::class);