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);
})
->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();

View File

@ -43,9 +43,11 @@ public function index(Request $request)
}
// 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)
->sum(DB::raw('inventories.stock * prices.cost'));
->sum(DB::raw('inventory_warehouse.stock * prices.cost'));
$products = $products->orderBy('name')
->paginate(config('app.pagination'));

View File

@ -1,37 +1,135 @@
<?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\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 Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Descripción
* Controlador para movimientos de inventario
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class InventoryMovementController extends Controller
{
public function __construct(
protected InventoryMovementService $movementService
) {}
/**
* Listar movimientos con filtros
*/
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);
}
public function store(Request $request)
{
//
if ($request->has('inventory_id')) {
$query->where('inventory_id', $request->inventory_id);
}
public function update(Request $request, $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);
});
}
public function inventory()
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]);
}
/**
* 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
]);
}
/**
* 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()
]);
}
}
/**
* 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\Models\Inventory;
use App\Models\InventorySerial;
use App\Services\InventoryMovementService;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
@ -81,11 +82,15 @@ public function store(Inventory $inventario, Request $request)
{
$request->validate([
'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'],
'warehouse_id' => ['nullable', 'exists:warehouses,id'],
'notes' => ['nullable', 'string'],
]);
$warehouseId = $request->warehouse_id ?? app(InventoryMovementService::class)->getMainWarehouseId();
$serial = InventorySerial::create([
'inventory_id' => $inventario->id,
'warehouse_id' => $warehouseId,
'serial_number' => $request->serial_number,
'status' => 'disponible',
'notes' => $request->notes,

View File

@ -1,18 +1,130 @@
<?php namespace App\Http\Controllers;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
<?php
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 Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
* Controlador para gestión de almacenes
*/
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'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
'category_id' => ['required', 'exists:categories,id'],
'stock' => ['nullable', 'integer', 'min:0'],
'track_serials' => ['nullable', 'boolean'],
// Campos de Price
@ -48,8 +47,6 @@ public function messages(): array
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
'category_id.required' => 'La categoría es obligatoria.',
'category_id.exists' => 'La categoría seleccionada no es válida.',
'stock.min' => 'El stock no puede ser negativo.',
// Mensajes de Price
'cost.required' => 'El costo es obligatorio.',
'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'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
'category_id' => ['nullable', 'exists:categories,id'],
'stock' => ['nullable', 'integer', 'min:0'],
'track_serials' => ['nullable', 'boolean'],
// 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.unique' => 'El código de barras ya está registrado en otro producto.',
'category_id.exists' => 'La categoría seleccionada no es válida.',
'stock.min' => 'El stock no puede ser negativo.',
// Mensajes de Price
'cost.numeric' => 'El costo debe ser un número.',
'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\Http\Requests\App\InventoryImportRequest;
use App\Models\InventorySerial;
use App\Services\InventoryMovementService;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
@ -35,6 +36,12 @@ class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChu
private $imported = 0;
private $updated = 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
@ -99,13 +106,12 @@ public function model(array $row)
$categoryId = $category->id;
}
// Crear el producto en inventario
// Crear el producto en inventario (sin stock, vive en inventory_warehouse)
$inventory = new Inventory();
$inventory->name = trim($row['nombre']);
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
$inventory->category_id = $categoryId;
$inventory->stock = 0;
$inventory->is_active = true;
$inventory->save();
@ -137,6 +143,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
{
$serialsAdded = 0;
$serialsSkipped = 0;
$mainWarehouseId = $this->movementService->getMainWarehouseId();
// Agregar seriales nuevos (ignorar duplicados)
if (!empty($row['numeros_serie'])) {
@ -152,6 +159,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
if (!$exists) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial,
'status' => 'disponible',
]);
@ -164,9 +172,11 @@ private function updateExistingProduct(Inventory $inventory, array $row)
// Sincronizar stock basado en seriales disponibles
$inventory->syncStock();
} else {
// Producto sin seriales: sumar stock
// Producto sin seriales: sumar stock en almacén principal
$stockToAdd = (int) ($row['stock'] ?? 0);
$inventory->increment('stock', $stockToAdd);
if ($stockToAdd > 0) {
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockToAdd);
}
}
$this->updated++;
@ -183,6 +193,8 @@ private function updateExistingProduct(Inventory $inventory, array $row)
*/
private function addSerials(Inventory $inventory, ?string $serialsString, int $stockFromExcel)
{
$mainWarehouseId = $this->movementService->getMainWarehouseId();
if (!empty($serialsString)) {
$serials = explode(',', $serialsString);
@ -191,6 +203,7 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
if (!empty($serial)) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'warehouse_id' => $mainWarehouseId,
'serial_number' => $serial,
'status' => 'disponible',
]);
@ -198,9 +211,10 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
}
$inventory->syncStock();
} else {
// Producto sin seriales
$inventory->stock = $stockFromExcel;
$inventory->save();
// Producto sin seriales: registrar stock en almacén principal
if ($stockFromExcel > 0) {
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockFromExcel);
}
}
}

View File

@ -4,10 +4,13 @@
*/
use App\Services\InventoryMovementService;
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>
*
@ -20,7 +23,6 @@ class Inventory extends Model
'name',
'sku',
'barcode',
'stock',
'track_serials',
'is_active',
];
@ -30,24 +32,40 @@ class Inventory extends Model
'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')
->withPivot('stock', 'min_stock', 'max_stock')
->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');
}
// Sincronizar stock global
public function syncGlobalStock(): void {
$this->update(['stock' => $this->total_stock]);
/**
* Alias para compatibilidad
*/
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()
{
@ -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
{
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
{
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
{
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',
'user_id',
'notes',
'invoice_reference',
];
protected $casts = [

View File

@ -8,6 +8,8 @@
/**
* Servicio para gestión de movimientos de inventario
*
* El stock vive en inventory_warehouse, no en inventories
*/
class InventoryMovementService
{
@ -24,26 +26,22 @@ public function entry(array $data): InventoryMovement
// Actualizar stock en inventory_warehouse
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
// Sincronizar stock global
$inventory->syncGlobalStock();
// Registrar movimiento
return InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => null, // Entrada externa
'warehouse_from_id' => null,
'warehouse_to_id' => $warehouse->id,
'movement_type' => $data['movement_type'] ?? 'entry',
'movement_type' => 'entry',
'quantity' => $quantity,
'user_id' => auth()->id(),
'notes' => $data['notes'] ?? null,
'reference_type' => $data['reference_type'] ?? null,
'reference_id' => $data['reference_id'] ?? null,
'invoice_reference' => $data['invoice_reference'] ?? null,
]);
});
}
/**
* Salida de inventario (merma, ajuste negativo, robo)
* Salida de inventario (merma, ajuste negativo, robo, daño)
*/
public function exit(array $data): InventoryMovement
{
@ -58,20 +56,15 @@ public function exit(array $data): InventoryMovement
// Decrementar stock en inventory_warehouse
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
// Sincronizar stock global
$inventory->syncGlobalStock();
// Registrar movimiento
return InventoryMovement::create([
'inventory_id' => $inventory->id,
'warehouse_from_id' => $warehouse->id,
'warehouse_to_id' => null, // Salida externa
'movement_type' => $data['movement_type'] ?? 'exit',
'warehouse_to_id' => null,
'movement_type' => 'exit',
'quantity' => $quantity,
'user_id' => auth()->id(),
'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
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
// Stock global no cambia, no es necesario sincronizar
// Registrar movimiento
return InventoryMovement::create([
'inventory_id' => $inventory->id,
@ -119,7 +110,7 @@ public function transfer(array $data): InventoryMovement
/**
* 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(
[
@ -131,7 +122,6 @@ protected function updateWarehouseStock(int $inventoryId, int $warehouseId, int
$newStock = $record->stock + $quantityChange;
// No permitir stock negativo
if ($newStock < 0) {
throw new \Exception('Stock insuficiente en el almacén.');
}
@ -142,7 +132,7 @@ protected function updateWarehouseStock(int $inventoryId, int $warehouseId, int
/**
* 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)
->where('warehouse_id', $warehouseId)
@ -188,4 +178,53 @@ public function recordReturn(int $inventoryId, int $warehouseId, int $quantity,
'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'],
'barcode' => $data['barcode'] ?? null,
'category_id' => $data['category_id'],
'stock' => $data['stock'] ?? 0,
'track_serials' => $data['track_serials'] ?? false,
]);
$price = Price::create([
Price::create([
'inventory_id' => $inventory->id,
'cost' => $data['cost'],
'retail_price' => $data['retail_price'],
@ -38,7 +37,6 @@ public function updateProduct(Inventory $inventory, array $data)
'sku' => $data['sku'] ?? null,
'barcode' => $data['barcode'] ?? null,
'category_id' => $data['category_id'] ?? null,
'stock' => $data['stock'] ?? null,
'track_serials' => $data['track_serials'] ?? null,
], fn($value) => $value !== null);

View File

@ -13,12 +13,10 @@
class ReturnService
{
protected ClientTierService $clientTierService;
public function __construct(ClientTierService $clientTierService)
{
$this->clientTierService = $clientTierService;
}
public function __construct(
protected ClientTierService $clientTierService,
protected InventoryMovementService $movementService
) {}
/**
* Crear una nueva devolución con sus detalles
*/
@ -165,7 +163,9 @@ public function createReturn(array $data): Returns
// Sincronizar el stock del inventario
$saleDetail->inventory->syncStock();
} 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
$detail->inventory->syncStock();
} else {
// Revertir stock numérico (la devolución lo había incrementado)
$detail->inventory->decrement('stock', $detail->quantity_returned);
// Revertir stock (la devolución lo había incrementado)
$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
{
protected ClientTierService $clientTierService;
public function __construct(ClientTierService $clientTierService)
{
$this->clientTierService = $clientTierService;
}
public function __construct(
protected ClientTierService $clientTierService,
protected InventoryMovementService $movementService
) {}
/**
* Crear una nueva venta con sus detalles
*
@ -131,11 +129,11 @@ public function createSale(array $data)
// Sincronizar el stock
$inventory->syncStock();
} else {
if ($inventory->stock < $item['quantity']) {
throw new \Exception("Stock insuficiente para {$item['product_name']}");
}
// Obtener almacén (del item o el principal)
$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();
} else {
// Restaurar stock numérico
$detail->inventory->increment('stock', $detail->quantity);
// Restaurar stock en el almacén
$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');
$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 ====================
@ -195,7 +212,15 @@ public function run(): void
$invoiceRequestProcess,
$invoiceRequestReject,
$invoiceRequestUpload,
$invoiceRequestStats
$invoiceRequestStats,
$warehouseIndex,
$warehouseCreate,
$warehouseEdit,
$warehouseDestroy,
$movementsIndex,
$movementsCreate,
$movementsEdit,
$movementsDestroy
);
//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\InventorySerialController;
use App\Http\Controllers\App\InvoiceRequestController;
use App\Http\Controllers\App\InventoryMovementController;
use App\Http\Controllers\App\WarehouseController;
use Illuminate\Support\Facades\Route;
/**
@ -42,6 +44,18 @@
Route::resource('inventario.serials', InventorySerialController::class);
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
Route::resource('categorias', CategoryController::class);