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:
parent
3cac336e10
commit
5a646d84d5
@ -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();
|
||||
|
||||
@ -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'));
|
||||
|
||||
@ -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()
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
36
app/Http/Requests/App/InventoryEntryRequest.php
Normal file
36
app/Http/Requests/App/InventoryEntryRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
35
app/Http/Requests/App/InventoryExitRequest.php
Normal file
35
app/Http/Requests/App/InventoryExitRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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.',
|
||||
|
||||
39
app/Http/Requests/App/InventoryTransferRequest.php
Normal file
39
app/Http/Requests/App/InventoryTransferRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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.',
|
||||
|
||||
32
app/Http/Requests/App/WarehouseStoreRequest.php
Normal file
32
app/Http/Requests/App/WarehouseStoreRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
36
app/Http/Requests/App/WarehouseUpdateRequest.php
Normal file
36
app/Http/Requests/App/WarehouseUpdateRequest.php
Normal 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',
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ class InventoryMovement extends Model
|
||||
'reference_id',
|
||||
'user_id',
|
||||
'notes',
|
||||
'invoice_reference',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
|
||||
@ -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]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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)
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user