refactor: primeros cambios para añadir servicios, migraciones, modelos y controladores
This commit is contained in:
parent
2c8189ca59
commit
de0d477341
@ -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();
|
||||
|
||||
@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
@ -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.',
|
||||
@ -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.',
|
||||
@ -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
149
app/Models/CatalogItem.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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([
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
36
app/Models/SettingsGlobal.php
Normal file
36
app/Models/SettingsGlobal.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
Loading…
x
Reference in New Issue
Block a user