diff --git a/app/Http/Controllers/App/InventoryMovementController.php b/app/Http/Controllers/App/InventoryMovementController.php new file mode 100644 index 0000000..0250e6d --- /dev/null +++ b/app/Http/Controllers/App/InventoryMovementController.php @@ -0,0 +1,37 @@ + + * + * @version 1.0.0 + */ +class InventoryMovementController extends Controller +{ + public function index(Request $request) + { + // + } + + public function store(Request $request) + { + // + } + + public function update(Request $request, $id) + { + // + } + + public function inventory() + { + // + } +} diff --git a/app/Http/Controllers/App/WarehouseController.php b/app/Http/Controllers/App/WarehouseController.php new file mode 100644 index 0000000..6a0b941 --- /dev/null +++ b/app/Http/Controllers/App/WarehouseController.php @@ -0,0 +1,18 @@ + + * + * @version 1.0.0 + */ +class WarehouseController extends Controller +{ + // +} diff --git a/app/Models/Inventory.php b/app/Models/Inventory.php index 3dcfe13..50e27e0 100644 --- a/app/Models/Inventory.php +++ b/app/Models/Inventory.php @@ -32,6 +32,23 @@ class Inventory extends Model protected $appends = ['has_serials', 'inventory_value']; + 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 { + return $this->warehouses()->sum('inventory_warehouse.stock'); + } + + // Sincronizar stock global + public function syncGlobalStock(): void { + $this->update(['stock' => $this->total_stock]); + } + + public function category() { return $this->belongsTo(Category::class); @@ -84,6 +101,6 @@ public function getHasSerialsAttribute(): bool */ public function getInventoryValueAttribute(): float { - return $this->stock * ($this->price?->cost ?? 0); + return $this->total_stock * ($this->price?->cost ?? 0); } } diff --git a/app/Models/InventoryMovement.php b/app/Models/InventoryMovement.php new file mode 100644 index 0000000..c84c1b0 --- /dev/null +++ b/app/Models/InventoryMovement.php @@ -0,0 +1,64 @@ + 'integer', + 'created_at' => 'datetime', + ]; + + // Relaciones + public function inventory() { + return $this->belongsTo(Inventory::class); + } + + public function warehouseFrom() { + return $this->belongsTo(Warehouse::class, 'warehouse_from_id'); + } + + public function warehouseTo() { + return $this->belongsTo(Warehouse::class, 'warehouse_to_id'); + } + + public function user() { + return $this->belongsTo(User::class); + } + + // Relación polimórfica para la referencia + public function reference() { + return $this->morphTo(); + } + + // Scopes + public function scopeByType($query, string $type) { + return $query->where('movement_type', $type); + } + + public function scopeEntry($query) { + return $query->where('movement_type', 'entry'); + } + + public function scopeExit($query) { + return $query->where('movement_type', 'exit'); + } + + public function scopeTransfer($query) { + return $query->where('movement_type', 'transfer'); + } +} diff --git a/app/Models/InventorySerial.php b/app/Models/InventorySerial.php index 7fef2ad..310120e 100644 --- a/app/Models/InventorySerial.php +++ b/app/Models/InventorySerial.php @@ -12,6 +12,7 @@ class InventorySerial extends Model { protected $fillable = [ 'inventory_id', + 'warehouse_id', 'serial_number', 'status', 'sale_detail_id', @@ -28,6 +29,11 @@ public function inventory() return $this->belongsTo(Inventory::class); } + public function warehouse() + { + return $this->belongsTo(Warehouse::class); + } + public function saleDetail() { return $this->belongsTo(SaleDetail::class); @@ -49,11 +55,11 @@ public function isAvailable(): bool /** * Marcar como vendido */ - public function markAsSold(int $saleDetailId): void - { + public function markAsSold(int $saleDetailId, ?int $warehouseId = null): void { $this->update([ 'status' => 'vendido', 'sale_detail_id' => $saleDetailId, + 'warehouse_id' => $warehouseId, ]); } diff --git a/app/Models/InventoryWarehouse.php b/app/Models/InventoryWarehouse.php new file mode 100644 index 0000000..a0ca484 --- /dev/null +++ b/app/Models/InventoryWarehouse.php @@ -0,0 +1,44 @@ + 'integer', + 'min_stock' => 'integer', + 'max_stock' => 'integer', + ]; + + // Relaciones + public function inventory() { + return $this->belongsTo(Inventory::class); + } + + public function warehouse() { + return $this->belongsTo(Warehouse::class); + } + + // Métodos útiles + public function isLowStock(): bool { + return $this->min_stock && $this->stock <= $this->min_stock; + } + + public function isOverStock(): bool { + return $this->max_stock && $this->stock >= $this->max_stock; + } + + public function hasAvailableStock(int $quantity): bool { + return $this->stock >= $quantity; + } +} diff --git a/app/Models/SaleDetail.php b/app/Models/SaleDetail.php index 79ec22c..2cbcb94 100644 --- a/app/Models/SaleDetail.php +++ b/app/Models/SaleDetail.php @@ -18,6 +18,7 @@ class SaleDetail extends Model protected $fillable = [ 'sale_id', 'inventory_id', + 'warehouse_id', 'product_name', 'quantity', 'unit_price', @@ -33,6 +34,10 @@ class SaleDetail extends Model 'discount_amount' => 'decimal:2', ]; + public function warehouse() { + return $this->belongsTo(Warehouse::class); + } + public function sale() { return $this->belongsTo(Sale::class); diff --git a/app/Models/Warehouse.php b/app/Models/Warehouse.php new file mode 100644 index 0000000..f3f4589 --- /dev/null +++ b/app/Models/Warehouse.php @@ -0,0 +1,61 @@ + + * + * @version 1.0.0 + */ +class Warehouse extends Model +{ + protected $fillable = ['code', 'name', 'is_active', 'is_main']; + + protected $casts = [ + 'is_active' => 'boolean', + 'is_main' => 'boolean', + ]; + + // Relaciones + public function inventories() + { + return $this->belongsToMany(Inventory::class, 'inventory_warehouse') + ->withPivot('stock', 'min_stock', 'max_stock') + ->withTimestamps(); + } + + public function inventorySerials() + { + return $this->hasMany(InventorySerial::class); + } + + public function movementsFrom() + { + return $this->hasMany(InventoryMovement::class, 'warehouse_from_id'); + } + + public function movementsTo() + { + return $this->hasMany(InventoryMovement::class, 'warehouse_to_id'); + } + + // Scopes + public function scopeActive($query) + { + return $query->where('is_active', true); + } + + public function scopeMain($query) + { + return $query->where('is_main', true); + } +} diff --git a/app/Services/InventoryMovementService.php b/app/Services/InventoryMovementService.php new file mode 100644 index 0000000..592b469 --- /dev/null +++ b/app/Services/InventoryMovementService.php @@ -0,0 +1,191 @@ +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_to_id' => $warehouse->id, + 'movement_type' => $data['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, + ]); + }); + } + + /** + * Salida de inventario (merma, ajuste negativo, robo) + */ + public function exit(array $data): InventoryMovement + { + return DB::transaction(function () use ($data) { + $inventory = Inventory::findOrFail($data['inventory_id']); + $warehouse = Warehouse::findOrFail($data['warehouse_id']); + $quantity = $data['quantity']; + + // Validar stock disponible + $this->validateStock($inventory->id, $warehouse->id, $quantity); + + // 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', + 'quantity' => $quantity, + 'user_id' => auth()->id(), + 'notes' => $data['notes'] ?? null, + 'reference_type' => $data['reference_type'] ?? null, + 'reference_id' => $data['reference_id'] ?? null, + ]); + }); + } + + /** + * Traspaso entre almacenes + */ + public function transfer(array $data): InventoryMovement + { + return DB::transaction(function () use ($data) { + $inventory = Inventory::findOrFail($data['inventory_id']); + $warehouseFrom = Warehouse::findOrFail($data['warehouse_from_id']); + $warehouseTo = Warehouse::findOrFail($data['warehouse_to_id']); + $quantity = $data['quantity']; + + // Validar que no sea el mismo almacén + if ($warehouseFrom->id === $warehouseTo->id) { + throw new \Exception('No se puede traspasar al mismo almacén.'); + } + + // Validar stock disponible en almacén origen + $this->validateStock($inventory->id, $warehouseFrom->id, $quantity); + + // Decrementar en origen + $this->updateWarehouseStock($inventory->id, $warehouseFrom->id, -$quantity); + + // 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, + 'warehouse_from_id' => $warehouseFrom->id, + 'warehouse_to_id' => $warehouseTo->id, + 'movement_type' => 'transfer', + 'quantity' => $quantity, + 'user_id' => auth()->id(), + 'notes' => $data['notes'] ?? null, + ]); + }); + } + + /** + * Actualizar stock en inventory_warehouse + */ + protected function updateWarehouseStock(int $inventoryId, int $warehouseId, int $quantityChange): void + { + $record = InventoryWarehouse::firstOrCreate( + [ + 'inventory_id' => $inventoryId, + 'warehouse_id' => $warehouseId, + ], + ['stock' => 0] + ); + + $newStock = $record->stock + $quantityChange; + + // No permitir stock negativo + if ($newStock < 0) { + throw new \Exception('Stock insuficiente en el almacén.'); + } + + $record->update(['stock' => $newStock]); + } + + /** + * Validar stock disponible + */ + protected function validateStock(int $inventoryId, int $warehouseId, int $requiredQuantity): void + { + $record = InventoryWarehouse::where('inventory_id', $inventoryId) + ->where('warehouse_id', $warehouseId) + ->first(); + + $availableStock = $record?->stock ?? 0; + + if ($availableStock < $requiredQuantity) { + throw new \Exception("Stock insuficiente. Disponible: {$availableStock}, Requerido: {$requiredQuantity}"); + } + } + + /** + * Registrar movimiento de venta + */ + public function recordSale(int $inventoryId, int $warehouseId, int $quantity, int $saleId): InventoryMovement + { + return InventoryMovement::create([ + 'inventory_id' => $inventoryId, + 'warehouse_from_id' => $warehouseId, + 'warehouse_to_id' => null, + 'movement_type' => 'sale', + 'quantity' => $quantity, + 'reference_type' => 'App\Models\Sale', + 'reference_id' => $saleId, + 'user_id' => auth()->id(), + ]); + } + + /** + * Registrar movimiento de devolución + */ + public function recordReturn(int $inventoryId, int $warehouseId, int $quantity, int $returnId): InventoryMovement + { + return InventoryMovement::create([ + 'inventory_id' => $inventoryId, + 'warehouse_from_id' => null, + 'warehouse_to_id' => $warehouseId, + 'movement_type' => 'return', + 'quantity' => $quantity, + 'reference_type' => 'App\Models\Returns', + 'reference_id' => $returnId, + 'user_id' => auth()->id(), + ]); + } +} diff --git a/app/Services/WarehouseService.php b/app/Services/WarehouseService.php new file mode 100644 index 0000000..42e7b01 --- /dev/null +++ b/app/Services/WarehouseService.php @@ -0,0 +1,115 @@ +first(); + } + + /** + * Crear nuevo almacén + */ + public function createWarehouse(array $data): Warehouse + { + return DB::transaction(function () use ($data) { + // Si se marca como principal, desactivar otros principales + if ($data['is_main'] ?? false) { + Warehouse::where('is_main', true)->update(['is_main' => false]); + } + + // Si no hay almacenes principales, este debe serlo + if (!Warehouse::main()->exists()) { + $data['is_main'] = true; + } + + return Warehouse::create($data); + }); + } + + /** + * Actualizar almacén + */ + public function updateWarehouse(Warehouse $warehouse, array $data): Warehouse + { + return DB::transaction(function () use ($warehouse, $data) { + // Si se marca como principal, desactivar otros principales + if (isset($data['is_main']) && $data['is_main']) { + Warehouse::where('id', '!=', $warehouse->id) + ->where('is_main', true) + ->update(['is_main' => false]); + } + + // No permitir desactivar el último almacén principal + if (isset($data['is_main']) && !$data['is_main'] && $warehouse->is_main) { + if (Warehouse::main()->count() === 1) { + throw new \Exception('No se puede desactivar el único almacén principal. Asigna otro almacén como principal primero.'); + } + } + + $warehouse->update($data); + return $warehouse->fresh(); + }); + } + + /** + * Activar/Desactivar almacén + */ + public function toggleActive(Warehouse $warehouse): Warehouse + { + // No permitir desactivar el almacén principal + if ($warehouse->is_main && $warehouse->is_active) { + throw new \Exception('No se puede desactivar el almacén principal.'); + } + + $warehouse->update(['is_active' => !$warehouse->is_active]); + return $warehouse->fresh(); + } + + /** + * Obtener stock de un almacén + */ + public function getWarehouseStock(Warehouse $warehouse) + { + return $warehouse->inventories() + ->with(['category', 'price']) + ->select('inventories.*') + ->get() + ->map(function ($inventory) use ($warehouse) { + $pivot = $inventory->warehouses() + ->where('warehouse_id', $warehouse->id) + ->first(); + + return [ + 'inventory_id' => $inventory->id, + 'name' => $inventory->name, + 'sku' => $inventory->sku, + 'category' => $inventory->category?->name, + 'stock' => $pivot?->pivot->stock ?? 0, + 'min_stock' => $pivot?->pivot->min_stock, + 'max_stock' => $pivot?->pivot->max_stock, + 'cost' => $inventory->price?->cost, + 'retail_price' => $inventory->price?->retail_price, + ]; + }); + } + + /** + * Validar que existe almacén principal + */ + public function ensureMainWarehouse(): void + { + if (!Warehouse::main()->exists()) { + throw new \Exception('No existe un almacén principal configurado.'); + } + } +} diff --git a/database/migrations/2026_02_05_133032_create_warehouses_table.php b/database/migrations/2026_02_05_133032_create_warehouses_table.php new file mode 100644 index 0000000..20592d8 --- /dev/null +++ b/database/migrations/2026_02_05_133032_create_warehouses_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('code')->unique(); + $table->string('name'); + $table->boolean('is_active')->default(true); + $table->boolean('is_main')->default(false); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('warehouses'); + } +}; diff --git a/database/migrations/2026_02_05_133656_create_inventory_warehouse_table.php b/database/migrations/2026_02_05_133656_create_inventory_warehouse_table.php new file mode 100644 index 0000000..c230792 --- /dev/null +++ b/database/migrations/2026_02_05_133656_create_inventory_warehouse_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('inventory_id')->constrained()->onDelete('restrict'); + $table->foreignId('warehouse_id')->constrained()->onDelete('restrict'); + $table->integer('stock')->default(0); + $table->integer('min_stock')->nullable()->default(0); + $table->integer('max_stock')->nullable()->default(0); + $table->timestamps(); + $table->unique(['inventory_id', 'warehouse_id']); + $table->index(['warehouse_id', 'stock']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_warehouse'); + } +}; diff --git a/database/migrations/2026_02_05_133708_create_inventory_movements_table.php b/database/migrations/2026_02_05_133708_create_inventory_movements_table.php new file mode 100644 index 0000000..269d067 --- /dev/null +++ b/database/migrations/2026_02_05_133708_create_inventory_movements_table.php @@ -0,0 +1,41 @@ +id(); + $table->foreignId('inventory_id')->constrained('inventories')->onDelete('restrict'); + $table->foreignId('warehouse_from_id')->nullable()->constrained('warehouses')->onDelete('restrict'); + $table->foreignId('warehouse_to_id')->nullable()->constrained('warehouses')->onDelete('restrict'); + $table->enum('movement_type', ['entry', 'exit', 'transfer', 'sale', 'return']); + $table->integer('quantity'); + $table->string('reference_type')->nullable(); + $table->unsignedBigInteger('reference_id')->nullable(); + $table->foreignId('user_id')->constrained('users')->onDelete('restrict'); + $table->text('notes')->nullable(); + $table->timestamp('created_at'); + + $table->index(['inventory_id', 'created_at']); + $table->index('warehouse_from_id'); + $table->index('warehouse_to_id'); + $table->index(['reference_type', 'reference_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('inventory_movements'); + } +}; diff --git a/database/migrations/2026_02_05_133730_add_warehouse_id_to_inventory_serials_table.php b/database/migrations/2026_02_05_133730_add_warehouse_id_to_inventory_serials_table.php new file mode 100644 index 0000000..e719b38 --- /dev/null +++ b/database/migrations/2026_02_05_133730_add_warehouse_id_to_inventory_serials_table.php @@ -0,0 +1,30 @@ +foreignId('warehouse_id')->nullable()->after('inventory_id')->constrained('warehouses')->onDelete('restrict'); + $table->index(['warehouse_id', 'status']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('inventory_serials', function (Blueprint $table) { + $table->dropForeign(['warehouse_id']); + $table->dropIndex(['warehouse_id', 'status']); + }); + } +}; diff --git a/database/migrations/2026_02_05_133745_add_warehouse_id_to_sale_details_table.php b/database/migrations/2026_02_05_133745_add_warehouse_id_to_sale_details_table.php new file mode 100644 index 0000000..166e99e --- /dev/null +++ b/database/migrations/2026_02_05_133745_add_warehouse_id_to_sale_details_table.php @@ -0,0 +1,31 @@ +foreignId('warehouse_id')->nullable()->after('inventory_id')->constrained('warehouses')->onDelete('restrict'); + + $table->index('warehouse_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('sale_details', function (Blueprint $table) { + $table->dropForeign(['warehouse_id']); + $table->dropIndex(['warehouse_id']); + }); + } +}; diff --git a/database/migrations/2026_02_05_141627_migrate_existing_inventory_to_warehouses.php b/database/migrations/2026_02_05_141627_migrate_existing_inventory_to_warehouses.php new file mode 100644 index 0000000..f339c1f --- /dev/null +++ b/database/migrations/2026_02_05_141627_migrate_existing_inventory_to_warehouses.php @@ -0,0 +1,57 @@ +insertGetId([ + 'code' => 'ALM-PRINCIPAL', + 'name' => 'Almacén Principal', + 'is_active' => true, + 'is_main' => true, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + // 2. Migrar stock de inventories a inventory_warehouse + $inventories = DB::table('inventories')->whereNull('deleted_at')->get(); + + foreach ($inventories as $inventory) { + DB::table('inventory_warehouse')->insert([ + 'inventory_id' => $inventory->id, + 'warehouse_id' => $mainWarehouse, + 'stock' => $inventory->stock, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } + + // 3. Asignar almacén principal a todos los seriales existentes + DB::table('inventory_serials')->update([ + 'warehouse_id' => $mainWarehouse, + ]); + + // 4. Asignar almacén principal a sale_details existentes (nullable, opcional) + DB::table('sale_details') + ->whereNull('warehouse_id') + ->update(['warehouse_id' => $mainWarehouse]); + } + + public function down() + { + // Rollback: eliminar asignaciones de warehouse + DB::table('inventory_serials')->update(['warehouse_id' => null]); + DB::table('sale_details')->update(['warehouse_id' => null]); + DB::table('inventory_warehouse')->truncate(); + DB::table('warehouses')->truncate(); + } +};