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;
use App\Models\Inventory;
use App\Models\CatalogItem;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\InventoryStoreRequest;
use App\Http\Requests\App\InventoryUpdateRequest;
use App\Http\Requests\App\InventoryImportRequest;
use App\Http\Requests\App\CatalogItemStoreRequest;
use App\Http\Requests\App\CatalogItemUpdateRequest;
use App\Http\Requests\App\CatalogItemImportRequest;
use App\Services\ProductService;
use App\Imports\ProductsImport;
use Illuminate\Http\Request;
@ -15,7 +15,7 @@
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\Exportable;
class InventoryController extends Controller
class CatalogItemController extends Controller
{
public function __construct(
protected ProductService $productService
@ -23,7 +23,7 @@ public function __construct(
public function index(Request $request)
{
$products = Inventory::with(['category', 'price'])->withCount('serials')
$products = CatalogItem::with(['category', 'price'])->withCount('serials')
->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([
'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());
@ -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([
'model' => $product
]);
}
public function destroy(Inventory $inventario)
public function destroy(CatalogItem $catalogItem)
{
$inventario->delete();
$catalogItem->delete();
return ApiResponse::OK->response();
}
/**
* Importar productos desde Excel
*/
public function import(InventoryImportRequest $request)
public function import(CatalogItemImportRequest $request)
{
try {
$import = new ProductsImport();

View File

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

View File

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

View File

@ -2,7 +2,7 @@
use Illuminate\Foundation\Http\FormRequest;
class InventoryStoreRequest extends FormRequest
class CatalogItemStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
@ -25,6 +25,12 @@ public function rules(): array
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
'category_id' => ['required', 'exists:categories,id'],
'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
'cost' => ['required', 'numeric', 'min:0'],
@ -48,6 +54,10 @@ public function messages(): array
'category_id.required' => 'La categoría es obligatoria.',
'category_id.exists' => 'La categoría seleccionada no es válida.',
'stock.min' => 'El stock no puede ser negativo.',
'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
'cost.required' => 'El costo es obligatorio.',

View File

@ -2,7 +2,7 @@
use Illuminate\Foundation\Http\FormRequest;
class InventoryUpdateRequest extends FormRequest
class CatalogItemUpdateRequest extends FormRequest
{
/**
* 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],
'category_id' => ['nullable', 'exists:categories,id'],
'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
'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.',
'category_id.exists' => 'La categoría seleccionada no es válida.',
'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
'cost.numeric' => 'El costo debe ser un número.',

View File

@ -2,10 +2,10 @@
namespace App\Imports;
use App\Models\Inventory;
use App\Models\CatalogItem;
use App\Models\Price;
use App\Models\Category;
use App\Http\Requests\App\InventoryImportRequest;
use App\Http\Requests\App\CatalogItemImportRequest;
use App\Models\InventorySerial;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
@ -68,10 +68,10 @@ public function model(array $row)
// Buscar producto existente por SKU o código de barras
$existingInventory = null;
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'])) {
$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
@ -100,29 +100,29 @@ public function model(array $row)
}
// Crear el producto en inventario
$inventory = new Inventory();
$inventory->name = trim($row['nombre']);
$inventory->sku = !empty($row['sku']) ? trim($row['sku']) : null;
$inventory->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
$inventory->category_id = $categoryId;
$inventory->stock = 0;
$inventory->is_active = true;
$inventory->save();
$catalogItem = new CatalogItem();
$catalogItem->name = trim($row['nombre']);
$catalogItem->sku = !empty($row['sku']) ? trim($row['sku']) : null;
$catalogItem->barcode = !empty($row['codigo_barras']) ? trim($row['codigo_barras']) : null;
$catalogItem->category_id = $categoryId;
$catalogItem->stock = 0;
$catalogItem->is_active = true;
$catalogItem->save();
// Crear el precio del producto
Price::create([
'inventory_id' => $inventory->id,
'catalog_item_id' => $catalogItem->id,
'cost' => $costo,
'retail_price' => $precioVenta,
'tax' => !empty($row['impuesto']) ? (float) $row['impuesto'] : 0,
]);
// 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++;
return $inventory;
return $catalogItem;
} catch (\Exception $e) {
$this->skipped++;
$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
*/
private function updateExistingProduct(Inventory $inventory, array $row)
private function updateExistingProduct(CatalogItem $catalogItem, array $row)
{
$serialsAdded = 0;
$serialsSkipped = 0;
@ -151,7 +151,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
if (!$exists) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'catalog_item_id' => $catalogItem->id,
'serial_number' => $serial,
'status' => 'disponible',
]);
@ -162,17 +162,17 @@ private function updateExistingProduct(Inventory $inventory, array $row)
}
// Sincronizar stock basado en seriales disponibles
$inventory->syncStock();
$catalogItem->syncStock();
} else {
// Producto sin seriales: sumar stock
$stockToAdd = (int) ($row['stock'] ?? 0);
$inventory->increment('stock', $stockToAdd);
$catalogItem->increment('stock', $stockToAdd);
}
$this->updated++;
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
@ -181,7 +181,7 @@ private function updateExistingProduct(Inventory $inventory, array $row)
/**
* 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)) {
$serials = explode(',', $serialsString);
@ -190,17 +190,17 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
$serial = trim($serial);
if (!empty($serial)) {
InventorySerial::create([
'inventory_id' => $inventory->id,
'catalog_item_id' => $catalogItem->id,
'serial_number' => $serial,
'status' => 'disponible',
]);
}
}
$inventory->syncStock();
$catalogItem->syncStock();
} else {
// Producto sin seriales
$inventory->stock = $stockFromExcel;
$inventory->save();
$catalogItem->stock = $stockFromExcel;
$catalogItem->save();
}
}
@ -209,7 +209,7 @@ private function addSerials(Inventory $inventory, ?string $serialsString, int $s
*/
public function rules(): array
{
return InventoryImportRequest::rowRules();
return CatalogItemImportRequest::rowRules();
}
/**
@ -217,7 +217,7 @@ public function rules(): array
*/
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',
];
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;
/**
* 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
{
protected $fillable = [
'inventory_id',
'catalog_item_id',
'serial_number',
'status',
'sale_detail_id',
@ -22,9 +16,10 @@ class InventorySerial extends Model
'status' => 'string',
];
public function inventory()
//RELACIONES
public function catalogItem()
{
return $this->belongsTo(Inventory::class);
return $this->belongsTo(CatalogItem::class);
}
public function saleDetail()
@ -32,17 +27,12 @@ public function saleDetail()
return $this->belongsTo(SaleDetail::class);
}
/**
* Verificar si el serial está disponible
*/
//HELPERS
public function isAvailable(): bool
{
return $this->status === 'disponible';
}
/**
* Marcar como vendido
*/
public function markAsSold(int $saleDetailId): void
{
$this->update([
@ -51,9 +41,6 @@ public function markAsSold(int $saleDetailId): void
]);
}
/**
* Marcar como disponible (ej: cancelación de venta)
*/
public function markAsAvailable(): void
{
$this->update([

View File

@ -16,7 +16,7 @@
class Price extends Model
{
protected $fillable = [
'inventory_id',
'catalog_itme_id',
'cost',
'retail_price',
'tax',
@ -28,8 +28,8 @@ class Price extends Model
'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 = [
'sale_id',
'inventory_id',
'catalog_item_id',
'product_name',
'quantity',
'unit_price',
@ -34,9 +34,9 @@ public function sale()
return $this->belongsTo(Sale::class);
}
public function inventory()
public function catalogItem()
{
return $this->belongsTo(Inventory::class);
return $this->belongsTo(CatalogItem::class);
}
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;
use App\Models\Inventory;
use App\Models\CatalogItem;
use App\Models\Price;
use Illuminate\Support\Facades\DB;
@ -9,7 +9,7 @@ class ProductService
public function createProduct(array $data)
{
return DB::transaction(function () use ($data) {
$inventory = Inventory::create([
$catalogItem = CatalogItem::create([
'name' => $data['name'],
'sku' => $data['sku'],
'barcode' => $data['barcode'] ?? null,
@ -18,21 +18,21 @@ public function createProduct(array $data)
]);
$price = Price::create([
'inventory_id' => $inventory->id,
'catalog_item_id' => $catalogItem->id,
'cost' => $data['cost'],
'retail_price' => $data['retail_price'],
'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) {
// Actualizar campos de Inventory solo si están presentes
$inventoryData = array_filter([
return DB::transaction(function () use ($catalogItem, $data) {
// Actualizar campos de CatalogItem solo si están presentes
$catalogItemData = array_filter([
'name' => $data['name'] ?? null,
'sku' => $data['sku'] ?? null,
'barcode' => $data['barcode'] ?? null,
@ -40,8 +40,8 @@ public function updateProduct(Inventory $inventory, array $data)
'stock' => $data['stock'] ?? null,
], fn($value) => $value !== null);
if (!empty($inventoryData)) {
$inventory->update($inventoryData);
if (!empty($catalogItemData)) {
$catalogItem->update($catalogItemData);
}
// Actualizar campos de Price solo si están presentes
@ -52,13 +52,13 @@ public function updateProduct(Inventory $inventory, array $data)
], fn($value) => $value !== null);
if (!empty($priceData)) {
$inventory->price()->updateOrCreate(
['inventory_id' => $inventory->id],
$catalogItem->price()->updateOrCreate(
['catalog_item_id' => $catalogItem->id],
$priceData
);
}
return $inventory->fresh(['category', 'price']);
return $catalogItem->fresh(['category', 'price']);
});
}
}

View File

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

View File

@ -1,9 +1,9 @@
<?php namespace App\Services;
use App\Models\CashRegister;
use App\Models\CatalogItem;
use App\Models\Sale;
use App\Models\SaleDetail;
use App\Models\Inventory;
use App\Models\InventorySerial;
use Illuminate\Support\Facades\DB;
@ -44,7 +44,7 @@ public function createSale(array $data)
// Crear detalle de venta
$saleDetail = SaleDetail::create([
'sale_id' => $sale->id,
'inventory_id' => $item['inventory_id'],
'catalog_item_id' => $item['catalog_item_id'],
'product_name' => $item['product_name'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
@ -53,13 +53,13 @@ public function createSale(array $data)
]);
// 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
if (isset($item['serial_numbers']) && is_array($item['serial_numbers'])) {
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('status', 'disponible')
->first();
@ -72,7 +72,7 @@ public function createSale(array $data)
}
} else {
// 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')
->limit($item['quantity'])
->get();
@ -87,12 +87,12 @@ public function createSale(array $data)
}
// Sincronizar el stock
$inventory->syncStock();
$catalogItem->syncStock();
}
}
// 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
$detail->inventory->syncStock();
$detail->catalogItem->syncStock();
}
// Marcar venta como cancelada
$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');
}
};