wip: catalog de almacenes, entrada, salida y traspaso

This commit is contained in:
Juan Felipe Zapata Moreno 2026-02-05 17:01:19 -06:00
parent 656492251b
commit 3cac336e10
16 changed files with 786 additions and 3 deletions

View File

@ -0,0 +1,37 @@
<?php namespace App\Http\Controllers\App;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @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()
{
//
}
}

View File

@ -0,0 +1,18 @@
<?php namespace App\Http\Controllers;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Http\Request;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class WarehouseController extends Controller
{
//
}

View File

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

View File

@ -0,0 +1,64 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InventoryMovement extends Model
{
const UPDATED_AT = null;
protected $fillable = [
'inventory_id',
'warehouse_from_id',
'warehouse_to_id',
'movement_type',
'quantity',
'reference_type',
'reference_id',
'user_id',
'notes',
];
protected $casts = [
'quantity' => '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');
}
}

View File

@ -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,
]);
}

View File

@ -0,0 +1,44 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InventoryWarehouse extends Model
{
protected $table = 'inventory_warehouse';
protected $fillable = [
'inventory_id',
'warehouse_id',
'stock',
'min_stock',
'max_stock',
];
protected $casts = [
'stock' => '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;
}
}

View File

@ -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);

61
app/Models/Warehouse.php Normal file
View File

@ -0,0 +1,61 @@
<?php
namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Modelo para almacenes
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @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);
}
}

View File

@ -0,0 +1,191 @@
<?php namespace App\Services;
use App\Models\Inventory;
use App\Models\InventoryMovement;
use App\Models\InventoryWarehouse;
use App\Models\Warehouse;
use Illuminate\Support\Facades\DB;
/**
* Servicio para gestión de movimientos de inventario
*/
class InventoryMovementService
{
/**
* Entrada de inventario (compra, ajuste positivo)
*/
public function entry(array $data): InventoryMovement
{
return DB::transaction(function () use ($data) {
$inventory = Inventory::findOrFail($data['inventory_id']);
$warehouse = Warehouse::findOrFail($data['warehouse_id']);
$quantity = $data['quantity'];
// 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_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(),
]);
}
}

View File

@ -0,0 +1,115 @@
<?php namespace App\Services;
use App\Models\Warehouse;
use Illuminate\Support\Facades\DB;
/**
* Servicio para gestión de almacenes
*/
class WarehouseService
{
/**
* Obtener almacén principal
*/
public function getMainWarehouse(): ?Warehouse
{
return Warehouse::main()->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.');
}
}
}

View File

@ -0,0 +1,32 @@
<?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
{
Schema::create('warehouses', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,34 @@
<?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
{
Schema::create('inventory_warehouse', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,41 @@
<?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
{
Schema::create('inventory_movements', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,30 @@
<?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
{
Schema::table('inventory_serials', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,31 @@
<?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
{
Schema::table('sale_details', function (Blueprint $table) {
$table->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']);
});
}
};

View File

@ -0,0 +1,57 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up()
{
// 1. Crear almacén principal
$mainWarehouse = DB::table('warehouses')->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();
}
};