Compare commits

..

No commits in common. "pdv-servicios" and "main" have entirely different histories.

52 changed files with 48 additions and 3978 deletions

View File

@ -29,7 +29,7 @@ class UserController extends Controller
*/ */
public function index() public function index()
{ {
$users = User::orderBy('name')->where('id', '!=', 1); $users = User::orderBy('name');
QuerySupport::queryByKeys($users, ['name', 'email']); QuerySupport::queryByKeys($users, ['name', 'email']);

View File

@ -1,139 +0,0 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\CashRegister;
use App\Services\CashRegisterService;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class CashRegisterController extends Controller
{
public function __construct(
protected CashRegisterService $cashRegisterService
) {}
/**
* Listar cortes de caja
*/
public function index(Request $request)
{
$query = CashRegister::with('user')->orderBy('opened_at', 'desc');
// Filtro por rango de fechas
if ($request->has('from') && $request->has('to')) {
$query->whereBetween('opened_at', [$request->from, $request->to]);
}
// Filtro por estado
if ($request->has('status')) {
$query->where('status', $request->status);
}
$registers = $query->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'registers' => $registers
]);
}
/**
* Ver caja actual del usuario
*/
public function current()
{
$register = CashRegister::where('user_id', auth()->id())
->where('status', 'open')
->with(['user', 'sales'])
->first();
if (!$register) {
return ApiResponse::OK->response([
'register' => null,
'message' => 'No tienes una caja abierta.'
]);
}
$service = new CashRegisterService();
$summary = $service->getCurrentSummary($register);
return ApiResponse::OK->response(['register' => $summary ]);
}
/**
* Ver detalle de un corte específico
*/
public function show(CashRegister $register)
{
$register->load(['user', 'sales.details']);
$summary = [
'register' => $register,
'payment_summary' => $register->getTotalsByPaymentMethod(),
];
return ApiResponse::OK->response($summary);
}
/**
* Abrir caja
*/
public function open(Request $request)
{
$request->validate([
'initial_cash' => ['required', 'numeric', 'min:0'],
], [
'initial_cash.required' => 'El efectivo inicial es obligatorio.',
'initial_cash.numeric' => 'El efectivo inicial debe ser un número.',
'initial_cash.min' => 'El efectivo inicial no puede ser negativo.',
]);
try {
$register = $this->cashRegisterService->openRegister([
'user_id' => auth()->id(),
'initial_cash' => $request->initial_cash,
]);
return ApiResponse::CREATED->response([
'model' => $register,
'message' => 'Caja abierta exitosamente.'
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
/**
* Cerrar caja
*/
public function close(CashRegister $register, Request $request)
{
$request->validate([
'final_cash' => ['required', 'numeric', 'min:0'],
'notes' => ['nullable', 'string', 'max:500'],
], [
'final_cash.required' => 'El efectivo final es obligatorio.',
'final_cash.numeric' => 'El efectivo final debe ser un número.',
'final_cash.min' => 'El efectivo final no puede ser negativo.',
]);
try {
$closedRegister = $this->cashRegisterService->closeRegister($register, [
'final_cash' => $request->final_cash,
'notes' => $request->notes,
]);
return ApiResponse::OK->response([
'model' => $closedRegister,
'message' => 'Caja cerrada exitosamente.'
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
}

View File

@ -1,54 +0,0 @@
<?php namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\CategoryStoreRequest;
use App\Http\Requests\App\CategoryUpdateRequest;
use App\Models\Category;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class CategoryController extends Controller
{
public function index()
{
$categorias = Category::where('is_active', true)
->orderBy('name')
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'categories' => $categorias
]);
}
public function show(Category $categoria)
{
return ApiResponse::OK->response([
'model' => $categoria
]);
}
public function store(CategoryStoreRequest $request)
{
$categoria = Category::create($request->validated());
return ApiResponse::OK->response([
'model' => $categoria
]);
}
public function update(CategoryUpdateRequest $request, Category $categoria)
{
$categoria->update($request->validated());
return ApiResponse::OK->response([
'model' => $categoria->fresh()
]);
}
public function destroy(Category $categoria)
{
$categoria->delete();
return ApiResponse::OK->response();
}
}

View File

@ -1,127 +0,0 @@
<?php namespace App\Http\Controllers\App;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;
use App\Models\Client;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ClientController extends Controller
{
public function index(Request $request)
{
$query = Client::query();
if ($request->has('with')) {
$relations = explode(',', $request->with);
$query->with($relations);
}
if ($request->has('q') && $request->q) {
$query->where(function($q) use ($request) {
$q->where('name', 'like', "%{$request->q}%")
->orWhere('email', 'like', "%{$request->q}%")
->orWhere('rfc', 'like', "%{$request->q}%");
});
}
return ApiResponse::OK->response([
'clients' => $query->paginate(config('app.pagination')),
]);
}
public function show(Client $client)
{
return ApiResponse::OK->response([
'client' => $client
]);
}
public function store(Request $request)
{
$request->validate([
'name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:500',
'rfc' => 'nullable|string|max:13',
],[
'email.unique' => 'El correo electrónico ya está en uso por otro cliente.',
'phone.unique' => 'El teléfono ya está en uso por otro cliente.',
'rfc.unique' => 'El RFC ya está en uso por otro cliente.',
]);
try{
$client = Client::create($request->only([
'name',
'email',
'phone',
'address',
'rfc',
]));
return ApiResponse::OK->response([
'client' => $client,
'message' => 'Cliente creado correctamente.'
]);
}catch(\Exception $e){
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al crear el cliente.'
]);
}
}
public function update(Request $request, Client $client)
{
$request->validate([
'name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:500',
'rfc' => 'nullable|string|max:13',
],[
'email.unique' => 'El correo electrónico ya está en uso por otro cliente.',
'phone.unique' => 'El teléfono ya está en uso por otro cliente.',
'rfc.unique' => 'El RFC ya está en uso por otro cliente.',
]);
try{
$client->update($request->only([
'name',
'email',
'phone',
'address',
'rfc',
]));
return ApiResponse::OK->response([
'client' => $client,
'message' => 'Cliente actualizado correctamente.'
]);
}catch(\Exception $e){
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al actualizar el cliente.'
]);
}
}
public function destroy(Client $client)
{
try{
$client->delete();
return ApiResponse::OK->response([
'message' => 'Cliente eliminado correctamente.'
]);
}catch(\Exception $e){
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error al eliminar el cliente.'
]);
}
}
}

View File

@ -1,185 +0,0 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Models\Client;
use App\Models\Sale;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class FacturaDataController extends Controller
{
/**
* Muestra los datos de la venta para el formulario de facturación.
*/
public function show(string $invoiceNumber)
{
$sale = Sale::where('invoice_number', $invoiceNumber)
->with([
'client',
'details.inventory.category',
'details.serials',
'user:id,name,email'
])
->first();
if (!$sale) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Venta no encontrada'
]);
}
// Si ya tiene datos de facturación
if ($sale->client_id) {
return ApiResponse::NO_CONTENT->response([
'message' => 'Esta venta ya tiene datos de facturación registrados',
'client' => $sale->client,
'sale' => $this->formatSaleData($sale)
]);
}
return ApiResponse::OK->response([
'sale' => $this->formatSaleData($sale)
]);
}
/**
* Guarda los datos fiscales del cliente para la venta.
*/
public function store(Request $request, string $invoiceNumber)
{
$sale = Sale::where('invoice_number', $invoiceNumber)
->with('details.serials')
->first();
if (!$sale) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Venta no encontrada'
]);
}
if ($sale->client_id) {
return ApiResponse::NO_CONTENT->response([
'message' => 'Esta venta ya tiene datos de facturación registrados'
]);
}
// Verificar que la venta esté completada
if ($sale->status !== 'completed') {
return ApiResponse::BAD_REQUEST->response([
'message' => 'Solo se pueden facturar ventas completadas'
]);
}
$validated = $request->validate([
'name' => 'required|string|max:255',
'email' => 'required|email|max:255',
'phone' => 'nullable|string|max:20',
'address' => 'nullable|string|max:500',
'rfc' => 'required|string|size:13|regex:/^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/i',
'razon_social' => 'required|string|max:255',
'regimen_fiscal' => 'required|string|max:100',
'cp_fiscal' => 'required|string|size:5|regex:/^\d{5}$/',
'uso_cfdi' => 'required|string|max:10',
], [
'rfc.regex' => 'El RFC no tiene un formato válido',
'rfc.size' => 'El RFC debe tener 13 caracteres',
'cp_fiscal.regex' => 'El código postal debe ser de 5 dígitos',
'cp_fiscal.size' => 'El código postal debe ser de 5 dígitos',
'name.required' => 'El nombre es obligatorio',
'email.required' => 'El correo electrónico es obligatorio',
'email.email' => 'El correo electrónico debe ser válido',
'razon_social.required' => 'La razón social es obligatoria',
'regimen_fiscal.required' => 'El régimen fiscal es obligatorio',
'uso_cfdi.required' => 'El uso de CFDI es obligatorio',
]);
// Buscar si ya existe un cliente con ese RFC
$client = Client::where('rfc', strtoupper($validated['rfc']))->first();
if ($client) {
// Actualizar datos del cliente existente
$client->update([
'name' => $validated['name'],
'email' => $validated['email'],
'phone' => $validated['phone'] ?? $client->phone,
'address' => $validated['address'] ?? $client->address,
'razon_social' => $validated['razon_social'],
'regimen_fiscal' => $validated['regimen_fiscal'],
'cp_fiscal' => $validated['cp_fiscal'],
'uso_cfdi' => $validated['uso_cfdi'],
]);
} else {
// Crear nuevo cliente
$client = Client::create([
'name' => $validated['name'],
'email' => $validated['email'],
'phone' => $validated['phone'],
'address' => $validated['address'],
'rfc' => strtoupper($validated['rfc']),
'razon_social' => $validated['razon_social'],
'regimen_fiscal' => $validated['regimen_fiscal'],
'cp_fiscal' => $validated['cp_fiscal'],
'uso_cfdi' => $validated['uso_cfdi'],
]);
}
// Asociar cliente a la venta
$sale->update(['client_id' => $client->id]);
// Recargar relaciones
$sale->load([
'client',
'details.inventory.category',
'details.serials',
'user:id,name,email'
]);
return ApiResponse::OK->response([
'message' => 'Datos de facturación guardados correctamente',
'client' => $client,
'sale' => $this->formatSaleData($sale)
]);
}
/**
* Formatear datos de la venta incluyendo números de serie
*/
private function formatSaleData(Sale $sale): array
{
return [
'id' => $sale->id,
'invoice_number' => $sale->invoice_number,
'total' => $sale->total,
'subtotal' => $sale->subtotal,
'tax' => $sale->tax,
'payment_method' => $sale->payment_method,
'status' => $sale->status,
'created_at' => $sale->created_at,
'user' => $sale->user ? [
'id' => $sale->user->id,
'name' => $sale->user->name,
'email' => $sale->user->email,
] : null,
'items' => $sale->details->map(function ($detail) {
return [
'id' => $detail->id,
'product_name' => $detail->product_name,
'quantity' => $detail->quantity,
'unit_price' => $detail->unit_price,
'subtotal' => $detail->subtotal,
'category' => $detail->inventory->category->name ?? null,
'sku' => $detail->inventory->sku ?? null,
// Números de serie vendidos
'serial_numbers' => $detail->serials->map(function ($serial) {
return [
'serial_number' => $serial->serial_number,
'status' => $serial->status,
];
})->toArray(),
];
})->toArray(),
];
}
}

View File

@ -1,192 +0,0 @@
<?php namespace App\Http\Controllers\App;
use App\Models\CatalogItem;
use App\Http\Controllers\Controller;
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;
use Notsoweb\ApiResponse\Enums\ApiResponse;
use Maatwebsite\Excel\Facades\Excel;
use Maatwebsite\Excel\Validators\ValidationException;
use Maatwebsite\Excel\Concerns\FromArray;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\Exportable;
class CatalogItemController extends Controller
{
public function __construct(
protected ProductService $productService
) {}
public function index(Request $request)
{
$products = CatalogItem::with(['category', 'price'])->withCount('serials')
->where('is_active', true);
if ($request->has('q') && $request->q) {
$products->where(function($query) use ($request) {
$query->where('name', 'like', "%{$request->q}%")
->orWhere('sku', 'like', "%{$request->q}%")
->orWhere('barcode', $request->q);
});
}
$products = $products->orderBy('name')
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'products' => $products
]);
}
public function show(CatalogItem $catalogItem)
{
return ApiResponse::OK->response([
'model' => $catalogItem->load(['category', 'price'])->loadCount('serials')
]);
}
public function store(CatalogItemStoreRequest $request)
{
$product = $this->productService->createProduct($request->validated());
return ApiResponse::OK->response([
'model' => $product
]);
}
public function update(CatalogItemUpdateRequest $request, CatalogItem $catalogItem)
{
$product = $this->productService->updateProduct($catalogItem, $request->validated());
return ApiResponse::OK->response([
'model' => $product
]);
}
public function destroy(CatalogItem $catalogItem)
{
$catalogItem->delete();
return ApiResponse::OK->response();
}
/**
* Importar productos desde Excel
*/
public function import(CatalogItemImportRequest $request)
{
try {
$import = new ProductsImport();
Excel::import($import, $request->file('file'));
$stats = $import->getStats();
return ApiResponse::OK->response([
'message' => 'Importación completada exitosamente.',
'imported' => $stats['imported'],
'updated' => $stats['updated'],
'skipped' => $stats['skipped'],
'errors' => $stats['errors'],
]);
} catch (ValidationException $e) {
$failures = $e->failures();
$errors = [];
foreach ($failures as $failure) {
$errors[] = [
'row' => $failure->row(),
'attribute' => $failure->attribute(),
'errors' => $failure->errors(),
'values' => $failure->values(),
];
}
return ApiResponse::BAD_REQUEST->response([
'message' => 'Error de validación en el archivo.',
'errors' => $errors,
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al importar productos: ' . $e->getMessage(),
]);
}
}
/**
* Descargar plantilla de Excel para importación
*/
public function downloadTemplate()
{
$headers = [
'nombre',
'sku',
'codigo_barras',
'categoria',
'stock',
'costo',
'precio_venta',
'impuesto',
'numeros_serie'
];
$exampleData = [
[
'nombre' => 'Samsung Galaxy A55',
'sku' => 'SAM-A55-BLK',
'codigo_barras' => '7502276853456',
'categoria' => 'Electrónica',
'stock' => 15,
'costo' => 5000.00,
'precio_venta' => 7500.00,
'impuesto' => 16
],
[
'nombre' => 'Coca Cola 600ml',
'sku' => 'COCA-600',
'codigo_barras' => '750227686666',
'categoria' => 'Bebidas',
'stock' => 5,
'costo' => 12.50,
'precio_venta' => 18.00,
'impuesto' => 8,
'numeros_serie' => '' // Dejar vacío si el producto no maneja seriales individuales
],
[
'nombre' => 'Laptop HP Pavilion 15',
'sku' => 'HP-LAP-15',
'codigo_barras' => '7502276854443',
'categoria' => 'Computadoras',
'stock' => 5,
'costo' => 8500.00,
'precio_venta' => 12000.00,
'impuesto' => 16,
'numeros_serie' => 'HP-LAP-15-01,HP-LAP-15-02,HP-LAP-15-03,HP-LAP-15-04,HP-LAP-15-05'
],
];
return Excel::download(
new class($headers, $exampleData) implements FromArray, WithHeadings {
use Exportable;
public function __construct(private array $headers, private array $data) {}
public function array(): array
{
return $this->data;
}
public function headings(): array
{
return $this->headers;
}
},
'plantilla_productos.xlsx'
);
}
}

View File

@ -1,138 +0,0 @@
<?php namespace App\Http\Controllers\App;
/**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/
use App\Http\Controllers\Controller;
use App\Models\CatalogItem;
use App\Models\InventorySerial;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
/**
* Controlador para gestión de números de serie
*
* @author Moisés Cortés C. <moises.cortes@notsoweb.com>
* @version 1.0.0
*/
class InventorySerialController extends Controller
{
/**
* Listar seriales de un producto
*/
public function index(CatalogItem $catalogItem, Request $request)
{
$query = $catalogItem->serials();
if ($request->has('status')) {
$query->where('status', $request->status);
}
if ($request->has('q')) {
$query->where('serial_number', 'like', "%{$request->q}%");
}
$serials = $query->orderBy('serial_number', 'ASC')->with('saleDetail.sale')
->paginate(config('app.pagination'));
return ApiResponse::OK->response([
'serials' => $serials,
'catalogItem' => $catalogItem->load('category'),
]);
}
/**
* Mostrar un serial específico
*/
public function show(CatalogItem $catalogItem, InventorySerial $serial)
{
// Verificar que el serial pertenece al inventario
if ($serial->catalog_item_id !== $catalogItem->id) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario'
]);
}
return ApiResponse::OK->response([
'serial' => $serial->load('saleDetail'),
'catalogItem' => $catalogItem->load('category'),
]);
}
/**
* Crear un nuevo serial
*/
public function store(CatalogItem $catalogItem, Request $request)
{
$request->validate([
'serial_number' => ['required', 'string', 'unique:inventory_serials,serial_number'],
'notes' => ['nullable', 'string'],
]);
$serial = InventorySerial::create([
'catalog_item_id' => $catalogItem->id,
'serial_number' => $request->serial_number,
'status' => 'disponible',
'notes' => $request->notes,
]);
// Sincronizar stock
$catalogItem->syncStock();
return ApiResponse::CREATED->response([
'serial' => $serial,
'catalogItem' => $catalogItem->fresh(),
]);
}
/**
* Actualizar un serial
*/
public function update(CatalogItem $catalogItem , InventorySerial $serial, Request $request)
{
// Verificar que el serial pertenece al inventario
if ($serial->catalog_item_id !== $catalogItem->id) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario'
]);
}
$request->validate([
'serial_number' => ['sometimes', 'string', 'unique:inventory_serials,serial_number,' . $serial->id],
'status' => ['sometimes', 'in:disponible,vendido'],
'notes' => ['nullable', 'string'],
]);
$serial->update($request->only(['serial_number', 'status', 'notes']));
// Sincronizar stock del inventario
$catalogItem->syncStock();
return ApiResponse::OK->response([
'serial' => $serial->fresh(),
'catalogItem' => $catalogItem->fresh(),
]);
}
/**
* Eliminar un serial
*/
public function destroy(CatalogItem $catalogItem, InventorySerial $serial)
{
// Verificar que el serial pertenece al inventario
if ($serial->catalog_item_id !== $catalogItem->id) {
return ApiResponse::NOT_FOUND->response([
'message' => 'Serial no encontrado para este inventario'
]);
}
$serial->delete();
// Sincronizar stock
$catalogItem->syncStock();
return ApiResponse::OK->response([
'message' => 'Serial eliminado exitosamente',
'catalogItem' => $catalogItem->fresh(),
]);
}
}

View File

@ -1,29 +0,0 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\PriceUpdateRequest;
use App\Models\Price;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class PriceController extends Controller
{
public function index()
{
$prices = Price::with('inventory')->paginate();
return ApiResponse::OK->response(['prices' => $prices]);
}
public function show(Price $price)
{
return ApiResponse::OK->response(['model' => $price->load('inventory')]);
}
// Actualizar solo precio
public function update(PriceUpdateRequest $request, Price $precio)
{
$precio->update($request->validated());
return ApiResponse::OK->response(['model' => $precio->fresh('inventory')]);
}
}

View File

@ -1,94 +0,0 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Services\ReportService;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class ReportController extends Controller
{
public function __construct(
protected ReportService $reportService
) {}
/**
* Obtener el producto más vendido
*/
public function topSellingProduct(Request $request)
{
$request->validate([
'include_stock_value' => ['nullable', 'boolean'],
'from_date' => ['nullable', 'date_format:Y-m-d'],
'to_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:from_date', 'required_with:from_date'],
], [
'include_stock_value.boolean' => 'El parámetro de valor de stock debe ser verdadero o falso.',
'from_date.date_format' => 'La fecha inicial debe tener el formato Y-m-d (ejemplo: 2025-01-01).',
'to_date.date_format' => 'La fecha final debe tener el formato Y-m-d (ejemplo: 2025-01-31).',
'to_date.after_or_equal' => 'La fecha final debe ser igual o posterior a la fecha inicial.',
'to_date.required_with' => 'La fecha final es obligatoria cuando se proporciona fecha inicial.',
]);
try {
$product = $this->reportService->getTopSellingProduct(
fromDate: $request->input('from_date'),
toDate: $request->input('to_date')
);
if ($product === null) {
return ApiResponse::OK->response([
'product' => null,
'message' => 'No hay datos de ventas para el período especificado.'
]);
}
return ApiResponse::OK->response([
'product' => $product,
'message' => 'Producto más vendido obtenido exitosamente.'
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al generar el reporte: ' . $e->getMessage()
]);
}
}
/**
* Obtener productos sin movimiento
*/
public function productsWithoutMovement(Request $request)
{
$request->validate([
'include_stock_value' => ['nullable', 'boolean'],
'from_date' => ['required', 'date_format:Y-m-d'],
'to_date' => ['required', 'date_format:Y-m-d', 'after_or_equal:from_date'],
], [
'include_stock_value.boolean' => 'El parámetro de valor de stock debe ser verdadero o falso.',
'from_date.required' => 'La fecha inicial es obligatoria.',
'from_date.date_format' => 'La fecha inicial debe tener el formato Y-m-d (ejemplo: 2025-01-01).',
'to_date.required' => 'La fecha final es obligatoria.',
'to_date.date_format' => 'La fecha final debe tener el formato Y-m-d (ejemplo: 2025-01-31).',
'to_date.after_or_equal' => 'La fecha final debe ser igual o posterior a la fecha inicial.',
]);
try {
$products = $this->reportService->getProductsWithoutMovement(
includeStockValue: (bool)($request->input('include_stock_value', true)),
fromDate: $request->input('from_date'),
toDate: $request->input('to_date')
);
return ApiResponse::OK->response([
'products' => $products,
'total_products' => count($products),
'message' => 'Reporte de productos sin movimiento generado exitosamente.'
]);
} catch (\Exception $e) {
return ApiResponse::INTERNAL_ERROR->response([
'message' => 'Error al generar el reporte: ' . $e->getMessage()
]);
}
}
}

View File

@ -1,74 +0,0 @@
<?php
namespace App\Http\Controllers\App;
use App\Http\Controllers\Controller;
use App\Http\Requests\App\SaleStoreRequest;
use App\Services\SaleService;
use App\Models\Sale;
use Illuminate\Http\Request;
use Notsoweb\ApiResponse\Enums\ApiResponse;
class SaleController extends Controller
{
public function __construct(
protected SaleService $saleService
) {}
public function index(Request $request)
{
$sales = Sale::with(['details.inventory', 'details.serials', 'user', 'client'])
->orderBy('created_at', 'desc');
if ($request->has('q') && $request->q) {
$sales->where('invoice_number', 'like', "%{$request->q}%")
->orWhereHas('user', fn($query) =>
$query->where('name', 'like', "%{$request->q}%")
);
}
if ($request->has('cash_register_id')) {
$sales->where('cash_register_id', $request->cash_register_id);
}
if ($request->has('status')) {
$sales->where('status', $request->status);
}
return ApiResponse::OK->response([
'sales' => $sales->paginate(config('app.pagination')),
]);
}
public function show(Sale $sale)
{
return ApiResponse::OK->response([
'model' => $sale->load(['details.inventory', 'user', 'client'])
]);
}
public function store(SaleStoreRequest $request)
{
$sale = $this->saleService->createSale($request->validated());
return ApiResponse::CREATED->response([
'model' => $sale,
]);
}
public function cancel(Sale $sale)
{
try {
$cancelledSale = $this->saleService->cancelSale($sale);
return ApiResponse::OK->response([
'model' => $cancelledSale,
'message' => 'Venta cancelada exitosamente. Stock restaurado.'
]);
} catch (\Exception $e) {
return ApiResponse::BAD_REQUEST->response([
'message' => $e->getMessage()
]);
}
}
}

View File

@ -1,88 +0,0 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class CatalogItemImportRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
'file' => [
'required',
'file',
'mimes:xlsx,xls,csv',
'max:10240', // 10MB máximo
],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
'file.required' => 'Debe seleccionar un archivo para importar.',
'file.file' => 'El archivo no es válido.',
'file.mimes' => 'El archivo debe ser de tipo Excel (.xlsx, .xls) o CSV (.csv).',
'file.max' => 'El archivo no debe superar los 10MB.',
];
}
/**
* Reglas de validación para cada fila del Excel
* Nota: SKU y código de barras no tienen 'unique' porque se permite reimportar
* para agregar stock/seriales a productos existentes
*/
public static function rowRules(): array
{
return [
'nombre' => ['required', 'string', 'max:100'],
'sku' => ['nullable', 'string', 'max:50'],
'codigo_barras' => ['nullable', 'string', 'max:100'],
'categoria' => ['nullable', 'string', 'max:100'],
'stock' => ['required', 'integer', 'min:0'],
'costo' => ['required', 'numeric', 'min:0'],
'precio_venta' => ['required', 'numeric', 'min:0'],
'impuesto' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
/**
* Mensajes personalizados de validación para las filas del Excel
*/
public static function rowMessages(): array
{
return [
'nombre.required' => 'El nombre del producto es requerido.',
'nombre.max' => 'El nombre no debe exceder los 100 caracteres.',
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
'stock.required' => 'El stock es requerido.',
'stock.integer' => 'El stock debe ser un número entero.',
'stock.min' => 'El stock no puede ser negativo.',
'costo.required' => 'El costo es requerido.',
'costo.numeric' => 'El costo debe ser un número.',
'costo.min' => 'El costo no puede ser negativo.',
'precio_venta.required' => 'El precio de venta es requerido.',
'precio_venta.numeric' => 'El precio de venta debe ser un número.',
'precio_venta.min' => 'El precio de venta no puede ser negativo.',
'precio_venta.gt' => 'El precio de venta debe ser mayor que el costo.',
'impuesto.numeric' => 'El impuesto debe ser un número.',
'impuesto.min' => 'El impuesto no puede ser negativo.',
'impuesto.max' => 'El impuesto no puede exceder el 100%.',
];
}
}

View File

@ -1,75 +0,0 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class CatalogItemStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
// Campos de Inventory
'name' => ['required', 'string', 'max:100'],
'sku' => ['nullable', 'string', 'max:50', 'unique:inventories,sku'],
'barcode' => ['nullable', 'string', 'unique:inventories,barcode'],
'category_id' => ['required', 'exists:categories,id'],
'stock' => ['nullable', 'integer', 'min:0'],
'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'],
'retail_price' => ['required', 'numeric', 'min:0', 'gt:cost'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
public function messages(): array
{
return [
// Mensajes de Inventory
'name.required' => 'El nombre es obligatorio.',
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'sku.string' => 'El SKU debe ser una cadena de texto.',
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
'sku.unique' => 'El SKU ya está en uso.',
'barcode.string' => 'El código de barras debe ser una cadena de texto.',
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
'category_id.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.',
'cost.numeric' => 'El costo debe ser un número.',
'cost.min' => 'El costo no puede ser negativo.',
'retail_price.required' => 'El precio de venta es obligatorio.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
'tax.numeric' => 'El impuesto debe ser un número.',
'tax.min' => 'El impuesto no puede ser negativo.',
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
}

View File

@ -1,72 +0,0 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class CatalogItemUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
$inventoryId = $this->route('inventario')?->id;
return [
// Campos de Inventory
'name' => ['nullable', 'string', 'max:100'],
'sku' => ['nullable', 'string', 'max:50'],
'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'],
'retail_price' => ['nullable', 'numeric', 'min:0', 'gt:cost'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
public function messages(): array
{
return [
// Mensajes de Inventory
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'sku.string' => 'El SKU debe ser una cadena de texto.',
'sku.max' => 'El SKU no debe exceder los 50 caracteres.',
'sku.unique' => 'El SKU ya está en uso.',
'barcode.string' => 'El código de barras debe ser una cadena de texto.',
'barcode.unique' => 'El código de barras ya está registrado en otro producto.',
'category_id.exists' => 'La categoría seleccionada no es válida.',
'stock.min' => 'El stock no puede ser negativo.',
'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.',
'cost.min' => 'El costo no puede ser negativo.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
'tax.numeric' => 'El impuesto debe ser un número.',
'tax.min' => 'El impuesto no puede ser negativo.',
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
}

View File

@ -1,37 +0,0 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class CategoryStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
'name' => ['required', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:225'],
];
}
public function messages(): array
{
return [
'name.required' => 'El nombre es obligatorio.',
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'description.string' => 'La descripción debe ser una cadena de texto.',
'description.max' => 'La descripción no debe exceder los 225 caracteres.',
];
}
}

View File

@ -1,36 +0,0 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class CategoryUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
'name' => ['nullable', 'string', 'max:100'],
'description' => ['nullable', 'string', 'max:225'],
];
}
public function messages(): array
{
return [
'name.string' => 'El nombre debe ser una cadena de texto.',
'name.max' => 'El nombre no debe exceder los 100 caracteres.',
'description.string' => 'La descripción debe ser una cadena de texto.',
'description.max' => 'La descripción no debe exceder los 225 caracteres.',
];
}
}

View File

@ -1,41 +0,0 @@
<?php namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class PriceUpdateRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*
*/
public function rules(): array
{
return [
'cost' => ['nullable', 'numeric', 'min:0'],
'retail_price' => ['nullable', 'numeric', 'min:0', 'gt:cost'],
'tax' => ['nullable', 'numeric', 'min:0', 'max:100'],
];
}
public function messages(): array
{
return [
'cost.numeric' => 'El costo debe ser un número.',
'cost.min' => 'El costo no puede ser negativo.',
'retail_price.numeric' => 'El precio de venta debe ser un número.',
'retail_price.min' => 'El precio de venta no puede ser negativo.',
'retail_price.gt' => 'El precio de venta debe ser mayor que el costo.',
'tax.numeric' => 'El impuesto debe ser un número.',
'tax.min' => 'El impuesto no puede ser negativo.',
'tax.max' => 'El impuesto no puede exceder el 100%.',
];
}
}

View File

@ -1,107 +0,0 @@
<?php
namespace App\Http\Requests\App;
use Illuminate\Foundation\Http\FormRequest;
class SaleStoreRequest extends FormRequest
{
/**
* Determine if the user is authorized to make this request.
*/
public function authorize(): bool
{
return true;
}
/**
* Get the validation rules that apply to the request.
*/
public function rules(): array
{
return [
// Datos de la venta
'user_id' => ['required', 'exists:users,id'],
'subtotal' => ['required', 'numeric', 'min:0'],
'tax' => ['required', 'numeric', 'min:0'],
'total' => ['required', 'numeric', 'min:0'],
'payment_method' => ['required', 'in:cash,credit_card,debit_card'],
// Campos para pagos en efectivo
'cash_received' => ['required_if:payment_method,cash', 'nullable', 'numeric', 'min:0'],
// Items del carrito
'items' => ['required', 'array', 'min:1'],
'items.*.inventory_id' => ['required', 'exists:inventories,id'],
'items.*.product_name' => ['required', 'string', 'max:255'],
'items.*.quantity' => ['required', 'integer', 'min:1'],
'items.*.unit_price' => ['required', 'numeric', 'min:0'],
'items.*.subtotal' => ['required', 'numeric', 'min:0'],
'items.*.serial_numbers' => ['nullable', 'array'],
'items.*.serial_numbers.*' => ['string', 'exists:inventory_serials,serial_number'],
];
}
/**
* Get custom messages for validator errors.
*/
public function messages(): array
{
return [
// Mensajes de Sale
'user_id.required' => 'El usuario es obligatorio.',
'user_id.exists' => 'El usuario seleccionado no existe.',
'subtotal.required' => 'El subtotal es obligatorio.',
'subtotal.numeric' => 'El subtotal debe ser un número.',
'subtotal.min' => 'El subtotal no puede ser negativo.',
'tax.required' => 'El impuesto es obligatorio.',
'tax.numeric' => 'El impuesto debe ser un número.',
'tax.min' => 'El impuesto no puede ser negativo.',
'total.required' => 'El total es obligatorio.',
'total.numeric' => 'El total debe ser un número.',
'total.min' => 'El total no puede ser negativo.',
'payment_method.required' => 'El método de pago es obligatorio.',
'payment_method.in' => 'El método de pago debe ser: efectivo, tarjeta de crédito o débito.',
// Mensajes de cash_received
'cash_received.required_if' => 'El dinero recibido es obligatorio para pagos en efectivo.',
'cash_received.numeric' => 'El dinero recibido debe ser un número.',
'cash_received.min' => 'El dinero recibido no puede ser negativo.',
// Mensajes de Items
'items.required' => 'Debe incluir al menos un producto.',
'items.array' => 'Los items deben ser un arreglo.',
'items.min' => 'Debe incluir al menos un producto.',
'items.*.inventory_id.required' => 'El ID del producto es obligatorio.',
'items.*.inventory_id.exists' => 'El producto seleccionado no existe.',
'items.*.product_name.required' => 'El nombre del producto es obligatorio.',
'items.*.quantity.required' => 'La cantidad es obligatoria.',
'items.*.quantity.integer' => 'La cantidad debe ser un número entero.',
'items.*.quantity.min' => 'La cantidad debe ser al menos 1.',
'items.*.unit_price.required' => 'El precio unitario es obligatorio.',
'items.*.unit_price.numeric' => 'El precio unitario debe ser un número.',
'items.*.unit_price.min' => 'El precio unitario no puede ser negativo.',
'items.*.subtotal.required' => 'El subtotal del item es obligatorio.',
'items.*.subtotal.numeric' => 'El subtotal del item debe ser un número.',
'items.*.subtotal.min' => 'El subtotal del item no puede ser negativo.',
];
}
/**
* Validación adicional después de las reglas básicas
*/
public function withValidator($validator)
{
$validator->after(function ($validator) {
// Validar que el dinero recibido sea suficiente para pagos en efectivo
if ($this->payment_method === 'cash' && $this->cash_received !== null) {
if ($this->cash_received < $this->total) {
$validator->errors()->add(
'cash_received',
'El dinero recibido debe ser mayor o igual al total de la venta ($' . number_format($this->total, 2) . ').'
);
}
}
});
}
}

View File

@ -1,243 +0,0 @@
<?php
namespace App\Imports;
use App\Models\CatalogItem;
use App\Models\Price;
use App\Models\Category;
use App\Http\Requests\App\CatalogItemImportRequest;
use App\Models\InventorySerial;
use Maatwebsite\Excel\Concerns\ToModel;
use Maatwebsite\Excel\Concerns\WithHeadingRow;
use Maatwebsite\Excel\Concerns\WithValidation;
use Maatwebsite\Excel\Concerns\Importable;
use Maatwebsite\Excel\Concerns\WithChunkReading;
use Maatwebsite\Excel\Concerns\SkipsEmptyRows;
use Maatwebsite\Excel\Concerns\WithMapping;
/**
* Import de productos desde Excel
*
* Formato esperado del Excel:
* - nombre: Nombre del producto (requerido)
* - sku: Código SKU (opcional, único)
* - categoria: Nombre de la categoría (opcional)
* - stock: Cantidad inicial (requerido, mínimo 0)
* - costo: Precio de costo (requerido, mínimo 0)
* - precio_venta: Precio de venta (requerido, mayor que costo)
* - impuesto: Porcentaje de impuesto (opcional, 0-100)
*/
class ProductsImport implements ToModel, WithHeadingRow, WithValidation, WithChunkReading, SkipsEmptyRows, WithMapping
{
use Importable;
private $errors = [];
private $imported = 0;
private $updated = 0;
private $skipped = 0;
/**
* Mapea y transforma los datos de cada fila antes de la validación
*/
public function map($row): array
{
return [
'nombre' => $row['nombre'] ?? null,
'sku' => isset($row['sku']) ? (string) $row['sku'] : null,
'codigo_barras' => isset($row['codigo_barras']) ? (string) $row['codigo_barras'] : null,
'categoria' => $row['categoria'] ?? null,
'stock' => $row['stock'] ?? null,
'costo' => $row['costo'] ?? null,
'precio_venta' => $row['precio_venta'] ?? null,
'impuesto' => $row['impuesto'] ?? null,
'numeros_serie' => $row['numeros_serie'] ?? null,
];
}
/**
* Procesa cada fila del Excel
*/
public function model(array $row)
{
// Ignorar filas completamente vacías
if (empty($row['nombre']) && empty($row['sku']) && empty($row['stock'])) {
return null;
}
try {
// Buscar producto existente por SKU o código de barras
$existingInventory = null;
if (!empty($row['sku'])) {
$existingInventory = CatalogItem::where('sku', trim($row['sku']))->first();
}
if (!$existingInventory && !empty($row['codigo_barras'])) {
$existingInventory = CatalogItem::where('barcode', trim($row['codigo_barras']))->first();
}
// Si el producto ya existe, solo agregar stock y seriales
if ($existingInventory) {
return $this->updateExistingProduct($existingInventory, $row);
}
// Producto nuevo: validar precios
$costo = (float) $row['costo'];
$precioVenta = (float) $row['precio_venta'];
if ($precioVenta <= $costo) {
$this->skipped++;
$this->errors[] = "Fila con producto '{$row['nombre']}': El precio de venta ($precioVenta) debe ser mayor que el costo ($costo)";
return null;
}
// Buscar o crear categoría si se proporciona
$categoryId = null;
if (!empty($row['categoria'])) {
$category = Category::firstOrCreate(
['name' => trim($row['categoria'])],
['is_active' => true]
);
$categoryId = $category->id;
}
// Crear el producto en inventario
$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([
'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($catalogItem, $row['numeros_serie'] ?? null, $row['stock'] ?? 0);
$this->imported++;
return $catalogItem;
} catch (\Exception $e) {
$this->skipped++;
$this->errors[] = "Error en fila: " . $e->getMessage();
return null;
}
}
/**
* Actualiza un producto existente: suma stock y agrega seriales nuevos
*/
private function updateExistingProduct(CatalogItem $catalogItem, array $row)
{
$serialsAdded = 0;
$serialsSkipped = 0;
// Agregar seriales nuevos (ignorar duplicados)
if (!empty($row['numeros_serie'])) {
$serials = explode(',', $row['numeros_serie']);
foreach ($serials as $serial) {
$serial = trim($serial);
if (empty($serial)) continue;
// Verificar si el serial ya existe (global, no solo en este producto)
$exists = InventorySerial::where('serial_number', $serial)->exists();
if (!$exists) {
InventorySerial::create([
'catalog_item_id' => $catalogItem->id,
'serial_number' => $serial,
'status' => 'disponible',
]);
$serialsAdded++;
} else {
$serialsSkipped++;
}
}
// Sincronizar stock basado en seriales disponibles
$catalogItem->syncStock();
} else {
// Producto sin seriales: sumar stock
$stockToAdd = (int) ($row['stock'] ?? 0);
$catalogItem->increment('stock', $stockToAdd);
}
$this->updated++;
if ($serialsSkipped > 0) {
$this->errors[] = "Producto '{$catalogItem->name}': {$serialsSkipped} seriales ya existían y fueron ignorados";
}
return null; // No retornar modelo para evitar que Maatwebsite intente guardarlo
}
/**
* Agrega seriales a un producto nuevo
*/
private function addSerials(CatalogItem $catalogItem, ?string $serialsString, int $stockFromExcel)
{
if (!empty($serialsString)) {
$serials = explode(',', $serialsString);
foreach ($serials as $serial) {
$serial = trim($serial);
if (!empty($serial)) {
InventorySerial::create([
'catalog_item_id' => $catalogItem->id,
'serial_number' => $serial,
'status' => 'disponible',
]);
}
}
$catalogItem->syncStock();
} else {
// Producto sin seriales
$catalogItem->stock = $stockFromExcel;
$catalogItem->save();
}
}
/**
* Reglas de validación para cada fila
*/
public function rules(): array
{
return CatalogItemImportRequest::rowRules();
}
/**
* Mensajes personalizados de validación
*/
public function customValidationMessages()
{
return CatalogItemImportRequest::rowMessages();
}
/**
* Chunk size for reading
*/
public function chunkSize(): int
{
return 100;
}
/**
* Obtener estadísticas de la importación
*/
public function getStats(): array
{
return [
'imported' => $this->imported,
'updated' => $this->updated,
'skipped' => $this->skipped,
'errors' => $this->errors,
];
}
}

View File

@ -1,62 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class CashRegister extends Model
{
protected $fillable = [
'user_id',
'opened_at',
'closed_at',
'initial_cash',
'final_cash',
'expected_cash',
'difference',
'total_sales',
'sales_count',
'notes',
'status',
];
protected $casts = [
'opened_at' => 'datetime',
'closed_at' => 'datetime',
'initial_cash' => 'decimal:2',
'final_cash' => 'decimal:2',
'expected_cash' => 'decimal:2',
'difference' => 'decimal:2',
'total_sales' => 'decimal:2',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function sales()
{
return $this->hasMany(Sale::class);
}
/**
* Verificar si la caja está abierta
*/
public function isOpen(): bool
{
return $this->status === 'open';
}
/**
* Calcular totales por método de pago
*/
public function getTotalsByPaymentMethod()
{
return $this->sales()
->where('status', 'completed')
->selectRaw('payment_method, SUM(total) as total, COUNT(*) as count')
->groupBy('payment_method')
->get();
}
}

View File

@ -1,149 +0,0 @@
<?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

@ -1,32 +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 Category extends Model
{
protected $fillable = [
'name',
'description',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function catalogItems()
{
return $this->hasMany(CatalogItem::class);
}
}

View File

@ -1,23 +0,0 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class Client extends Model
{
protected $fillable = [
'name',
'email',
'phone',
'address',
'rfc',
'razon_social',
'regimen_fiscal',
'cp_fiscal',
'uso_cfdi',
];
public function sales()
{
return $this->hasMany(Sale::class);
}
}

View File

@ -1,51 +0,0 @@
<?php namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class InventorySerial extends Model
{
protected $fillable = [
'catalog_item_id',
'serial_number',
'status',
'sale_detail_id',
'notes',
];
protected $casts = [
'status' => 'string',
];
//RELACIONES
public function catalogItem()
{
return $this->belongsTo(CatalogItem::class);
}
public function saleDetail()
{
return $this->belongsTo(SaleDetail::class);
}
//HELPERS
public function isAvailable(): bool
{
return $this->status === 'disponible';
}
public function markAsSold(int $saleDetailId): void
{
$this->update([
'status' => 'vendido',
'sale_detail_id' => $saleDetailId,
]);
}
public function markAsAvailable(): void
{
$this->update([
'status' => 'disponible',
'sale_detail_id' => null,
]);
}
}

View File

@ -1,35 +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 Price extends Model
{
protected $fillable = [
'catalog_itme_id',
'cost',
'retail_price',
'tax',
];
protected $casts = [
'cost' => 'decimal:2',
'retail_price' => 'decimal:2',
'tax' => 'decimal:2',
];
public function catalogItem()
{
return $this->belongsTo(CatalogItem::class);
}
}

View File

@ -1,59 +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 Sale extends Model
{
protected $fillable = [
'user_id',
'client_id',
'cash_register_id',
'invoice_number',
'subtotal',
'tax',
'total',
'cash_received',
'change',
'payment_method',
'status',
];
protected $casts = [
'subtotal' => 'decimal:2',
'tax' => 'decimal:2',
'total' => 'decimal:2',
'cash_received' => 'decimal:2',
'change' => 'decimal:2',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function details()
{
return $this->hasMany(SaleDetail::class);
}
public function cashRegister()
{
return $this->belongsTo(CashRegister::class);
}
public function client()
{
return $this->belongsTo(Client::class);
}
}

View File

@ -1,54 +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 SaleDetail extends Model
{
protected $fillable = [
'sale_id',
'catalog_item_id',
'product_name',
'quantity',
'unit_price',
'subtotal',
];
protected $casts = [
'unit_price' => 'decimal:2',
'subtotal' => 'decimal:2',
];
public function sale()
{
return $this->belongsTo(Sale::class);
}
public function catalogItem()
{
return $this->belongsTo(CatalogItem::class);
}
public function serials()
{
return $this->hasMany(InventorySerial::class);
}
/**
* Obtener números de serie vendidos
*/
public function getSerialNumbersAttribute(): array
{
return $this->serials()->pluck('serial_number')->toArray();
}
}

View File

@ -1,36 +0,0 @@
<?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,121 +0,0 @@
<?php
namespace App\Services;
use App\Models\CashRegister;
use App\Models\Sale;
class CashRegisterService
{
/**
* Abrir caja
*/
public function openRegister(array $data)
{
// Verificar que el usuario no tenga una caja abierta
$openRegister = CashRegister::where('user_id', $data['user_id'])
->where('status', 'open')
->first();
if ($openRegister) {
throw new \Exception('Ya tienes una caja abierta. Debes cerrarla antes de abrir una nueva.');
}
return CashRegister::create([
'user_id' => $data['user_id'],
'opened_at' => now(),
'initial_cash' => $data['initial_cash'] ?? 0,
'status' => 'open',
]);
}
/**
* Cerrar caja
*/
public function closeRegister(CashRegister $register, array $data)
{
$sales = Sale::where('cash_register_id', $register->id)
->where('status', 'completed')
->get();
// Calcular efectivo real (recibido - devuelto)
$cashSales = $sales->where('payment_method', 'cash')
->sum(function ($sale) {
return ($sale->cash_received ?? 0) - ($sale->change ?? 0);
});
$totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received');
$totalChangeGiven = $sales->where('payment_method', 'cash')->sum('change');
$cardSales = $sales->whereIn('payment_method', ['credit_card', 'debit_card'])->sum('total');
$totalSales = $sales->sum('total');
// Efectivo esperado
$expectedCash = $register->initial_cash + $cashSales;
// Diferencia (sobrante o faltante)
$difference = $data['final_cash'] - $expectedCash;
// Cerrar caja
$register->update([
'closed_at' => now(),
'final_cash' => $data['final_cash'],
'expected_cash' => $expectedCash,
'difference' => $difference,
'total_sales' => $totalSales,
'cash_sales' => $cashSales,
'card_sales' => $cardSales,
'total_cash_received' => $totalCashReceived,
'total_change_given' => $totalChangeGiven,
'notes' => $data['notes'] ?? null,
'status' => 'closed',
]);
return $register;
}
/**
* Obtener resumen de caja actual
*/
public function getCurrentSummary(CashRegister $register)
{
$sales = Sale::where('cash_register_id', $register->id)
->where('status', 'completed')
->get();
// Calcular efectivo real en caja (recibido - devuelto)
$cashSales = $sales->where('payment_method', 'cash')
->sum(function ($sale) {
return ($sale->cash_received ?? 0) - ($sale->change ?? 0);
});
// Confirmación, envio de los totales
$totalCashReceived = $sales->where('payment_method', 'cash')->sum('cash_received');
$totalChangeGiven = $sales->where('payment_method', 'cash')->sum('change');
$cardSales = $sales->whereIn('payment_method', ['credit_card', 'debit_card'])->sum('total');
$totalSales = $sales->sum('total');
$transactionCount = $sales->count();
return [
'id' => $register->id,
'user_id' => $register->user_id,
'status' => $register->status,
'opened_at' => $register->opened_at,
'closed_at' => $register->closed_at,
'initial_cash' => (float) $register->initial_cash,
// Totales calculados
'total_sales' => (float) $totalSales,
'transaction_count' => $transactionCount,
'cash_sales' => (float) $cashSales,
'card_sales' => (float) $cardSales,
//Desglose de efectivo
'total_cash_received' => (float) $totalCashReceived,
'total_change_given' => (float) $totalChangeGiven,
// Efectivo esperado
'expected_cash' => (float) ($register->initial_cash + $cashSales)
];
}
}

View File

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

View File

@ -1,100 +0,0 @@
<?php
namespace App\Services;
use App\Models\CatalogItem;
use App\Models\SaleDetail;
use Illuminate\Support\Facades\DB;
class ReportService
{
/**
* Obtener el producto más vendido
*
* @param string|null $fromDate Fecha inicial (formato: Y-m-d)
* @param string|null $toDate Fecha final (formato: Y-m-d)
* @return array|null Retorna el producto más vendido o null si no hay ventas
*/
public function getTopSellingProduct(?string $fromDate = null, ?string $toDate = null): ?array
{
$query = SaleDetail::query()
->selectRaw('
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,
catalog_items.created_at as added_date
')
->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');
// Aplicar filtro de fechas si se proporcionan ambas
if ($fromDate && $toDate) {
$query->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate]);
}
$result = $query
->groupBy('catalog_items.id', 'catalog_items.name', 'catalog_items.sku',
'categories.name', 'catalog_items.created_at')
->orderByDesc('total_quantity_sold')
->first();
return $result ? $result->toArray() : null;
}
/**
* Obtener productos sin movimiento
*
* @param int $daysThreshold Días mínimos sin movimiento (default: 30)
* @param bool $includeStockValue Incluir valor del inventario (default: true)
* @return array Lista de productos sin ventas
*/
public function getProductsWithoutMovement(bool $includeStockValue = true, ?string $fromDate = null, ?string $toDate = null): array
{
// Obtener IDs de productos que SÍ tienen ventas
$inventoriesWithSales = SaleDetail::query()
->join('sales', 'sale_details.sale_id', '=', 'sales.id')
->where('sales.status', 'completed')
->whereNull('sales.deleted_at')
->whereBetween(DB::raw('DATE(sales.created_at)'), [$fromDate, $toDate])
->distinct()
->pluck('sale_details.catalog_item_id')
->toArray();
// Construir query para productos SIN ventas
$query = CatalogItem::query()
->selectRaw('
catalog_items.id,
catalog_items.name,
catalog_items.sku,
catalog_items.stock,
categories.name as category_name,
catalog_items.created_at as date_added,
prices.retail_price
');
// Agregar valor del inventario si se solicita
if ($includeStockValue) {
$query->addSelect(
DB::raw('(catalog_items.stock * COALESCE(prices.cost, 0)) as inventory_value')
);
}
return $query
->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,158 +0,0 @@
<?php namespace App\Services;
use App\Models\CashRegister;
use App\Models\CatalogItem;
use App\Models\Sale;
use App\Models\SaleDetail;
use App\Models\InventorySerial;
use Illuminate\Support\Facades\DB;
class SaleService
{
/**
* Crear una nueva venta con sus detalles
*
*/
public function createSale(array $data)
{
return DB::transaction(function () use ($data) {
// Calcular el cambio si es pago en efectivo
$cashReceived = null;
$change = null;
if ($data['payment_method'] === 'cash' && isset($data['cash_received'])) {
$cashReceived = $data['cash_received'];
$change = $cashReceived - $data['total'];
}
// 1. Crear la venta principal
$sale = Sale::create([
'user_id' => $data['user_id'],
'cash_register_id' => $data['cash_register_id'] ?? $this->getCurrentCashRegister($data['user_id']),
'invoice_number' => $data['invoice_number'] ?? $this->generateInvoiceNumber(),
'subtotal' => $data['subtotal'],
'tax' => $data['tax'],
'total' => $data['total'],
'cash_received' => $cashReceived,
'change' => $change,
'payment_method' => $data['payment_method'],
'status' => $data['status'] ?? 'completed',
]);
// 2. Crear los detalles de la venta y asignar seriales
foreach ($data['items'] as $item) {
// Crear detalle de venta
$saleDetail = SaleDetail::create([
'sale_id' => $sale->id,
'catalog_item_id' => $item['catalog_item_id'],
'product_name' => $item['product_name'],
'quantity' => $item['quantity'],
'unit_price' => $item['unit_price'],
'subtotal' => $item['subtotal'],
'serial_numbers' => $item['serial_numbers'] ?? null, // Si vienen del frontend
]);
// Obtener el inventario
$catalogItem = CatalogItem::find($item['catalog_item']);
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('catalog_item_id', $catalogItem->id)
->where('serial_number', $serialNumber)
->where('status', 'disponible')
->first();
if ($serial) {
$serial->markAsSold($saleDetail->id);
} else {
throw new \Exception("Serial {$serialNumber} no disponible");
}
}
} else {
// Asignar automáticamente los primeros N seriales disponibles
$serials = InventorySerial::where('catalog_item_id', $catalogItem->id)
->where('status', 'disponible')
->limit($item['quantity'])
->get();
if ($serials->count() < $item['quantity']) {
throw new \Exception("Stock insuficiente de seriales para {$item['product_name']}");
}
foreach ($serials as $serial) {
$serial->markAsSold($saleDetail->id);
}
}
// Sincronizar el stock
$catalogItem->syncStock();
}
}
// 3. Retornar la venta con sus relaciones cargadas
return $sale->load(['details.catalogItem', 'details.serials', 'user']);
});
}
/**
* Cancelar una venta y restaurar el stock
*
*/
public function cancelSale(Sale $sale)
{
return DB::transaction(function () use ($sale) {
// Verificar que la venta esté completada
if ($sale->status !== 'completed') {
throw new \Exception('Solo se pueden cancelar ventas completadas.');
}
// Restaurar seriales a disponible
foreach ($sale->details as $detail) {
$serials = InventorySerial::where('sale_detail_id', $detail->id)->get();
foreach ($serials as $serial) {
$serial->markAsAvailable();
}
// Sincronizar stock
$detail->catalogItem->syncStock();
}
// Marcar venta como cancelada
$sale->update(['status' => 'cancelled']);
return $sale->fresh(['details.catalogItem', 'details.serials', 'user']);
});
}
/**
* Generar número de factura único
* Formato: INV-YYYYMMDD-0001
*/
private function generateInvoiceNumber(): string
{
$prefix = 'INV-';
$date = now()->format('Ymd');
// Obtener la última venta del día
$lastSale = Sale::whereDate('created_at', today())
->orderBy('id', 'desc')
->first();
// Incrementar secuencial
$sequential = $lastSale ? (intval(substr($lastSale->invoice_number, -4)) + 1) : 1;
return $prefix . $date . '-' . str_pad($sequential, 4, '0', STR_PAD_LEFT);
}
private function getCurrentCashRegister($userId)
{
$register = CashRegister::where('user_id', $userId)
->where('status', 'open')
->first();
return $register ? $register->id : null;
}
}

View File

@ -12,7 +12,6 @@
"laravel/pulse": "^1.4", "laravel/pulse": "^1.4",
"laravel/reverb": "^1.4", "laravel/reverb": "^1.4",
"laravel/tinker": "^2.10", "laravel/tinker": "^2.10",
"maatwebsite/excel": "^3.1",
"notsoweb/laravel-core": "dev-main", "notsoweb/laravel-core": "dev-main",
"spatie/laravel-permission": "^6.16", "spatie/laravel-permission": "^6.16",
"tightenco/ziggy": "^2.5" "tightenco/ziggy": "^2.5"

593
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "9f4c9c1c470ced74308c732439eb5b3d", "content-hash": "0cdf26aa072a7f833793cfba72e94e81",
"packages": [ "packages": [
{ {
"name": "brick/math", "name": "brick/math",
@ -265,162 +265,6 @@
], ],
"time": "2025-01-03T16:18:33+00:00" "time": "2025-01-03T16:18:33+00:00"
}, },
{
"name": "composer/pcre",
"version": "3.3.2",
"source": {
"type": "git",
"url": "https://github.com/composer/pcre.git",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
"shasum": ""
},
"require": {
"php": "^7.4 || ^8.0"
},
"conflict": {
"phpstan/phpstan": "<1.11.10"
},
"require-dev": {
"phpstan/phpstan": "^1.12 || ^2",
"phpstan/phpstan-strict-rules": "^1 || ^2",
"phpunit/phpunit": "^8 || ^9"
},
"type": "library",
"extra": {
"phpstan": {
"includes": [
"extension.neon"
]
},
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Pcre\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
}
],
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
"keywords": [
"PCRE",
"preg",
"regex",
"regular expression"
],
"support": {
"issues": "https://github.com/composer/pcre/issues",
"source": "https://github.com/composer/pcre/tree/3.3.2"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/composer/composer",
"type": "tidelift"
}
],
"time": "2024-11-12T16:29:46+00:00"
},
{
"name": "composer/semver",
"version": "3.4.4",
"source": {
"type": "git",
"url": "https://github.com/composer/semver.git",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
"shasum": ""
},
"require": {
"php": "^5.3.2 || ^7.0 || ^8.0"
},
"require-dev": {
"phpstan/phpstan": "^1.11",
"symfony/phpunit-bridge": "^3 || ^7"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-main": "3.x-dev"
}
},
"autoload": {
"psr-4": {
"Composer\\Semver\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nils Adermann",
"email": "naderman@naderman.de",
"homepage": "http://www.naderman.de"
},
{
"name": "Jordi Boggiano",
"email": "j.boggiano@seld.be",
"homepage": "http://seld.be"
},
{
"name": "Rob Bast",
"email": "rob.bast@gmail.com",
"homepage": "http://robbast.nl"
}
],
"description": "Semver library that offers utilities, version constraint parsing and validation.",
"keywords": [
"semantic",
"semver",
"validation",
"versioning"
],
"support": {
"irc": "ircs://irc.libera.chat:6697/composer",
"issues": "https://github.com/composer/semver/issues",
"source": "https://github.com/composer/semver/tree/3.4.4"
},
"funding": [
{
"url": "https://packagist.com",
"type": "custom"
},
{
"url": "https://github.com/composer",
"type": "github"
}
],
"time": "2025-08-20T19:15:30+00:00"
},
{ {
"name": "defuse/php-encryption", "name": "defuse/php-encryption",
"version": "v2.4.0", "version": "v2.4.0",
@ -965,67 +809,6 @@
}, },
"time": "2023-08-08T05:53:35+00:00" "time": "2023-08-08T05:53:35+00:00"
}, },
{
"name": "ezyang/htmlpurifier",
"version": "v4.19.0",
"source": {
"type": "git",
"url": "https://github.com/ezyang/htmlpurifier.git",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ezyang/htmlpurifier/zipball/b287d2a16aceffbf6e0295559b39662612b77fcf",
"reference": "b287d2a16aceffbf6e0295559b39662612b77fcf",
"shasum": ""
},
"require": {
"php": "~5.6.0 || ~7.0.0 || ~7.1.0 || ~7.2.0 || ~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0"
},
"require-dev": {
"cerdic/css-tidy": "^1.7 || ^2.0",
"simpletest/simpletest": "dev-master"
},
"suggest": {
"cerdic/css-tidy": "If you want to use the filter 'Filter.ExtractStyleBlocks'.",
"ext-bcmath": "Used for unit conversion and imagecrash protection",
"ext-iconv": "Converts text to and from non-UTF-8 encodings",
"ext-tidy": "Used for pretty-printing HTML"
},
"type": "library",
"autoload": {
"files": [
"library/HTMLPurifier.composer.php"
],
"psr-0": {
"HTMLPurifier": "library/"
},
"exclude-from-classmap": [
"/library/HTMLPurifier/Language/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Edward Z. Yang",
"email": "admin@htmlpurifier.org",
"homepage": "http://ezyang.com"
}
],
"description": "Standards compliant HTML filter written in PHP",
"homepage": "http://htmlpurifier.org/",
"keywords": [
"html"
],
"support": {
"issues": "https://github.com/ezyang/htmlpurifier/issues",
"source": "https://github.com/ezyang/htmlpurifier/tree/v4.19.0"
},
"time": "2025-10-17T16:34:55+00:00"
},
{ {
"name": "firebase/php-jwt", "name": "firebase/php-jwt",
"version": "v6.11.1", "version": "v6.11.1",
@ -3185,272 +2968,6 @@
], ],
"time": "2025-04-12T22:26:52+00:00" "time": "2025-04-12T22:26:52+00:00"
}, },
{
"name": "maatwebsite/excel",
"version": "3.1.67",
"source": {
"type": "git",
"url": "https://github.com/SpartnerNL/Laravel-Excel.git",
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/SpartnerNL/Laravel-Excel/zipball/e508e34a502a3acc3329b464dad257378a7edb4d",
"reference": "e508e34a502a3acc3329b464dad257378a7edb4d",
"shasum": ""
},
"require": {
"composer/semver": "^3.3",
"ext-json": "*",
"illuminate/support": "5.8.*||^6.0||^7.0||^8.0||^9.0||^10.0||^11.0||^12.0",
"php": "^7.0||^8.0",
"phpoffice/phpspreadsheet": "^1.30.0",
"psr/simple-cache": "^1.0||^2.0||^3.0"
},
"require-dev": {
"laravel/scout": "^7.0||^8.0||^9.0||^10.0",
"orchestra/testbench": "^6.0||^7.0||^8.0||^9.0||^10.0",
"predis/predis": "^1.1"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"Excel": "Maatwebsite\\Excel\\Facades\\Excel"
},
"providers": [
"Maatwebsite\\Excel\\ExcelServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Maatwebsite\\Excel\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Patrick Brouwers",
"email": "patrick@spartner.nl"
}
],
"description": "Supercharged Excel exports and imports in Laravel",
"keywords": [
"PHPExcel",
"batch",
"csv",
"excel",
"export",
"import",
"laravel",
"php",
"phpspreadsheet"
],
"support": {
"issues": "https://github.com/SpartnerNL/Laravel-Excel/issues",
"source": "https://github.com/SpartnerNL/Laravel-Excel/tree/3.1.67"
},
"funding": [
{
"url": "https://laravel-excel.com/commercial-support",
"type": "custom"
},
{
"url": "https://github.com/patrickbrouwers",
"type": "github"
}
],
"time": "2025-08-26T09:13:16+00:00"
},
{
"name": "maennchen/zipstream-php",
"version": "3.2.1",
"source": {
"type": "git",
"url": "https://github.com/maennchen/ZipStream-PHP.git",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/682f1098a8fddbaf43edac2306a691c7ad508ec5",
"reference": "682f1098a8fddbaf43edac2306a691c7ad508ec5",
"shasum": ""
},
"require": {
"ext-mbstring": "*",
"ext-zlib": "*",
"php-64bit": "^8.3"
},
"require-dev": {
"brianium/paratest": "^7.7",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^3.86",
"guzzlehttp/guzzle": "^7.5",
"mikey179/vfsstream": "^1.6",
"php-coveralls/php-coveralls": "^2.5",
"phpunit/phpunit": "^12.0",
"vimeo/psalm": "^6.0"
},
"suggest": {
"guzzlehttp/psr7": "^2.4",
"psr/http-message": "^2.0"
},
"type": "library",
"autoload": {
"psr-4": {
"ZipStream\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paul Duncan",
"email": "pabs@pablotron.org"
},
{
"name": "Jonatan Männchen",
"email": "jonatan@maennchen.ch"
},
{
"name": "Jesse Donat",
"email": "donatj@gmail.com"
},
{
"name": "András Kolesár",
"email": "kolesar@kolesar.hu"
}
],
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
"keywords": [
"stream",
"zip"
],
"support": {
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.1"
},
"funding": [
{
"url": "https://github.com/maennchen",
"type": "github"
}
],
"time": "2025-12-10T09:58:31+00:00"
},
{
"name": "markbaker/complex",
"version": "3.0.2",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
"shasum": ""
},
"require": {
"php": "^7.2 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
},
"time": "2022-12-06T16:21:08+00:00"
},
{
"name": "markbaker/matrix",
"version": "3.0.1",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPMatrix.git",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
"shasum": ""
},
"require": {
"php": "^7.1 || ^8.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
"phpcompatibility/php-compatibility": "^9.3",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "^4.0",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
"sebastian/phpcpd": "^4.0",
"squizlabs/php_codesniffer": "^3.7"
},
"type": "library",
"autoload": {
"psr-4": {
"Matrix\\": "classes/src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@demon-angel.eu"
}
],
"description": "PHP Class for working with matrices",
"homepage": "https://github.com/MarkBaker/PHPMatrix",
"keywords": [
"mathematics",
"matrix",
"vector"
],
"support": {
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
},
"time": "2022-12-02T22:17:43+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "3.9.0", "version": "3.9.0",
@ -4324,112 +3841,6 @@
}, },
"time": "2024-09-04T12:51:01+00:00" "time": "2024-09-04T12:51:01+00:00"
}, },
{
"name": "phpoffice/phpspreadsheet",
"version": "1.30.1",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "fa8257a579ec623473eabfe49731de5967306c4c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/fa8257a579ec623473eabfe49731de5967306c4c",
"reference": "fa8257a579ec623473eabfe49731de5967306c4c",
"shasum": ""
},
"require": {
"composer/pcre": "^1||^2||^3",
"ext-ctype": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"ezyang/htmlpurifier": "^4.15",
"maennchen/zipstream-php": "^2.1 || ^3.0",
"markbaker/complex": "^3.0",
"markbaker/matrix": "^3.0",
"php": ">=7.4.0 <8.5.0",
"psr/http-client": "^1.0",
"psr/http-factory": "^1.0",
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
"dompdf/dompdf": "^1.0 || ^2.0 || ^3.0",
"friendsofphp/php-cs-fixer": "^3.2",
"mitoteam/jpgraph": "^10.3",
"mpdf/mpdf": "^8.1.1",
"phpcompatibility/php-compatibility": "^9.3",
"phpstan/phpstan": "^1.1",
"phpstan/phpstan-phpunit": "^1.0",
"phpunit/phpunit": "^8.5 || ^9.0",
"squizlabs/php_codesniffer": "^3.7",
"tecnickcom/tcpdf": "^6.5"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"ext-intl": "PHP Internationalization Functions",
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "https://blog.maartenballiauw.be"
},
{
"name": "Mark Baker",
"homepage": "https://markbakeruk.net"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net"
},
{
"name": "Erik Tilt"
},
{
"name": "Adrien Crivelli"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"support": {
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/1.30.1"
},
"time": "2025-10-26T16:01:04+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.3", "version": "1.9.3",
@ -11083,5 +10494,5 @@
"php": "^8.3" "php": "^8.3"
}, },
"platform-dev": {}, "platform-dev": {},
"plugin-api-version": "2.9.0" "plugin-api-version": "2.6.0"
} }

View File

@ -1,31 +0,0 @@
<?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('categories', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->text('description')->nullable();
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('categories');
}
};

View File

@ -1,33 +0,0 @@
<?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('inventories', function (Blueprint $table) {
$table->id();
$table->foreignId('category_id')->constrained()->onDelete('cascade');
$table->string('name');
$table->string('sku')->unique();
$table->integer('stock')->default(0);
$table->boolean('is_active')->default(true);
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('inventories');
}
};

View File

@ -1,31 +0,0 @@
<?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('prices', function (Blueprint $table) {
$table->id();
$table->foreignId('inventory_id')->constrained()->onDelete('cascade');
$table->decimal('cost', 10, 2);
$table->decimal('retail_price', 10, 2);
$table->decimal('tax', 5, 2);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('prices');
}
};

View File

@ -1,35 +0,0 @@
<?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('sales', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('invoice_number')->unique();
$table->decimal('subtotal', 10, 2);
$table->decimal('tax', 10, 2);
$table->decimal('total', 10, 2);
$table->enum('payment_method', ['cash', 'credit_card', 'debit_card']);
$table->enum('status', ['pending', 'completed', 'cancelled'])->default('pending');
$table->timestamps();
$table->softDeletes();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sales');
}
};

View File

@ -1,33 +0,0 @@
<?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('sale_details', function (Blueprint $table) {
$table->id();
$table->foreignId('sale_id')->constrained()->onDelete('cascade');
$table->foreignId('inventory_id')->constrained()->onDelete('cascade');
$table->string('product_name');
$table->integer('quantity');
$table->decimal('unit_price', 10, 2);
$table->decimal('subtotal', 10, 2);
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('sale_details');
}
};

View File

@ -1,44 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cash_registers', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('restrict');
$table->timestamp('opened_at');
$table->timestamp('closed_at')->nullable();
$table->decimal('initial_cash', 10, 2)->default(0);
$table->decimal('final_cash', 10, 2)->nullable();
$table->decimal('expected_cash', 10, 2)->nullable();
$table->decimal('difference', 10, 2)->nullable();
$table->decimal('total_sales', 10, 2)->default(0);
$table->integer('sales_count')->default(0);
$table->text('notes')->nullable();
$table->enum('status', ['open', 'closed'])->default('open');
$table->timestamps();
$table->index(['user_id', 'status']);
$table->index('opened_at');
});
Schema::table('sales', function (Blueprint $table) {
$table->foreignId('cash_register_id')->nullable()->after('user_id')->constrained()->onDelete('restrict');
});
}
public function down(): void
{
Schema::table('sales', function (Blueprint $table) {
$table->dropForeign(['cash_register_id']);
$table->dropColumn('cash_register_id');
});
Schema::dropIfExists('cash_registers');
}
};

View File

@ -1,29 +0,0 @@
<?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::table('sales', function (Blueprint $table) {
$table->decimal('cash_received', 10, 2)->nullable()->after('total');
$table->decimal('change', 10, 2)->nullable()->after('cash_received');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sales', function (Blueprint $table) {
$table->dropColumn(['cash_received', 'change']);
});
}
};

View File

@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('inventories', function (Blueprint $table) {
$table->string('barcode')
->nullable()
->unique()
->after('sku');
});
}
public function down(): void
{
Schema::table('inventories', function (Blueprint $table) {
$table->dropColumn('barcode');
});
}
};

View File

@ -1,32 +0,0 @@
<?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('clients', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('email')->nullable();
$table->string('phone')->nullable();
$table->string('address')->nullable();
$table->string('rfc')->unique()->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('clients');
}
};

View File

@ -1,35 +0,0 @@
<?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('inventory_serials', function (Blueprint $table) {
$table->id();
$table->foreignId('inventory_id')->constrained('inventories')->onDelete('cascade');
$table->string('serial_number')->unique();
$table->enum('status', ['disponible', 'vendido'])->default('disponible');
$table->foreignId('sale_detail_id')->nullable()->constrained('sale_details')->onDelete('set null');
$table->text('notes')->nullable();
$table->timestamps();
$table->index(['inventory_id', 'status']);
$table->index('serial_number');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('inventory_serials');
}
};

View File

@ -1,46 +0,0 @@
<?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::table('sales', function (Blueprint $table) {
$table->foreignId('client_id')->nullable()->after('user_id')->constrained()->onDelete('set null');
});
Schema::table('clients', function (Blueprint $table) {
$table->string('rfc', 13)->nullable()->change();
// Datos fiscales para facturación
$table->string('razon_social')->nullable()->after('rfc');
$table->string('regimen_fiscal')->nullable()->after('razon_social');
$table->string('cp_fiscal', 5)->nullable()->after('regimen_fiscal');
$table->string('uso_cfdi')->nullable()->after('cp_fiscal');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('sales', function (Blueprint $table) {
$table->dropForeign(['client_id']);
$table->dropColumn('client_id');
});
Schema::table('clients', function (Blueprint $table) {
$table->dropColumn(['razon_social', 'regimen_fiscal', 'cp_fiscal', 'uso_cfdi']);
// Revertimos el cambio en RFC a su estado original (varchar 255)
$table->string('rfc', 255)->comment('')->nullable()->change();
});
}
};

View File

@ -1,87 +0,0 @@
<?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

@ -1,31 +0,0 @@
<?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');
}
};

View File

@ -1,7 +1,4 @@
<?php <?php namespace Database\Seeders;
namespace Database\Seeders;
/** /**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved * @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/ */
@ -9,7 +6,6 @@
use App\Models\PermissionType; use App\Models\PermissionType;
use App\Models\Role; use App\Models\Role;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use Notsoweb\LaravelCore\Traits\MySql\RolePermission; use Notsoweb\LaravelCore\Traits\MySql\RolePermission;
use Spatie\Permission\Models\Permission; use Spatie\Permission\Models\Permission;
@ -29,19 +25,6 @@ class RoleSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// Limpiar tablas de permisos para poder re-ejecutar
app()[\Spatie\Permission\PermissionRegistrar::class]->forgetCachedPermissions();
DB::statement('SET FOREIGN_KEY_CHECKS=0;');
DB::table('role_has_permissions')->truncate();
DB::table('model_has_permissions')->truncate();
DB::table('model_has_roles')->truncate();
DB::table('permissions')->truncate();
DB::table('roles')->truncate();
DB::table('permission_types')->truncate();
DB::statement('SET FOREIGN_KEY_CHECKS=1;');
$users = PermissionType::create([ $users = PermissionType::create([
'name' => 'Usuarios' 'name' => 'Usuarios'
]); ]);
@ -83,55 +66,6 @@ public function run(): void
guardName: 'api' guardName: 'api'
); );
// ==================== NUEVOS PERMISOS PARA PDV ====================
// Permisos de Caja
$cashRegisterType = PermissionType::create([
'name' => 'Caja registradora'
]);
$cashRegisterIndex = $this->onIndex('cash-registers', 'Mostrar datos', $cashRegisterType, 'api');
$cashRegisterOpen = $this->onPermission('cash-registers.open', 'Abrir caja', $cashRegisterType, 'api');
$cashRegisterClose = $this->onPermission('cash-registers.close', 'Cerrar caja', $cashRegisterType, 'api');
$cashRegisterCurrent = $this->onPermission('cash-registers.current', 'Ver caja actual', $cashRegisterType, 'api');
// Permisos de Ventas
$salesType = PermissionType::create([
'name' => 'Ventas'
]);
$salesIndex = $this->onIndex('sales', 'Mostrar datos', $salesType, 'api');
$salesCreate = $this->onCreate('sales', 'Crear registros', $salesType, 'api');
$salesCancel = $this->onPermission('sales.cancel', 'Cancelar venta', $salesType, 'api');
// Permisos de Inventario (solo lectura)
$inventoryType = PermissionType::create([
'name' => 'Inventario'
]);
$inventoryIndex = $this->onIndex('inventario', 'Mostrar datos', $inventoryType, 'api');
$inventoryCreate = $this->onCreate('inventario', 'Crear registros', $inventoryType, 'api');
$inventoryEdit = $this->onEdit('inventario', 'Actualizar registro', $inventoryType, 'api');
$inventoryDestroy = $this->onDestroy('inventario', 'Eliminar registro', $inventoryType, 'api');
$inventoryImport = $this->onPermission('inventario.import', 'Importar productos desde Excel', $inventoryType, 'api');
// Permisos de Clientes
$clientsType = PermissionType::create([
'name' => 'Clientes'
]);
[
$clientIndex,
$clientCreate,
$clientEdit,
$clientDestroy
] = $this->onCRUD('clients', $clientsType, 'api');
// ==================== ROLES ====================
// Desarrollador // Desarrollador
Role::create([ Role::create([
'name' => 'developer', 'name' => 'developer',
@ -156,48 +90,7 @@ public function run(): void
$roleEdit, $roleEdit,
$roleDestroy, $roleDestroy,
$systemPulse, $systemPulse,
$activityIndex, $activityIndex
// Permisos completos de PDV
$cashRegisterIndex,
$cashRegisterOpen,
$cashRegisterClose,
$cashRegisterCurrent,
$salesIndex,
$salesCreate,
$salesCancel,
$inventoryIndex,
$inventoryImport,
$inventoryCreate,
$inventoryEdit,
$inventoryDestroy,
$clientIndex,
$clientCreate,
$clientEdit,
$clientDestroy
);
//Operador PDV (solo permisos de operación de caja y ventas)
Role::create([
'name' => 'operador_pdv',
'description' => 'Operador de Punto de Venta',
'guard_name' => 'api'
])->givePermissionTo(
// Caja
$cashRegisterIndex, // Ver historial de cajas
$cashRegisterOpen, // Abrir caja
$cashRegisterClose, // Cerrar caja
$cashRegisterCurrent, // Ver caja actual
// Ventas
$salesIndex, // Ver historial de ventas
$salesCreate, // Crear ventas
// Inventario (solo lectura)
$inventoryIndex,
$inventoryImport, // Importar productos
$inventoryCreate,
$inventoryEdit,
$inventoryDestroy,
// Clientes
$clientIndex, // Buscar clientes
); );
} }
} }

View File

@ -1,7 +1,4 @@
<?php <?php namespace Database\Seeders;
namespace Database\Seeders;
/** /**
* @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved * @copyright (c) 2025 Notsoweb Software (https://notsoweb.com) - All Rights Reserved
*/ */
@ -24,40 +21,34 @@ class UserSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
$developer = UserSecureSupport::create('developer@golsystems.com'); $developer = UserSecureSupport::create('developer@notsoweb.com');
User::firstOrCreate( User::create([
['email' => $developer->email],
[
'name' => 'Developer', 'name' => 'Developer',
'paternal' => 'Golsystems', 'paternal' => 'Notsoweb',
'maternal' => 'Dev', 'maternal' => 'Software',
'email' => $developer->email,
'password' => $developer->hash, 'password' => $developer->hash,
] ])->assignRole(__('developer'));
)->assignRole('developer');
$admin = UserSecureSupport::create('admin@golsystems.com'); $admin = UserSecureSupport::create('admin@notsoweb.com');
User::firstOrCreate( User::create([
['email' => $admin->email],
[
'name' => 'Admin', 'name' => 'Admin',
'paternal' => 'Golsystems', 'paternal' => 'Notsoweb',
'maternal' => 'Dev', 'maternal' => 'Software',
'password' => 'SoyAdmin123..', 'email' => $admin->email,
] 'password' => $admin->hash,
)->assignRole('admin'); ])->assignRole(__('admin'));
$operadorPdv = UserSecureSupport::create('opv@golsystems.com'); $demo = UserSecureSupport::create('demo@notsoweb.com');
User::firstOrCreate( User::create([
['email' => $operadorPdv->email], 'name' => 'Demo',
[ 'paternal' => 'Notsoweb',
'name' => 'Operador PDV', 'maternal' => 'Software',
'paternal' => 'Golsystems', 'email' => $demo->email,
'maternal' => 'Dev', 'password' => $demo->hash,
'password' => $operadorPdv->hash, ]);
]
)->assignRole('operador_pdv');
} }
} }

View File

@ -3,20 +3,16 @@ services:
build: build:
context: . context: .
dockerfile: dockerfile dockerfile: dockerfile
args:
USER_ID: 1000
GROUP_ID: 1000
working_dir: /var/www/pdv.backend working_dir: /var/www/pdv.backend
user: "1000:1000"
environment: environment:
- DB_HOST=mysql - DB_HOST=mysql
- DB_USERNAME=${DB_USERNAME} - DB_USERNAME=${DB_USERNAME}
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
- DB_DATABASE=${DB_DATABASE} - DB_DATABASE=${DB_DATABASE}
- DB_PORT=${DB_PORT} - DB_PORT=${DB_PORT}
- HOME=/tmp
volumes: volumes:
- ./:/var/www/pdv.backend - ./:/var/www/pdv.backend
- /var/www/pdv.backend/vendor
networks: networks:
- pdv-network - pdv-network
depends_on: depends_on:

View File

@ -34,11 +34,6 @@ RUN mkdir -p storage/app/keys storage/logs bootstrap/cache
RUN chown -R www-data:www-data /var/www/pdv.backend/storage /var/www/pdv.backend/bootstrap/cache RUN chown -R www-data:www-data /var/www/pdv.backend/storage /var/www/pdv.backend/bootstrap/cache
RUN chmod -R 775 /var/www/pdv.backend/storage /var/www/pdv.backend/bootstrap/cache RUN chmod -R 775 /var/www/pdv.backend/storage /var/www/pdv.backend/bootstrap/cache
# Configurar usuario para evitar problemas de permisos
ARG USER_ID=1000
ARG GROUP_ID=1000
RUN usermod -u ${USER_ID} www-data && groupmod -g ${GROUP_ID} www-data
EXPOSE 9000 EXPOSE 9000
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@ -1,12 +1,13 @@
#!/bin/bash #!/bin/bash
set -e set -e
# Configurar Git sin necesidad de permisos globales
export GIT_CONFIG_GLOBAL=/tmp/.gitconfig
git config --global --add safe.directory /var/www/pdv.backend git config --global --add safe.directory /var/www/pdv.backend
echo "=== Iniciando entrypoint DESARROLLO ===" echo "=== Iniciando entrypoint DESARROLLO ==="
chown -R www-data:www-data /var/www/pdv.backend/storage /var/www/pdv.backend/bootstrap/cache
chmod -R 775 /var/www/pdv.backend/storage /var/www/pdv.backend/bootstrap/cache
# Variables desde Docker environment # Variables desde Docker environment
DB_HOST=${DB_HOST:-mysql} DB_HOST=${DB_HOST:-mysql}
DB_USERNAME=${DB_USERNAME:-root} DB_USERNAME=${DB_USERNAME:-root}
@ -81,6 +82,7 @@ fi
# Establecer permisos correctos para las claves # Establecer permisos correctos para las claves
chmod 600 storage/app/keys/oauth-private.key chmod 600 storage/app/keys/oauth-private.key
chmod 644 storage/app/keys/oauth-public.key chmod 644 storage/app/keys/oauth-public.key
chown www-data:www-data storage/app/keys/oauth-*.key
echo "✓ Claves de Passport verificadas" echo "✓ Claves de Passport verificadas"

View File

@ -1,14 +1,4 @@
<?php <?php
use App\Http\Controllers\App\CashRegisterController;
use App\Http\Controllers\App\CategoryController;
use App\Http\Controllers\App\ClientController;
use App\Http\Controllers\App\InventoryController;
use App\Http\Controllers\App\PriceController;
use App\Http\Controllers\App\ReportController;
use App\Http\Controllers\App\SaleController;
use App\Http\Controllers\App\FacturaDataController;
use App\Http\Controllers\App\InventorySerialController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
/** /**
@ -28,47 +18,7 @@
/** Rutas protegidas (requieren autenticación) */ /** Rutas protegidas (requieren autenticación) */
Route::middleware('auth:api')->group(function() { Route::middleware('auth:api')->group(function() {
// Tus rutas protegidas // Tus rutas protegidas
//INVENTARIO
Route::resource('inventario', InventoryController::class);
Route::post('inventario/import', [InventoryController::class, 'import']);
Route::get('inventario/template/download', [InventoryController::class, 'downloadTemplate']);
// Números de serie
Route::resource('inventario.serials', InventorySerialController::class);
//CATEGORIAS
Route::resource('categorias', CategoryController::class);
//PRECIOS
Route::resource('precios', PriceController::class);
//VENTAS
Route::resource('/sales', SaleController::class);
Route::put('/sales/{sale}/cancel', [SaleController::class, 'cancel']);
// Rutas de caja
Route::prefix('cash-registers')->group(function () {
Route::get('/', [CashRegisterController::class, 'index']);
Route::get('/current', [CashRegisterController::class, 'current']);
Route::get('/{register}', [CashRegisterController::class, 'show']);
Route::post('/open', [CashRegisterController::class, 'open']);
Route::put('/{register}/close', [CashRegisterController::class, 'close']);
});
// REPORTES
Route::prefix('reports')->group(function () {
Route::get('top-selling-product', [ReportController::class, 'topSellingProduct']);
Route::get('products-without-movement', [ReportController::class, 'productsWithoutMovement']);
});
//CLIENTES
Route::resource('clients', ClientController::class);
}); });
/** Rutas públicas */ /** Rutas públicas */
// Formulario de datos fiscales para facturación // Tus rutas públicas
Route::prefix('facturacion')->group(function () {
Route::get('/{invoiceNumber}', [FacturaDataController::class, 'show']);
Route::post('/{invoiceNumber}', [FacturaDataController::class, 'store']);
});