diff --git a/app/Http/Controllers/App/ExcelController.php b/app/Http/Controllers/App/ExcelController.php index 484335a..7f720c6 100644 --- a/app/Http/Controllers/App/ExcelController.php +++ b/app/Http/Controllers/App/ExcelController.php @@ -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(); diff --git a/app/Http/Controllers/App/InventoryController.php b/app/Http/Controllers/App/InventoryController.php index 94c4967..ce07967 100644 --- a/app/Http/Controllers/App/InventoryController.php +++ b/app/Http/Controllers/App/InventoryController.php @@ -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')); diff --git a/app/Http/Controllers/App/InventoryMovementController.php b/app/Http/Controllers/App/InventoryMovementController.php index 0250e6d..757140d 100644 --- a/app/Http/Controllers/App/InventoryMovementController.php +++ b/app/Http/Controllers/App/InventoryMovementController.php @@ -1,37 +1,135 @@ - * * @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); + } + + 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() + ]); + } } } diff --git a/app/Http/Controllers/App/InventorySerialController.php b/app/Http/Controllers/App/InventorySerialController.php index b892e28..e97743c 100644 --- a/app/Http/Controllers/App/InventorySerialController.php +++ b/app/Http/Controllers/App/InventorySerialController.php @@ -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, diff --git a/app/Http/Controllers/App/WarehouseController.php b/app/Http/Controllers/App/WarehouseController.php index 6a0b941..b62409d 100644 --- a/app/Http/Controllers/App/WarehouseController.php +++ b/app/Http/Controllers/App/WarehouseController.php @@ -1,18 +1,130 @@ - - * - * @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' + ]); + } } diff --git a/app/Http/Requests/App/InventoryEntryRequest.php b/app/Http/Requests/App/InventoryEntryRequest.php new file mode 100644 index 0000000..7826ffb --- /dev/null +++ b/app/Http/Requests/App/InventoryEntryRequest.php @@ -0,0 +1,36 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/App/InventoryExitRequest.php b/app/Http/Requests/App/InventoryExitRequest.php new file mode 100644 index 0000000..a62a463 --- /dev/null +++ b/app/Http/Requests/App/InventoryExitRequest.php @@ -0,0 +1,35 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/App/InventoryStoreRequest.php b/app/Http/Requests/App/InventoryStoreRequest.php index 63fd6d1..8220098 100644 --- a/app/Http/Requests/App/InventoryStoreRequest.php +++ b/app/Http/Requests/App/InventoryStoreRequest.php @@ -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.', diff --git a/app/Http/Requests/App/InventoryTransferRequest.php b/app/Http/Requests/App/InventoryTransferRequest.php new file mode 100644 index 0000000..ea46b52 --- /dev/null +++ b/app/Http/Requests/App/InventoryTransferRequest.php @@ -0,0 +1,39 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/App/InventoryUpdateRequest.php b/app/Http/Requests/App/InventoryUpdateRequest.php index 87d8e1a..b053748 100644 --- a/app/Http/Requests/App/InventoryUpdateRequest.php +++ b/app/Http/Requests/App/InventoryUpdateRequest.php @@ -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.', diff --git a/app/Http/Requests/App/WarehouseStoreRequest.php b/app/Http/Requests/App/WarehouseStoreRequest.php new file mode 100644 index 0000000..7cb7a6a --- /dev/null +++ b/app/Http/Requests/App/WarehouseStoreRequest.php @@ -0,0 +1,32 @@ + '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', + ]; + } +} diff --git a/app/Http/Requests/App/WarehouseUpdateRequest.php b/app/Http/Requests/App/WarehouseUpdateRequest.php new file mode 100644 index 0000000..0368899 --- /dev/null +++ b/app/Http/Requests/App/WarehouseUpdateRequest.php @@ -0,0 +1,36 @@ + [ + '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', + ]; + } +} diff --git a/app/Imports/ProductsImport.php b/app/Imports/ProductsImport.php index bde158a..de148e3 100644 --- a/app/Imports/ProductsImport.php +++ b/app/Imports/ProductsImport.php @@ -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); + } } } diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index 50e27e0..29226d7 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -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. * @@ -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); } } diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php index c84c1b0..ff84638 100644 --- a/app/Models/InventoryMovement.php +++ b/app/Models/InventoryMovement.php @@ -16,6 +16,7 @@ class InventoryMovement extends Model 'reference_id', 'user_id', 'notes', + 'invoice_reference', ]; protected $casts = [ diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php index 592b469..3ba41be 100644 --- a/app/Services/InventoryMovementService.php +++ b/app/Services/InventoryMovementService.php @@ -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]); + } } diff --git a/app/Services/ProductService.php b/app/Services/ProductService.php index 908c67b..4ac362c 100644 --- a/app/Services/ProductService.php +++ b/app/Services/ProductService.php @@ -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); diff --git a/app/Services/ReturnService.php b/app/Services/ReturnService.php index 2c66766..a92f5cc 100644 --- a/app/Services/ReturnService.php +++ b/app/Services/ReturnService.php @@ -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); } } diff --git a/app/Services/SaleService.php b/app/Services/SaleService.php index e0d7ecc..6d1dbe3 100644 --- a/app/Services/SaleService.php +++ b/app/Services/SaleService.php @@ -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); } } diff --git a/database/migrations/2026_02_05_211236_add_invoice_reference_to_inventory_movements_table.php b/database/migrations/2026_02_05_211236_add_invoice_reference_to_inventory_movements_table.php new file mode 100644 index 0000000..6b819ca --- /dev/null +++ b/database/migrations/2026_02_05_211236_add_invoice_reference_to_inventory_movements_table.php @@ -0,0 +1,38 @@ +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'); + }); + } +}; diff --git a/database/seeders/RoleSeeder.php b/database/seeders/RoleSeeder.php index 78a6761..d360549 100644 --- a/database/seeders/RoleSeeder.php +++ b/database/seeders/RoleSeeder.php @@ -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) diff --git a/routes/api.php b/routes/api.php index f929658..48b0e2c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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);