refactor: primeros cambios para añadir servicios, migraciones, modelos y controladores

This commit is contained in:
Juan Felipe Zapata Moreno 2026-01-22 16:22:06 -06:00
parent 2c8189ca59
commit de0d477341
18 changed files with 444 additions and 214 deletions

View File

@ -1,10 +1,10 @@
<?php namespace App\Http\Controllers\App; <?php namespace App\Http\Controllers\App;
use App\Models\Inventory; use App\Models\CatalogItem;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Http\Requests\App\InventoryStoreRequest; use App\Http\Requests\App\CatalogItemStoreRequest;
use App\Http\Requests\App\InventoryUpdateRequest; use App\Http\Requests\App\CatalogItemUpdateRequest;
use App\Http\Requests\App\InventoryImportRequest; use App\Http\Requests\App\CatalogItemImportRequest;
use App\Services\ProductService; use App\Services\ProductService;
use App\Imports\ProductsImport; use App\Imports\ProductsImport;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@ -15,7 +15,7 @@
use Maatwebsite\Excel\Concerns\WithHeadings; use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\Exportable; use Maatwebsite\Excel\Concerns\Exportable;
class InventoryController extends Controller class CatalogItemController extends Controller
{ {
public function __construct( public function __construct(
protected ProductService $productService protected ProductService $productService
@ -23,7 +23,7 @@ public function __construct(
public function index(Request $request) public function index(Request $request)
{ {
$products = Inventory::with(['category', 'price'])->withCount('serials') $products = CatalogItem::with(['category', 'price'])->withCount('serials')
->where('is_active', true); ->where('is_active', true);
@ -43,14 +43,14 @@ public function index(Request $request)
]); ]);
} }
public function show(Inventory $inventario) public function show(CatalogItem $catalogItem)
{ {
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'model' => $inventario->load(['category', 'price'])->loadCount('serials') 'model' => $catalogItem->load(['category', 'price'])->loadCount('serials')
]); ]);
} }
public function store(InventoryStoreRequest $request) public function store(CatalogItemStoreRequest $request)
{ {
$product = $this->productService->createProduct($request->validated()); $product = $this->productService->createProduct($request->validated());
@ -59,26 +59,25 @@ public function store(InventoryStoreRequest $request)
]); ]);
} }
public function update(InventoryUpdateRequest $request, Inventory $inventario) public function update(CatalogItemUpdateRequest $request, CatalogItem $catalogItem)
{ {
$product = $this->productService->updateProduct($inventario, $request->validated()); $product = $this->productService->updateProduct($catalogItem, $request->validated());
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'model' => $product 'model' => $product
]); ]);
} }
public function destroy(Inventory $inventario) public function destroy(CatalogItem $catalogItem)
{ {
$inventario->delete(); $catalogItem->delete();
return ApiResponse::OK->response(); return ApiResponse::OK->response();
} }
/** /**
* Importar productos desde Excel * Importar productos desde Excel
*/ */
public function import(InventoryImportRequest $request) public function import(CatalogItemImportRequest $request)
{ {
try { try {
$import = new ProductsImport(); $import = new ProductsImport();

View File

@ -4,7 +4,7 @@
*/ */
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Inventory; use App\Models\CatalogItem;
use App\Models\InventorySerial; use App\Models\InventorySerial;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse; use Notsoweb\ApiResponse\Enums\ApiResponse;
@ -20,9 +20,9 @@ class InventorySerialController extends Controller
/** /**
* Listar seriales de un producto * Listar seriales de un producto
*/ */
public function index(Inventory $inventario, Request $request) public function index(CatalogItem $catalogItem, Request $request)
{ {
$query = $inventario->serials(); $query = $catalogItem->serials();
if ($request->has('status')) { if ($request->has('status')) {
$query->where('status', $request->status); $query->where('status', $request->status);
@ -37,17 +37,17 @@ public function index(Inventory $inventario, Request $request)
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'serials' => $serials, 'serials' => $serials,
'inventory' => $inventario->load('category'), 'catalogItem' => $catalogItem->load('category'),
]); ]);
} }
/** /**
* Mostrar un serial específico * Mostrar un serial específico
*/ */
public function show(Inventory $inventario, InventorySerial $serial) public function show(CatalogItem $catalogItem, InventorySerial $serial)
{ {
// Verificar que el serial pertenece al inventario // Verificar que el serial pertenece al inventario
if ($serial->inventory_id !== $inventario->id) { if ($serial->catalog_item_id !== $catalogItem->id) {
return ApiResponse::NOT_FOUND->response([ return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario' 'message' => 'Serial no encontrado para este inventario'
]); ]);
@ -55,14 +55,14 @@ public function show(Inventory $inventario, InventorySerial $serial)
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'serial' => $serial->load('saleDetail'), 'serial' => $serial->load('saleDetail'),
'inventory' => $inventario->load('category'), 'catalogItem' => $catalogItem->load('category'),
]); ]);
} }
/** /**
* Crear un nuevo serial * Crear un nuevo serial
*/ */
public function store(Inventory $inventario, Request $request) public function store(CatalogItem $catalogItem, Request $request)
{ {
$request->validate([ $request->validate([
'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'], 'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'],
@ -70,28 +70,28 @@ public function store(Inventory $inventario, Request $request)
]); ]);
$serial = InventorySerial::create([ $serial = InventorySerial::create([
'inventory_id' => $inventario->id, 'catalog_item_id' => $catalogItem->id,
'serial_number' => $request->serial_number, 'serial_number' => $request->serial_number,
'status' => 'disponible', 'status' => 'disponible',
'notes' => $request->notes, 'notes' => $request->notes,
]); ]);
// Sincronizar stock // Sincronizar stock
$inventario->syncStock(); $catalogItem->syncStock();
return ApiResponse::CREATED->response([ return ApiResponse::CREATED->response([
'serial' => $serial, 'serial' => $serial,
'inventory' => $inventario->fresh(), 'catalogItem' => $catalogItem->fresh(),
]); ]);
} }
/** /**
* Actualizar un serial * Actualizar un serial
*/ */
public function update(Inventory $inventario , InventorySerial $serial, Request $request) public function update(CatalogItem $catalogItem , InventorySerial $serial, Request $request)
{ {
// Verificar que el serial pertenece al inventario // Verificar que el serial pertenece al inventario
if ($serial->inventory_id !== $inventario->id) { if ($serial->catalog_item_id !== $catalogItem->id) {
return ApiResponse::NOT_FOUND->response([ return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario' 'message' => 'Serial no encontrado para este inventario'
]); ]);
@ -106,21 +106,21 @@ public function update(Inventory $inventario , InventorySerial $serial, Request
$serial->update($request->only(['serial_number', 'status', 'notes'])); $serial->update($request->only(['serial_number', 'status', 'notes']));
// Sincronizar stock del inventario // Sincronizar stock del inventario
$inventario->syncStock(); $catalogItem->syncStock();
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'serial' => $serial->fresh(), 'serial' => $serial->fresh(),
'inventory' => $inventario->fresh(), 'catalogItem' => $catalogItem->fresh(),
]); ]);
} }
/** /**
* Eliminar un serial * Eliminar un serial
*/ */
public function destroy(Inventory $inventario, InventorySerial $serial) public function destroy(CatalogItem $catalogItem, InventorySerial $serial)
{ {
// Verificar que el serial pertenece al inventario // Verificar que el serial pertenece al inventario
if ($serial->inventory_id !== $inventario->id) { if ($serial->catalog_item_id !== $catalogItem->id) {
return ApiResponse::NOT_FOUND->response([ return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario' 'message' => 'Serial no encontrado para este inventario'
]); ]);
@ -129,11 +129,10 @@ public function destroy(Inventory $inventario, InventorySerial $serial)
$serial->delete(); $serial->delete();
// Sincronizar stock // Sincronizar stock
$inventario->syncStock(); $catalogItem->syncStock();
return ApiResponse::OK->response([ return ApiResponse::OK->response([
'message' => 'Serial eliminado exitosamente', 'message' => 'Serial eliminado exitosamente',
'inventory' => $inventario->fresh(), 'catalogItem' => $catalogItem->fresh(),
]); ]);
} }
} }

View File

@ -4,7 +4,7 @@
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class InventoryImportRequest extends FormRequest class CatalogItemImportRequest extends FormRequest
{ {
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.

View File

@ -2,7 +2,7 @@
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class InventoryStoreRequest extends FormRequest class CatalogItemStoreRequest extends FormRequest
{ {
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
@ -25,6 +25,12 @@ public function rules(): array
'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'], 'stock' => ['nullable', 'integer', 'min:0'],
'type' => ['nullable', 'string', 'in:product,service,laundry'],
'description' => ['nullable', 'string'],
'is_stockable' => ['nullable', 'boolean'],
'track_serials' => ['nullable', 'boolean'],
'attributes' => ['nullable', 'array'],
// Campos de Price // Campos de Price
'cost' => ['required', 'numeric', 'min:0'], 'cost' => ['required', 'numeric', 'min:0'],
@ -48,6 +54,10 @@ public function messages(): array
'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.', 'stock.min' => 'El stock no puede ser negativo.',
'type.in' => 'El tipo debe ser uno de los siguientes: product, service, laundry.',
'is_stockable.boolean' => 'El campo "es stockeable" debe ser verdadero o falso.',
'track_serials.boolean' => 'El campo "rastrear números de serie" debe ser verdadero o falso.',
'attributes.array' => 'Los atributos deben ser un arreglo.',
// Mensajes de Price // Mensajes de Price
'cost.required' => 'El costo es obligatorio.', 'cost.required' => 'El costo es obligatorio.',

View File

@ -2,7 +2,7 @@
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
class InventoryUpdateRequest extends FormRequest class CatalogItemUpdateRequest extends FormRequest
{ {
/** /**
* Determine if the user is authorized to make this request. * Determine if the user is authorized to make this request.
@ -27,6 +27,11 @@ public function rules(): array
'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'], 'stock' => ['nullable', 'integer', 'min:0'],
'type' => ['nullable', 'string', 'in:product,service,laundry'],
'description' => ['nullable', 'string'],
'is_stockable' => ['nullable', 'boolean'],
'track_serials' => ['nullable', 'boolean'],
'attributes' => ['nullable', 'array'],
// Campos de Price // Campos de Price
'cost' => ['nullable', 'numeric', 'min:0'], 'cost' => ['nullable', 'numeric', 'min:0'],
@ -48,6 +53,10 @@ 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.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.', 'stock.min' => 'El stock no puede ser negativo.',
'type.in' => 'El tipo debe ser uno de los siguientes: product, service, laundry.',
'is_stockable.boolean' => 'El campo "es stockeable" debe ser verdadero o falso.',
'track_serials.boolean' => 'El campo "rastrear números de serie" debe ser verdadero o falso.',
'attributes.array' => 'Los atributos deben ser un arreglo.',
// Mensajes de Price // Mensajes de Price
'cost.numeric' => 'El costo debe ser un número.', 'cost.numeric' => 'El costo debe ser un número.',

View File

@ -2,10 +2,10 @@
namespace App\Imports; namespace App\Imports;
use App\Models\Inventory; use App\Models\CatalogItem;
use App\Models\Price; use App\Models\Price;
use App\Models\Category; use App\Models\Category;
use App\Http\Requests\App\InventoryImportRequest; use App\Http\Requests\App\CatalogItemImportRequest;
use App\Models\InventorySerial; use App\Models\InventorySerial;
use Maatwebsite\Excel\Concerns\ToModel; use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow; use Maatwebsite\Excel\Concerns\WithHeadingRow;
@ -68,10 +68,10 @@ public function model(array $row)
// Buscar producto existente por SKU o código de barras // Buscar producto existente por SKU o código de barras
$existingInventory = null; $existingInventory = null;
if (!empty($row['sku'])) { if (!empty($row['sku'])) {
$existingInventory = Inventory::where('sku', trim($row['sku']))->first(); $existingInventory = CatalogItem::where('sku', trim($row['sku']))->first();
} }
if (!$existingInventory && !empty($row['codigo_barras'])) { if (!$existingInventory && !empty($row['codigo_barras'])) {
$existingInventory = Inventory::where('barcode', trim($row['codigo_barras']))->first(); $existingInventory = CatalogItem::where('barcode', trim($row['codigo_barras']))->first();
} }
// Si el producto ya existe, solo agregar stock y seriales // Si el producto ya existe, solo agregar stock y seriales
@ -100,29 +100,29 @@ public function model(array $row)
} }
// Crear el producto en inventario // Crear el producto en inventario
$inventory = new Inventory(); $catalogItem = new CatalogItem();
$inventory->name = trim($row['nombre']); $catalogItem->name = trim($row['nombre']);
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null; $catalogItem->sku = !empty($row['sku']) ? trim($row['sku']) : null;
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null; $catalogItem->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
$inventory->category_id = $categoryId; $catalogItem->category_id = $categoryId;
$inventory->stock = 0; $catalogItem->stock = 0;
$inventory->is_active = true; $catalogItem->is_active = true;
$inventory->save(); $catalogItem->save();
// Crear el precio del producto // Crear el precio del producto
Price::create([ Price::create([
'inventory_id' => $inventory->id, 'catalog_item_id' => $catalogItem->id,
'cost' => $costo, 'cost' => $costo,
'retail_price' => $precioVenta, 'retail_price' => $precioVenta,
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0, 'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0,
]); ]);
// Crear números de serie si se proporcionan // Crear números de serie si se proporcionan
$this->addSerials($inventory, $row['numeros_serie'] ?? null, $row['stock'] ?? 0); $this->addSerials($catalogItem, $row['numeros_serie'] ?? null, $row['stock'] ?? 0);
$this->imported++; $this->imported++;
return $inventory; return $catalogItem;
} catch (\Exception $e) { } catch (\Exception $e) {
$this->skipped++; $this->skipped++;
$this->errors[] = "Error en fila: " . $e->getMessage(); $this->errors[] = "Error en fila: " . $e->getMessage();
@ -133,7 +133,7 @@ public function model(array $row)
/** /**
* Actualiza un producto existente: suma stock y agrega seriales nuevos * Actualiza un producto existente: suma stock y agrega seriales nuevos
*/ */
private function updateExistingProduct(Inventory $inventory, array $row) private function updateExistingProduct(CatalogItem $catalogItem, array $row)
{ {
$serialsAdded = 0; $serialsAdded = 0;
$serialsSkipped = 0; $serialsSkipped = 0;
@ -151,7 +151,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
if (!$exists) { if (!$exists) {
InventorySerial::create([ InventorySerial::create([
'inventory_id' => $inventory->id, 'catalog_item_id' => $catalogItem->id,
'serial_number' => $serial, 'serial_number' => $serial,
'status' => 'disponible', 'status' => 'disponible',
]); ]);
@ -162,17 +162,17 @@ private function updateExistingProduct(Inventory $inventory, array $row)
} }
// Sincronizar stock basado en seriales disponibles // Sincronizar stock basado en seriales disponibles
$inventory->syncStock(); $catalogItem->syncStock();
} else { } else {
// Producto sin seriales: sumar stock // Producto sin seriales: sumar stock
$stockToAdd = (int) ($row['stock'] ?? 0); $stockToAdd = (int) ($row['stock'] ?? 0);
$inventory->increment('stock', $stockToAdd); $catalogItem->increment('stock', $stockToAdd);
} }
$this->updated++; $this->updated++;
if ($serialsSkipped > 0) { if ($serialsSkipped > 0) {
$this->errors[] = "Producto '{$inventory->name}': {$serialsSkipped} seriales ya existían y fueron ignorados"; $this->errors[] = "Producto '{$catalogItem->name}': {$serialsSkipped} seriales ya existían y fueron ignorados";
} }
return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo
@ -181,7 +181,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
/** /**
* Agrega seriales a un producto nuevo * Agrega seriales a un producto nuevo
*/ */
private function addSerials(Inventory $inventory, ?string $serialsString, int $stockFromExcel) private function addSerials(CatalogItem $catalogItem, ?string $serialsString, int $stockFromExcel)
{ {
if (!empty($serialsString)) { if (!empty($serialsString)) {
$serials = explode(',', $serialsString); $serials = explode(',', $serialsString);
@ -190,17 +190,17 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
$serial = trim($serial); $serial = trim($serial);
if (!empty($serial)) { if (!empty($serial)) {
InventorySerial::create([ InventorySerial::create([
'inventory_id' => $inventory->id, 'catalog_item_id' => $catalogItem->id,
'serial_number' => $serial, 'serial_number' => $serial,
'status' => 'disponible', 'status' => 'disponible',
]); ]);
} }
} }
$inventory->syncStock(); $catalogItem->syncStock();
} else { } else {
// Producto sin seriales // Producto sin seriales
$inventory->stock = $stockFromExcel; $catalogItem->stock = $stockFromExcel;
$inventory->save(); $catalogItem->save();
} }
} }
@ -209,7 +209,7 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
*/ */
public function rules(): array public function rules(): array
{ {
return InventoryImportRequest::rowRules(); return CatalogItemImportRequest::rowRules();
} }
/** /**
@ -217,7 +217,7 @@ public function rules(): array
*/ */
public function customValidationMessages() public function customValidationMessages()
{ {
return InventoryImportRequest::rowMessages(); return CatalogItemImportRequest::rowMessages();
} }
/** /**

149
app/Models/CatalogItem.php Normal file
View File

@ -0,0 +1,149 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class CatalogItem extends Model
{
use SoftDeletes;
protected $fillable = [
'type',
'category_id',
'name',
'description',
'sku',
'barcode',
'is_stockable',
'stock',
'track_serials',
'attributes',
'is_active',
];
protected $casts = [
'is_stockable' => 'boolean',
'track_serials' => 'boolean',
'is_active' => 'boolean',
'attributes' => 'array',
];
protected $appends = ['has_serials'];
// RELACIONES
public function category()
{
return $this->belongsTo(Category::class);
}
public function price()
{
return $this->hasOne(Price::class);
}
public function serials()
{
return $this->hasMany(InventorySerial::class);
}
public function availableSerials()
{
return $this->hasMany(InventorySerial::class)
->where('status', 'disponible');
}
// HELPERS DE TIPO
public function isProduct(): bool
{
return $this->type === 'product';
}
public function isService(): bool
{
return $this->type === 'service';
}
public function isLaundry(): bool
{
return $this->type === 'laundry';
}
// HELPERS DE STOCK
public function getAvailableStockAttribute(): int
{
if (!$this->is_stockable) {
return 0;
}
if ($this->track_serials) {
return $this->availableSerials()->count();
}
return $this->stock ?? 0;
}
public function syncStock(): void
{
if ($this->is_stockable && $this->track_serials) {
$this->update(['stock' => $this->availableSerials()->count()]);
}
}
public function decrementStock(int $quantity): void
{
if ($this->is_stockable && !$this->track_serials) {
$this->decrement('stock', $quantity);
}
}
public function incrementStock(int $quantity): void
{
if ($this->is_stockable && !$this->track_serials) {
$this->increment('stock', $quantity);
}
}
public function getHasSerialsAttribute(): bool
{
return $this->track_serials &&
isset($this->attributes['serials_count']) &&
$this->attributes['serials_count'] > 0;
}
//HELPERS DE ATRIBUTOS
public function getAtributos(string $key, $default = null)
{
return $this->attributes[$key] ?? $default;
}
//SCOPES
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function scopeOfType($query, string $type)
{
return $query->where('type', $type);
}
public function scopeProducts($query)
{
return $query->where('type', 'product');
}
public function scopeServices($query)
{
return $query->where('type', 'service');
}
public function scopeStockable($query)
{
return $query->where('is_stockable', true);
}
}

View File

@ -25,8 +25,8 @@ class Category extends Model
'is_active' => 'boolean', 'is_active' => 'boolean',
]; ];
public function inventories() public function catalogItems()
{ {
return $this->hasMany(Inventory::class); return $this->hasMany(CatalogItem::class);
} }
} }

View File

@ -1,77 +0,0 @@
<?php namespace App\Models;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use Illuminate\Database\Eloquent\Model;
/**
* Descripción
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
*
* @version 1.0.0
*/
class Inventory extends Model
{
protected $fillable = [
'category_id',
'name',
'sku',
'barcode',
'stock',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
protected $appends = ['has_serials'];
public function category()
{
return $this->belongsTo(Category::class);
}
public function price()
{
return $this->hasOne(Price::class);
}
public function serials()
{
return $this->hasMany(InventorySerial::class);
}
/**
* Obtener seriales disponibles
*/
public function availableSerials()
{
return $this->hasMany(InventorySerial::class)
->where('status', 'disponible');
}
/**
* Calcular stock basado en seriales disponibles
*/
public function getAvailableStockAttribute(): int
{
return $this->availableSerials()->count();
}
/**
* Sincronizar el campo stock con los seriales disponibles
*/
public function syncStock(): void
{
$this->update(['stock' => $this->getAvailableStockAttribute()]);
}
public function getHasSerialsAttribute(): bool
{
return isset($this->attributes['serials_count']) && $this->attributes['serials_count'] > 0;
}
}

View File

@ -2,16 +2,10 @@
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
/**
* Modelo para números de serie de inventario
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
* @version 1.0.0
*/
class InventorySerial extends Model class InventorySerial extends Model
{ {
protected $fillable = [ protected $fillable = [
'inventory_id', 'catalog_item_id',
'serial_number', 'serial_number',
'status', 'status',
'sale_detail_id', 'sale_detail_id',
@ -22,9 +16,10 @@ class InventorySerial extends Model
'status' => 'string', 'status' => 'string',
]; ];
public function inventory() //RELACIONES
public function catalogItem()
{ {
return $this->belongsTo(Inventory::class); return $this->belongsTo(CatalogItem::class);
} }
public function saleDetail() public function saleDetail()
@ -32,17 +27,12 @@ public function saleDetail()
return $this->belongsTo(SaleDetail::class); return $this->belongsTo(SaleDetail::class);
} }
/** //HELPERS
* Verificar si el serial está disponible
*/
public function isAvailable(): bool public function isAvailable(): bool
{ {
return $this->status === 'disponible'; return $this->status === 'disponible';
} }
/**
* Marcar como vendido
*/
public function markAsSold(int $saleDetailId): void public function markAsSold(int $saleDetailId): void
{ {
$this->update([ $this->update([
@ -51,9 +41,6 @@ public function markAsSold(int $saleDetailId): void
]); ]);
} }
/**
* Marcar como disponible (ej: cancelación de venta)
*/
public function markAsAvailable(): void public function markAsAvailable(): void
{ {
$this->update([ $this->update([

View File

@ -16,7 +16,7 @@
class Price extends Model class Price extends Model
{ {
protected $fillable = [ protected $fillable = [
'inventory_id', 'catalog_itme_id',
'cost', 'cost',
'retail_price', 'retail_price',
'tax', 'tax',
@ -28,8 +28,8 @@ class Price extends Model
'tax' => 'decimal:2', 'tax' => 'decimal:2',
]; ];
public function inventory() public function catalogItem()
{ {
return $this->belongsTo(Inventory::class); return $this->belongsTo(CatalogItem::class);
} }
} }

View File

@ -17,7 +17,7 @@ class SaleDetail extends Model
{ {
protected $fillable = [ protected $fillable = [
'sale_id', 'sale_id',
'inventory_id', 'catalog_item_id',
'product_name', 'product_name',
'quantity', 'quantity',
'unit_price', 'unit_price',
@ -34,9 +34,9 @@ public function sale()
return $this->belongsTo(Sale::class); return $this->belongsTo(Sale::class);
} }
public function inventory() public function catalogItem()
{ {
return $this->belongsTo(Inventory::class); return $this->belongsTo(CatalogItem::class);
} }
public function serials() public function serials()

View File

@ -0,0 +1,36 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class SettingsGlobal extends Model
{
protected $table = 'settings_global';
protected $fillable = [
'business_name',
'logo_path',
'contact_info',
'invoice',
];
protected $casts = [
'contact_info' => 'array',
'invoice' => 'array',
];
//HELPERS
public static function get(): ?self
{
return self::first();
}
public function getContactValue(string $key, $default = null)
{
return $this->contact_info[$key] ?? $default;
}
public function getInvoiceValue(string $key, $default = null)
{
return $this->invoice[$key] ?? $default;
}
}

View File

@ -1,6 +1,6 @@
<?php namespace App\Services; <?php namespace App\Services;
use App\Models\Inventory; use App\Models\CatalogItem;
use App\Models\Price; use App\Models\Price;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -9,7 +9,7 @@ class ProductService
public function createProduct(array $data) public function createProduct(array $data)
{ {
return DB::transaction(function () use ($data) { return DB::transaction(function () use ($data) {
$inventory = Inventory::create([ $catalogItem = CatalogItem::create([
'name' => $data['name'], 'name' => $data['name'],
'sku' => $data['sku'], 'sku' => $data['sku'],
'barcode' => $data['barcode'] ?? null, 'barcode' => $data['barcode'] ?? null,
@ -18,21 +18,21 @@ public function createProduct(array $data)
]); ]);
$price = Price::create([ $price = Price::create([
'inventory_id' => $inventory->id, 'catalog_item_id' => $catalogItem->id,
'cost' => $data['cost'], 'cost' => $data['cost'],
'retail_price' => $data['retail_price'], 'retail_price' => $data['retail_price'],
'tax' => $data['tax'] ?? 16.00, 'tax' => $data['tax'] ?? 16.00,
]); ]);
return $inventory->load(['category', 'price']); return $catalogItem->load(['category', 'price']);
}); });
} }
public function updateProduct(Inventory $inventory, array $data) public function updateProduct(CatalogItem $catalogItem, array $data)
{ {
return DB::transaction(function () use ($inventory, $data) { return DB::transaction(function () use ($catalogItem, $data) {
// Actualizar campos de Inventory solo si están presentes // Actualizar campos de CatalogItem solo si están presentes
$inventoryData = array_filter([ $catalogItemData = array_filter([
'name' => $data['name'] ?? null, 'name' => $data['name'] ?? null,
'sku' => $data['sku'] ?? null, 'sku' => $data['sku'] ?? null,
'barcode' => $data['barcode'] ?? null, 'barcode' => $data['barcode'] ?? null,
@ -40,8 +40,8 @@ public function updateProduct(Inventory $inventory, array $data)
'stock' => $data['stock'] ?? null, 'stock' => $data['stock'] ?? null,
], fn($value) => $value !== null); ], fn($value) => $value !== null);
if (!empty($inventoryData)) { if (!empty($catalogItemData)) {
$inventory->update($inventoryData); $catalogItem->update($catalogItemData);
} }
// Actualizar campos de Price solo si están presentes // Actualizar campos de Price solo si están presentes
@ -52,13 +52,13 @@ public function updateProduct(Inventory $inventory, array $data)
], fn($value) => $value !== null); ], fn($value) => $value !== null);
if (!empty($priceData)) { if (!empty($priceData)) {
$inventory->price()->updateOrCreate( $catalogItem->price()->updateOrCreate(
['inventory_id' => $inventory->id], ['catalog_item_id' => $catalogItem->id],
$priceData $priceData
); );
} }
return $inventory->fresh(['category', 'price']); return $catalogItem->fresh(['category', 'price']);
}); });
} }
} }

View File

@ -2,7 +2,7 @@
namespace App\Services; namespace App\Services;
use App\Models\Inventory; use App\Models\CatalogItem;
use App\Models\SaleDetail; use App\Models\SaleDetail;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -19,18 +19,18 @@ public function getTopSellingProduct(?string $fromDate = null, ?string $toDate =
{ {
$query = SaleDetail::query() $query = SaleDetail::query()
->selectRaw(' ->selectRaw('
inventories.id, catalog_items.id,
inventories.name, catalog_items.name,
inventories.sku, catalog_items.sku,
categories.name as category_name, categories.name as category_name,
SUM(sale_details.quantity) as total_quantity_sold, SUM(sale_details.quantity) as total_quantity_sold,
SUM(sale_details.subtotal) as total_revenue, SUM(sale_details.subtotal) as total_revenue,
COUNT(DISTINCT sale_details.sale_id) as times_sold, COUNT(DISTINCT sale_details.sale_id) as times_sold,
MAX(sales.created_at) as last_sale_date, MAX(sales.created_at) as last_sale_date,
inventories.created_at as added_date catalog_items.created_at as added_date
') ')
->join('inventories', 'sale_details.inventory_id', '=', 'inventories.id') ->join('catalog_items', 'sale_details.catalog_item_id', '=', 'catalog_items.id')
->join('categories', 'inventories.category_id', '=', 'categories.id') ->join('categories', 'catalog_items.category_id', '=', 'categories.id')
->join('sales', 'sale_details.sale_id', '=', 'sales.id') ->join('sales', 'sale_details.sale_id', '=', 'sales.id')
->where('sales.status', 'completed') ->where('sales.status', 'completed')
->whereNull('sales.deleted_at'); ->whereNull('sales.deleted_at');
@ -41,8 +41,8 @@ public function getTopSellingProduct(?string $fromDate = null, ?string $toDate =
} }
$result = $query $result = $query
->groupBy('inventories.id', 'inventories.name', 'inventories.sku', ->groupBy('catalog_items.id', 'catalog_items.name', 'catalog_items.sku',
'categories.name', 'inventories.created_at') 'categories.name', 'catalog_items.created_at')
->orderByDesc('total_quantity_sold') ->orderByDesc('total_quantity_sold')
->first(); ->first();
@ -65,35 +65,35 @@ public function getProductsWithoutMovement(bool $includeStockValue = true, ?stri
->whereNull('sales.deleted_at') ->whereNull('sales.deleted_at')
->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate]) ->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate])
->distinct() ->distinct()
->pluck('sale_details.inventory_id') ->pluck('sale_details.catalog_item_id')
->toArray(); ->toArray();
// Construir query para productos SIN ventas // Construir query para productos SIN ventas
$query = Inventory::query() $query = CatalogItem::query()
->selectRaw(' ->selectRaw('
inventories.id, catalog_items.id,
inventories.name, catalog_items.name,
inventories.sku, catalog_items.sku,
inventories.stock, catalog_items.stock,
categories.name as category_name, categories.name as category_name,
inventories.created_at as date_added, catalog_items.created_at as date_added,
prices.retail_price prices.retail_price
'); ');
// Agregar valor del inventario si se solicita // Agregar valor del inventario si se solicita
if ($includeStockValue) { if ($includeStockValue) {
$query->addSelect( $query->addSelect(
DB::raw('(inventories.stock * COALESCE(prices.cost, 0)) as inventory_value') DB::raw('(catalog_items.stock * COALESCE(prices.cost, 0)) as inventory_value')
); );
} }
return $query return $query
->leftJoin('categories', 'inventories.category_id', '=', 'categories.id') ->leftJoin('categories', 'catalog_items.category_id', '=', 'categories.id')
->leftJoin('prices', 'inventories.id', '=', 'prices.inventory_id') ->leftJoin('prices', 'catalog_items.id', '=', 'prices.catalog_item_id')
->where('inventories.is_active', true) ->where('catalog_items.is_active', true)
->whereNull('inventories.deleted_at') ->whereNull('catalog_items.deleted_at')
->whereNotIn('inventories.id', $inventoriesWithSales) ->whereNotIn('catalog_items.id', $inventoriesWithSales)
->orderBy('inventories.created_at') ->orderBy('catalog_items.created_at')
->get() ->get()
->toArray(); ->toArray();
} }

View File

@ -1,9 +1,9 @@
<?php namespace App\Services; <?php namespace App\Services;
use App\Models\CashRegister; use App\Models\CashRegister;
use App\Models\CatalogItem;
use App\Models\Sale; use App\Models\Sale;
use App\Models\SaleDetail; use App\Models\SaleDetail;
use App\Models\Inventory;
use App\Models\InventorySerial; use App\Models\InventorySerial;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -44,7 +44,7 @@ public function createSale(array $data)
// Crear detalle de venta // Crear detalle de venta
$saleDetail = SaleDetail::create([ $saleDetail = SaleDetail::create([
'sale_id' => $sale->id, 'sale_id' => $sale->id,
'inventory_id' => $item['inventory_id'], 'catalog_item_id' => $item['catalog_item_id'],
'product_name' => $item['product_name'], 'product_name' => $item['product_name'],
'quantity' => $item['quantity'], 'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'], 'unit_price' => $item['unit_price'],
@ -53,13 +53,13 @@ public function createSale(array $data)
]); ]);
// Obtener el inventario // Obtener el inventario
$inventory = Inventory::find($item['inventory_id']); $catalogItem = CatalogItem::find($item['catalog_item']);
if ($inventory) { if ($catalogItem && $catalogItem->is_stockable) {
// Si se proporcionaron números de serie específicos // Si se proporcionaron números de serie específicos
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) { if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
foreach ($item['serial_numbers'] as $serialNumber) { foreach ($item['serial_numbers'] as $serialNumber) {
$serial = InventorySerial::where('inventory_id', $inventory->id) $serial = InventorySerial::where('catalog_item_id', $catalogItem->id)
->where('serial_number', $serialNumber) ->where('serial_number', $serialNumber)
->where('status', 'disponible') ->where('status', 'disponible')
->first(); ->first();
@ -72,7 +72,7 @@ public function createSale(array $data)
} }
} else { } else {
// Asignar automáticamente los primeros N seriales disponibles // Asignar automáticamente los primeros N seriales disponibles
$serials = InventorySerial::where('inventory_id', $inventory->id) $serials = InventorySerial::where('catalog_item_id', $catalogItem->id)
->where('status', 'disponible') ->where('status', 'disponible')
->limit($item['quantity']) ->limit($item['quantity'])
->get(); ->get();
@ -87,12 +87,12 @@ public function createSale(array $data)
} }
// Sincronizar el stock // Sincronizar el stock
$inventory->syncStock(); $catalogItem->syncStock();
} }
} }
// 3. Retornar la venta con sus relaciones cargadas // 3. Retornar la venta con sus relaciones cargadas
return $sale->load(['details.inventory', 'details.serials', 'user']); return $sale->load(['details.catalogItem', 'details.serials', 'user']);
}); });
} }
@ -117,13 +117,13 @@ public function cancelSale(Sale $sale)
} }
// Sincronizar stock // Sincronizar stock
$detail->inventory->syncStock(); $detail->catalogItem->syncStock();
} }
// Marcar venta como cancelada // Marcar venta como cancelada
$sale->update(['status' => 'cancelled']); $sale->update(['status' => 'cancelled']);
return $sale->fresh(['details.inventory', 'details.serials', 'user']); return $sale->fresh(['details.catalogItem', 'details.serials', 'user']);
}); });
} }

View File

@ -0,0 +1,87 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::rename('inventories', 'catalog_items');
Schema::table('catalog_items', function (Blueprint $table) {
$table->string('type', 50)->default('product')->after('id');
$table->boolean('is_stockable')->default(false)->after('barcode');
$table->boolean('track_serials')->default(false)->after('stock');
$table->json('attributes')->nullable()->after('track_serials');
$table->text('description')->nullable()->after('name');
$table->string('sku')->nullable()->change();
$table->integer('stock')->nullable()->change();
});
Schema::table('prices', function (Blueprint $table) {
$table->dropForeign(['inventory_id']);
$table->renameColumn('inventory_id', 'catalog_item_id');
});
Schema::table('prices', function (Blueprint $table) {
$table->foreign('catalog_item_id')
->references('id')
->on('catalog_items')
->onDelete('cascade');
});
Schema::table('inventory_serials', function (Blueprint $table) {
$table->dropForeign(['inventory_id']);
$table->renameColumn('inventory_id', 'catalog_item_id');
});
Schema::table('inventory_serials', function (Blueprint $table) {
$table->foreign('catalog_item_id')
->references('id')
->on('catalog_items')
->onDelete('cascade');
});
Schema::table('sale_details', function (Blueprint $table) {
$table->dropForeign(['inventory_id']);
$table->renameColumn('inventory_id', 'catalog_item_id');
});
Schema::table('sale_details', function (Blueprint $table) {
$table->foreign('catalog_item_id')
->references('id')
->on('catalog_items')
->onDelete('cascade');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table) {
// Quitar lo agregado
$table->dropColumn([
'type',
'is_stockable',
'track_serials',
'attributes',
'description',
]);
// Revertir cambios (ajusta si antes eran NOT NULL)
$table->string('sku')->nullable(false)->change();
$table->integer('stock')->nullable(false)->change();
});
Schema::rename('catalog_items', 'inventories');
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('settings_global', function (Blueprint $table) {
$table->id();
$table->string('business_name', 255);
$table->string('logo_path', 255)->nullable();
$table->json('contact_info')->nullable();
$table->json('invoice')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('settings_global');
}
};