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);
|
$q->where('track_serials', true);
|
||||||
})
|
})
|
||||||
->when($request->low_stock_threshold, function($q) use ($request) {
|
->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();
|
$inventories = $query->orderBy('name')->get();
|
||||||
|
|||||||
@ -43,9 +43,11 @@ public function index(Request $request)
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calcular el valor total del inventario
|
// 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)
|
->where('inventories.is_active', true)
|
||||||
->sum(DB::raw('inventories.stock * prices.cost'));
|
->sum(DB::raw('inventory_warehouse.stock * prices.cost'));
|
||||||
|
|
||||||
$products = $products->orderBy('name')
|
$products = $products->orderBy('name')
|
||||||
->paginate(config('app.pagination'));
|
->paginate(config('app.pagination'));
|
||||||
|
|||||||
@ -1,37 +1,135 @@
|
|||||||
<?php namespace App\Http\Controllers\App;
|
<?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\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 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>
|
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||||
*
|
|
||||||
* @version 1.0.0
|
* @version 1.0.0
|
||||||
*/
|
*/
|
||||||
class InventoryMovementController extends Controller
|
class InventoryMovementController extends Controller
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected InventoryMovementService $movementService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listar movimientos con filtros
|
||||||
|
*/
|
||||||
public function index(Request $request)
|
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\Http\Controllers\Controller;
|
||||||
use App\Models\Inventory;
|
use App\Models\Inventory;
|
||||||
use App\Models\InventorySerial;
|
use App\Models\InventorySerial;
|
||||||
|
use App\Services\InventoryMovementService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||||
|
|
||||||
@ -81,11 +82,15 @@ public function store(Inventory $inventario, Request $request)
|
|||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'],
|
'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'],
|
||||||
|
'warehouse_id' => ['nullable', 'exists:warehouses,id'],
|
||||||
'notes' => ['nullable', 'string'],
|
'notes' => ['nullable', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$warehouseId = $request->warehouse_id ?? app(InventoryMovementService::class)->getMainWarehouseId();
|
||||||
|
|
||||||
$serial = InventorySerial::create([
|
$serial = InventorySerial::create([
|
||||||
'inventory_id' => $inventario->id,
|
'inventory_id' => $inventario->id,
|
||||||
|
'warehouse_id' => $warehouseId,
|
||||||
'serial_number' => $request->serial_number,
|
'serial_number' => $request->serial_number,
|
||||||
'status' => 'disponible',
|
'status' => 'disponible',
|
||||||
'notes' => $request->notes,
|
'notes' => $request->notes,
|
||||||
|
|||||||
@ -1,18 +1,130 @@
|
|||||||
<?php namespace App\Http\Controllers;
|
<?php
|
||||||
/**
|
|
||||||
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
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 Illuminate\Http\Request;
|
||||||
|
use Notsoweb\ApiResponse\Enums\ApiResponse;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Descripción
|
* Controlador para gestión de almacenes
|
||||||
*
|
|
||||||
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
|
||||||
*
|
|
||||||
* @version 1.0.0
|
|
||||||
*/
|
*/
|
||||||
class WarehouseController extends Controller
|
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'],
|
'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'],
|
||||||
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
|
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
|
||||||
'category_id' => ['required', 'exists:categories,id'],
|
'category_id' => ['required', 'exists:categories,id'],
|
||||||
'stock' => ['nullable', 'integer', 'min:0'],
|
|
||||||
'track_serials' => ['nullable', 'boolean'],
|
'track_serials' => ['nullable', 'boolean'],
|
||||||
|
|
||||||
// Campos de Price
|
// Campos de Price
|
||||||
@ -48,8 +47,6 @@ public function messages(): array
|
|||||||
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
|
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
|
||||||
'category_id.required' => 'La categoría es obligatoria.',
|
'category_id.required' => 'La categoría es obligatoria.',
|
||||||
'category_id.exists' => 'La categoría seleccionada no es válida.',
|
'category_id.exists' => 'La categoría seleccionada no es válida.',
|
||||||
'stock.min' => 'El stock no puede ser negativo.',
|
|
||||||
|
|
||||||
// Mensajes de Price
|
// Mensajes de Price
|
||||||
'cost.required' => 'El costo es obligatorio.',
|
'cost.required' => 'El costo es obligatorio.',
|
||||||
'cost.numeric' => 'El costo debe ser un número.',
|
'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'],
|
'sku' => ['nullable', 'string', 'max:50'],
|
||||||
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
|
'barcode' => ['nullable', 'string', 'unique:inventories,barcode,' . $inventoryId],
|
||||||
'category_id' => ['nullable', 'exists:categories,id'],
|
'category_id' => ['nullable', 'exists:categories,id'],
|
||||||
'stock' => ['nullable', 'integer', 'min:0'],
|
|
||||||
'track_serials' => ['nullable', 'boolean'],
|
'track_serials' => ['nullable', 'boolean'],
|
||||||
|
|
||||||
// Campos de Price
|
// 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.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.',
|
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
|
||||||
'category_id.exists' => 'La categoría seleccionada no es válida.',
|
'category_id.exists' => 'La categoría seleccionada no es válida.',
|
||||||
'stock.min' => 'El stock no puede ser negativo.',
|
|
||||||
|
|
||||||
// Mensajes de Price
|
// Mensajes de Price
|
||||||
'cost.numeric' => 'El costo debe ser un número.',
|
'cost.numeric' => 'El costo debe ser un número.',
|
||||||
'cost.min' => 'El costo no puede ser negativo.',
|
'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\Models\Category;
|
||||||
use App\Http\Requests\App\InventoryImportRequest;
|
use App\Http\Requests\App\InventoryImportRequest;
|
||||||
use App\Models\InventorySerial;
|
use App\Models\InventorySerial;
|
||||||
|
use App\Services\InventoryMovementService;
|
||||||
use Maatwebsite\Excel\Concerns\ToModel;
|
use Maatwebsite\Excel\Concerns\ToModel;
|
||||||
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
use Maatwebsite\Excel\Concerns\WithHeadingRow;
|
||||||
use Maatwebsite\Excel\Concerns\WithValidation;
|
use Maatwebsite\Excel\Concerns\WithValidation;
|
||||||
@ -35,6 +36,12 @@ class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChu
|
|||||||
private $imported = 0;
|
private $imported = 0;
|
||||||
private $updated = 0;
|
private $updated = 0;
|
||||||
private $skipped = 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
|
* 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;
|
$categoryId = $category->id;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Crear el producto en inventario
|
// Crear el producto en inventario (sin stock, vive en inventory_warehouse)
|
||||||
$inventory = new Inventory();
|
$inventory = new Inventory();
|
||||||
$inventory->name = trim($row['nombre']);
|
$inventory->name = trim($row['nombre']);
|
||||||
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
|
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
|
||||||
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
|
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
|
||||||
$inventory->category_id = $categoryId;
|
$inventory->category_id = $categoryId;
|
||||||
$inventory->stock = 0;
|
|
||||||
$inventory->is_active = true;
|
$inventory->is_active = true;
|
||||||
$inventory->save();
|
$inventory->save();
|
||||||
|
|
||||||
@ -137,6 +143,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
|
|||||||
{
|
{
|
||||||
$serialsAdded = 0;
|
$serialsAdded = 0;
|
||||||
$serialsSkipped = 0;
|
$serialsSkipped = 0;
|
||||||
|
$mainWarehouseId = $this->movementService->getMainWarehouseId();
|
||||||
|
|
||||||
// Agregar seriales nuevos (ignorar duplicados)
|
// Agregar seriales nuevos (ignorar duplicados)
|
||||||
if (!empty($row['numeros_serie'])) {
|
if (!empty($row['numeros_serie'])) {
|
||||||
@ -152,6 +159,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
|
|||||||
if (!$exists) {
|
if (!$exists) {
|
||||||
InventorySerial::create([
|
InventorySerial::create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
|
'warehouse_id' => $mainWarehouseId,
|
||||||
'serial_number' => $serial,
|
'serial_number' => $serial,
|
||||||
'status' => 'disponible',
|
'status' => 'disponible',
|
||||||
]);
|
]);
|
||||||
@ -164,9 +172,11 @@ private function updateExistingProduct(Inventory $inventory, array $row)
|
|||||||
// Sincronizar stock basado en seriales disponibles
|
// Sincronizar stock basado en seriales disponibles
|
||||||
$inventory->syncStock();
|
$inventory->syncStock();
|
||||||
} else {
|
} else {
|
||||||
// Producto sin seriales: sumar stock
|
// Producto sin seriales: sumar stock en almacén principal
|
||||||
$stockToAdd = (int) ($row['stock'] ?? 0);
|
$stockToAdd = (int) ($row['stock'] ?? 0);
|
||||||
$inventory->increment('stock', $stockToAdd);
|
if ($stockToAdd > 0) {
|
||||||
|
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockToAdd);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->updated++;
|
$this->updated++;
|
||||||
@ -183,6 +193,8 @@ private function updateExistingProduct(Inventory $inventory, array $row)
|
|||||||
*/
|
*/
|
||||||
private function addSerials(Inventory $inventory, ?string $serialsString, int $stockFromExcel)
|
private function addSerials(Inventory $inventory, ?string $serialsString, int $stockFromExcel)
|
||||||
{
|
{
|
||||||
|
$mainWarehouseId = $this->movementService->getMainWarehouseId();
|
||||||
|
|
||||||
if (!empty($serialsString)) {
|
if (!empty($serialsString)) {
|
||||||
$serials = explode(',', $serialsString);
|
$serials = explode(',', $serialsString);
|
||||||
|
|
||||||
@ -191,6 +203,7 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
|
|||||||
if (!empty($serial)) {
|
if (!empty($serial)) {
|
||||||
InventorySerial::create([
|
InventorySerial::create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
|
'warehouse_id' => $mainWarehouseId,
|
||||||
'serial_number' => $serial,
|
'serial_number' => $serial,
|
||||||
'status' => 'disponible',
|
'status' => 'disponible',
|
||||||
]);
|
]);
|
||||||
@ -198,9 +211,10 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
|
|||||||
}
|
}
|
||||||
$inventory->syncStock();
|
$inventory->syncStock();
|
||||||
} else {
|
} else {
|
||||||
// Producto sin seriales
|
// Producto sin seriales: registrar stock en almacén principal
|
||||||
$inventory->stock = $stockFromExcel;
|
if ($stockFromExcel > 0) {
|
||||||
$inventory->save();
|
$this->movementService->updateWarehouseStock($inventory->id, $mainWarehouseId, $stockFromExcel);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
use App\Services\InventoryMovementService;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
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>
|
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
|
||||||
*
|
*
|
||||||
@ -20,7 +23,6 @@ class Inventory extends Model
|
|||||||
'name',
|
'name',
|
||||||
'sku',
|
'sku',
|
||||||
'barcode',
|
'barcode',
|
||||||
'stock',
|
|
||||||
'track_serials',
|
'track_serials',
|
||||||
'is_active',
|
'is_active',
|
||||||
];
|
];
|
||||||
@ -30,24 +32,40 @@ class Inventory extends Model
|
|||||||
'track_serials' => 'boolean',
|
'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')
|
return $this->belongsToMany(Warehouse::class, 'inventory_warehouse')
|
||||||
->withPivot('stock', 'min_stock', 'max_stock')
|
->withPivot('stock', 'min_stock', 'max_stock')
|
||||||
->withTimestamps();
|
->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');
|
return $this->warehouses()->sum('inventory_warehouse.stock');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sincronizar stock global
|
/**
|
||||||
public function syncGlobalStock(): void {
|
* Alias para compatibilidad
|
||||||
$this->update(['stock' => $this->total_stock]);
|
*/
|
||||||
|
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()
|
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
|
public function getAvailableStockAttribute(): int
|
||||||
{
|
{
|
||||||
return $this->availableSerials()->count();
|
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
|
public function getHasSerialsAttribute(): bool
|
||||||
{
|
{
|
||||||
return isset($this->attributes['serials_count']) && $this->attributes['serials_count'] > 0;
|
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
|
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',
|
'reference_id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'notes',
|
'notes',
|
||||||
|
'invoice_reference',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
|
|||||||
@ -8,6 +8,8 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Servicio para gestión de movimientos de inventario
|
* Servicio para gestión de movimientos de inventario
|
||||||
|
*
|
||||||
|
* El stock vive en inventory_warehouse, no en inventories
|
||||||
*/
|
*/
|
||||||
class InventoryMovementService
|
class InventoryMovementService
|
||||||
{
|
{
|
||||||
@ -24,26 +26,22 @@ public function entry(array $data): InventoryMovement
|
|||||||
// Actualizar stock en inventory_warehouse
|
// Actualizar stock en inventory_warehouse
|
||||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
$this->updateWarehouseStock($inventory->id, $warehouse->id, $quantity);
|
||||||
|
|
||||||
// Sincronizar stock global
|
|
||||||
$inventory->syncGlobalStock();
|
|
||||||
|
|
||||||
// Registrar movimiento
|
// Registrar movimiento
|
||||||
return InventoryMovement::create([
|
return InventoryMovement::create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
'warehouse_from_id' => null, // Entrada externa
|
'warehouse_from_id' => null,
|
||||||
'warehouse_to_id' => $warehouse->id,
|
'warehouse_to_id' => $warehouse->id,
|
||||||
'movement_type' => $data['movement_type'] ?? 'entry',
|
'movement_type' => 'entry',
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'notes' => $data['notes'] ?? null,
|
'notes' => $data['notes'] ?? null,
|
||||||
'reference_type' => $data['reference_type'] ?? null,
|
'invoice_reference' => $data['invoice_reference'] ?? null,
|
||||||
'reference_id' => $data['reference_id'] ?? null,
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Salida de inventario (merma, ajuste negativo, robo)
|
* Salida de inventario (merma, ajuste negativo, robo, daño)
|
||||||
*/
|
*/
|
||||||
public function exit(array $data): InventoryMovement
|
public function exit(array $data): InventoryMovement
|
||||||
{
|
{
|
||||||
@ -58,20 +56,15 @@ public function exit(array $data): InventoryMovement
|
|||||||
// Decrementar stock en inventory_warehouse
|
// Decrementar stock en inventory_warehouse
|
||||||
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
|
$this->updateWarehouseStock($inventory->id, $warehouse->id, -$quantity);
|
||||||
|
|
||||||
// Sincronizar stock global
|
|
||||||
$inventory->syncGlobalStock();
|
|
||||||
|
|
||||||
// Registrar movimiento
|
// Registrar movimiento
|
||||||
return InventoryMovement::create([
|
return InventoryMovement::create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
'warehouse_from_id' => $warehouse->id,
|
'warehouse_from_id' => $warehouse->id,
|
||||||
'warehouse_to_id' => null, // Salida externa
|
'warehouse_to_id' => null,
|
||||||
'movement_type' => $data['movement_type'] ?? 'exit',
|
'movement_type' => 'exit',
|
||||||
'quantity' => $quantity,
|
'quantity' => $quantity,
|
||||||
'user_id' => auth()->id(),
|
'user_id' => auth()->id(),
|
||||||
'notes' => $data['notes'] ?? null,
|
'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
|
// Incrementar en destino
|
||||||
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
|
$this->updateWarehouseStock($inventory->id, $warehouseTo->id, $quantity);
|
||||||
|
|
||||||
// Stock global no cambia, no es necesario sincronizar
|
|
||||||
|
|
||||||
// Registrar movimiento
|
// Registrar movimiento
|
||||||
return InventoryMovement::create([
|
return InventoryMovement::create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
@ -119,7 +110,7 @@ public function transfer(array $data): InventoryMovement
|
|||||||
/**
|
/**
|
||||||
* Actualizar stock en inventory_warehouse
|
* 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(
|
$record = InventoryWarehouse::firstOrCreate(
|
||||||
[
|
[
|
||||||
@ -131,7 +122,6 @@ protected function updateWarehouseStock(int $inventoryId, int $warehouseId, int
|
|||||||
|
|
||||||
$newStock = $record->stock + $quantityChange;
|
$newStock = $record->stock + $quantityChange;
|
||||||
|
|
||||||
// No permitir stock negativo
|
|
||||||
if ($newStock < 0) {
|
if ($newStock < 0) {
|
||||||
throw new \Exception('Stock insuficiente en el almacén.');
|
throw new \Exception('Stock insuficiente en el almacén.');
|
||||||
}
|
}
|
||||||
@ -142,7 +132,7 @@ protected function updateWarehouseStock(int $inventoryId, int $warehouseId, int
|
|||||||
/**
|
/**
|
||||||
* Validar stock disponible
|
* 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)
|
$record = InventoryWarehouse::where('inventory_id', $inventoryId)
|
||||||
->where('warehouse_id', $warehouseId)
|
->where('warehouse_id', $warehouseId)
|
||||||
@ -188,4 +178,53 @@ public function recordReturn(int $inventoryId, int $warehouseId, int $quantity,
|
|||||||
'user_id' => auth()->id(),
|
'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'],
|
'sku' => $data['sku'],
|
||||||
'barcode' => $data['barcode'] ?? null,
|
'barcode' => $data['barcode'] ?? null,
|
||||||
'category_id' => $data['category_id'],
|
'category_id' => $data['category_id'],
|
||||||
'stock' => $data['stock'] ?? 0,
|
|
||||||
'track_serials' => $data['track_serials'] ?? false,
|
'track_serials' => $data['track_serials'] ?? false,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$price = Price::create([
|
Price::create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
'cost' => $data['cost'],
|
'cost' => $data['cost'],
|
||||||
'retail_price' => $data['retail_price'],
|
'retail_price' => $data['retail_price'],
|
||||||
@ -38,7 +37,6 @@ public function updateProduct(Inventory $inventory, array $data)
|
|||||||
'sku' => $data['sku'] ?? null,
|
'sku' => $data['sku'] ?? null,
|
||||||
'barcode' => $data['barcode'] ?? null,
|
'barcode' => $data['barcode'] ?? null,
|
||||||
'category_id' => $data['category_id'] ?? null,
|
'category_id' => $data['category_id'] ?? null,
|
||||||
'stock' => $data['stock'] ?? null,
|
|
||||||
'track_serials' => $data['track_serials'] ?? null,
|
'track_serials' => $data['track_serials'] ?? null,
|
||||||
], fn($value) => $value !== null);
|
], fn($value) => $value !== null);
|
||||||
|
|
||||||
|
|||||||
@ -13,12 +13,10 @@
|
|||||||
|
|
||||||
class ReturnService
|
class ReturnService
|
||||||
{
|
{
|
||||||
protected ClientTierService $clientTierService;
|
public function __construct(
|
||||||
|
protected ClientTierService $clientTierService,
|
||||||
public function __construct(ClientTierService $clientTierService)
|
protected InventoryMovementService $movementService
|
||||||
{
|
) {}
|
||||||
$this->clientTierService = $clientTierService;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Crear una nueva devolución con sus detalles
|
* Crear una nueva devolución con sus detalles
|
||||||
*/
|
*/
|
||||||
@ -165,7 +163,9 @@ public function createReturn(array $data): Returns
|
|||||||
// Sincronizar el stock del inventario
|
// Sincronizar el stock del inventario
|
||||||
$saleDetail->inventory->syncStock();
|
$saleDetail->inventory->syncStock();
|
||||||
} else {
|
} 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
|
// Sincronizar stock
|
||||||
$detail->inventory->syncStock();
|
$detail->inventory->syncStock();
|
||||||
} else {
|
} else {
|
||||||
// Revertir stock numérico (la devolución lo había incrementado)
|
// Revertir stock (la devolución lo había incrementado)
|
||||||
$detail->inventory->decrement('stock', $detail->quantity_returned);
|
$warehouseId = $detail->saleDetail->warehouse_id ?? $this->movementService->getMainWarehouseId();
|
||||||
|
$this->movementService->updateWarehouseStock($detail->inventory_id, $warehouseId, -$detail->quantity_returned);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,12 +10,10 @@
|
|||||||
|
|
||||||
class SaleService
|
class SaleService
|
||||||
{
|
{
|
||||||
protected ClientTierService $clientTierService;
|
public function __construct(
|
||||||
|
protected ClientTierService $clientTierService,
|
||||||
public function __construct(ClientTierService $clientTierService)
|
protected InventoryMovementService $movementService
|
||||||
{
|
) {}
|
||||||
$this->clientTierService = $clientTierService;
|
|
||||||
}
|
|
||||||
/**
|
/**
|
||||||
* Crear una nueva venta con sus detalles
|
* Crear una nueva venta con sus detalles
|
||||||
*
|
*
|
||||||
@ -131,11 +129,11 @@ public function createSale(array $data)
|
|||||||
// Sincronizar el stock
|
// Sincronizar el stock
|
||||||
$inventory->syncStock();
|
$inventory->syncStock();
|
||||||
} else {
|
} else {
|
||||||
if ($inventory->stock < $item['quantity']) {
|
// Obtener almacén (del item o el principal)
|
||||||
throw new \Exception("Stock insuficiente para {$item['product_name']}");
|
$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();
|
$detail->inventory->syncStock();
|
||||||
} else {
|
} else {
|
||||||
// Restaurar stock numérico
|
// Restaurar stock en el almacén
|
||||||
$detail->inventory->increment('stock', $detail->quantity);
|
$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');
|
$invoiceRequestUpload = $this->onPermission('invoice-requests.upload', 'Subir archivos de factura', $invoiceRequestsType, 'api');
|
||||||
$invoiceRequestStats = $this->onPermission('invoice-requests.stats', 'Ver estadísticas', $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 ====================
|
// ==================== ROLES ====================
|
||||||
|
|
||||||
@ -195,7 +212,15 @@ public function run(): void
|
|||||||
$invoiceRequestProcess,
|
$invoiceRequestProcess,
|
||||||
$invoiceRequestReject,
|
$invoiceRequestReject,
|
||||||
$invoiceRequestUpload,
|
$invoiceRequestUpload,
|
||||||
$invoiceRequestStats
|
$invoiceRequestStats,
|
||||||
|
$warehouseIndex,
|
||||||
|
$warehouseCreate,
|
||||||
|
$warehouseEdit,
|
||||||
|
$warehouseDestroy,
|
||||||
|
$movementsIndex,
|
||||||
|
$movementsCreate,
|
||||||
|
$movementsEdit,
|
||||||
|
$movementsDestroy
|
||||||
);
|
);
|
||||||
|
|
||||||
//Operador PDV (solo permisos de operación de caja y ventas)
|
//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\InvoiceController;
|
||||||
use App\Http\Controllers\App\InventorySerialController;
|
use App\Http\Controllers\App\InventorySerialController;
|
||||||
use App\Http\Controllers\App\InvoiceRequestController;
|
use App\Http\Controllers\App\InvoiceRequestController;
|
||||||
|
use App\Http\Controllers\App\InventoryMovementController;
|
||||||
|
use App\Http\Controllers\App\WarehouseController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -42,6 +44,18 @@
|
|||||||
Route::resource('inventario.serials', InventorySerialController::class);
|
Route::resource('inventario.serials', InventorySerialController::class);
|
||||||
Route::get('serials/search', [InventorySerialController::class, 'search']);
|
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
|
//CATEGORIAS
|
||||||
Route::resource('categorias', CategoryController::class);
|
Route::resource('categorias', CategoryController::class);
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user