Compare commits
17 Commits
main
...
pdv-servic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de0d477341 | ||
|
|
2c8189ca59 | ||
|
|
eaad8a57df | ||
| c1473cdb95 | |||
|
|
810aff1b0e | ||
| 08871b8dde | |||
|
|
fa8bea2060 | ||
|
|
06425157b8 | ||
| 388ba3eff2 | |||
|
|
40bc2b5735 | ||
| d5665f0448 | |||
|
|
e37a8b117d | ||
|
|
07ee72e548 | ||
|
|
a3c45c5efc | ||
|
|
599bb68ce6 | ||
|
|
569fbd09d7 | ||
|
|
f47a551d46 |
@ -29,7 +29,7 @@ class UserController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function index()
|
public function index()
|
||||||
{
|
{
|
||||||
$users = User::orderBy('name');
|
$users = User::orderBy('name')->where('id', '!=', 1);
|
||||||
|
|
||||||
QuerySupport::queryByKeys($users, ['name', 'email']);
|
QuerySupport::queryByKeys($users, ['name', 'email']);
|
||||||
|
|
||||||
|
|||||||
139
app/Http/Controllers/App/CashRegisterController.php
Normal file
139
app/Http/Controllers/App/CashRegisterController.php
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<?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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Http/Controllers/App/CategoryController.php
Normal file
54
app/Http/Controllers/App/CategoryController.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
127
app/Http/Controllers/App/ClientController.php
Normal file
127
app/Http/Controllers/App/ClientController.php
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
<?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.'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
185
app/Http/Controllers/App/FacturaDataController.php
Normal file
185
app/Http/Controllers/App/FacturaDataController.php
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
<?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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
192
app/Http/Controllers/App/InventoryController.php
Normal file
192
app/Http/Controllers/App/InventoryController.php
Normal file
@ -0,0 +1,192 @@
|
|||||||
|
<?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'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
138
app/Http/Controllers/App/InventorySerialController.php
Normal file
138
app/Http/Controllers/App/InventorySerialController.php
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
<?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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Http/Controllers/App/PriceController.php
Normal file
29
app/Http/Controllers/App/PriceController.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?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')]);
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Http/Controllers/App/ReportController.php
Normal file
94
app/Http/Controllers/App/ReportController.php
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
<?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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
74
app/Http/Controllers/App/SaleController.php
Normal file
74
app/Http/Controllers/App/SaleController.php
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
<?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()
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
app/Http/Requests/App/CatalogItemImportRequest.php
Normal file
88
app/Http/Requests/App/CatalogItemImportRequest.php
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
<?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%.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
75
app/Http/Requests/App/CatalogItemStoreRequest.php
Normal file
75
app/Http/Requests/App/CatalogItemStoreRequest.php
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?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%.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
72
app/Http/Requests/App/CatalogItemUpdateRequest.php
Normal file
72
app/Http/Requests/App/CatalogItemUpdateRequest.php
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
<?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%.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
37
app/Http/Requests/App/CategoryStoreRequest.php
Normal file
37
app/Http/Requests/App/CategoryStoreRequest.php
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
<?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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Http/Requests/App/CategoryUpdateRequest.php
Normal file
36
app/Http/Requests/App/CategoryUpdateRequest.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
41
app/Http/Requests/App/PriceUpdateRequest.php
Normal file
41
app/Http/Requests/App/PriceUpdateRequest.php
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<?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%.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
107
app/Http/Requests/App/SaleStoreRequest.php
Normal file
107
app/Http/Requests/App/SaleStoreRequest.php
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
<?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) . ').'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
243
app/Imports/ProductsImport.php
Normal file
243
app/Imports/ProductsImport.php
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
<?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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
app/Models/CashRegister.php
Normal file
62
app/Models/CashRegister.php
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
149
app/Models/CatalogItem.php
Normal file
149
app/Models/CatalogItem.php
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
|
|
||||||
|
class CatalogItem extends Model
|
||||||
|
{
|
||||||
|
use SoftDeletes;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'type',
|
||||||
|
'category_id',
|
||||||
|
'name',
|
||||||
|
'description',
|
||||||
|
'sku',
|
||||||
|
'barcode',
|
||||||
|
'is_stockable',
|
||||||
|
'stock',
|
||||||
|
'track_serials',
|
||||||
|
'attributes',
|
||||||
|
'is_active',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'is_stockable' => 'boolean',
|
||||||
|
'track_serials' => 'boolean',
|
||||||
|
'is_active' => 'boolean',
|
||||||
|
'attributes' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $appends = ['has_serials'];
|
||||||
|
|
||||||
|
// RELACIONES
|
||||||
|
|
||||||
|
public function category()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function price()
|
||||||
|
{
|
||||||
|
return $this->hasOne(Price::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function serials()
|
||||||
|
{
|
||||||
|
return $this->hasMany(InventorySerial::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function availableSerials()
|
||||||
|
{
|
||||||
|
return $this->hasMany(InventorySerial::class)
|
||||||
|
->where('status', 'disponible');
|
||||||
|
}
|
||||||
|
|
||||||
|
// HELPERS DE TIPO
|
||||||
|
|
||||||
|
public function isProduct(): bool
|
||||||
|
{
|
||||||
|
return $this->type === 'product';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isService(): bool
|
||||||
|
{
|
||||||
|
return $this->type === 'service';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isLaundry(): bool
|
||||||
|
{
|
||||||
|
return $this->type === 'laundry';
|
||||||
|
}
|
||||||
|
|
||||||
|
// HELPERS DE STOCK
|
||||||
|
|
||||||
|
public function getAvailableStockAttribute(): int
|
||||||
|
{
|
||||||
|
if (!$this->is_stockable) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->track_serials) {
|
||||||
|
return $this->availableSerials()->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->stock ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function syncStock(): void
|
||||||
|
{
|
||||||
|
if ($this->is_stockable && $this->track_serials) {
|
||||||
|
$this->update(['stock' => $this->availableSerials()->count()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function decrementStock(int $quantity): void
|
||||||
|
{
|
||||||
|
if ($this->is_stockable && !$this->track_serials) {
|
||||||
|
$this->decrement('stock', $quantity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function incrementStock(int $quantity): void
|
||||||
|
{
|
||||||
|
if ($this->is_stockable && !$this->track_serials) {
|
||||||
|
$this->increment('stock', $quantity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHasSerialsAttribute(): bool
|
||||||
|
{
|
||||||
|
return $this->track_serials &&
|
||||||
|
isset($this->attributes['serials_count']) &&
|
||||||
|
$this->attributes['serials_count'] > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
//HELPERS DE ATRIBUTOS
|
||||||
|
|
||||||
|
public function getAtributos(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->attributes[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
//SCOPES
|
||||||
|
|
||||||
|
public function scopeActive($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_active', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeOfType($query, string $type)
|
||||||
|
{
|
||||||
|
return $query->where('type', $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeProducts($query)
|
||||||
|
{
|
||||||
|
return $query->where('type', 'product');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeServices($query)
|
||||||
|
{
|
||||||
|
return $query->where('type', 'service');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function scopeStockable($query)
|
||||||
|
{
|
||||||
|
return $query->where('is_stockable', true);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Models/Category.php
Normal file
32
app/Models/Category.php
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Models/Client.php
Normal file
23
app/Models/Client.php
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Models/InventorySerial.php
Normal file
51
app/Models/InventorySerial.php
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
<?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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Models/Price.php
Normal file
35
app/Models/Price.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Models/Sale.php
Normal file
59
app/Models/Sale.php
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
<?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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Models/SaleDetail.php
Normal file
54
app/Models/SaleDetail.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
36
app/Models/SettingsGlobal.php
Normal file
36
app/Models/SettingsGlobal.php
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class SettingsGlobal extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'settings_global';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'business_name',
|
||||||
|
'logo_path',
|
||||||
|
'contact_info',
|
||||||
|
'invoice',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $casts = [
|
||||||
|
'contact_info' => 'array',
|
||||||
|
'invoice' => 'array',
|
||||||
|
];
|
||||||
|
|
||||||
|
//HELPERS
|
||||||
|
public static function get(): ?self
|
||||||
|
{
|
||||||
|
return self::first();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getContactValue(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->contact_info[$key] ?? $default;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInvoiceValue(string $key, $default = null)
|
||||||
|
{
|
||||||
|
return $this->invoice[$key] ?? $default;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
app/Services/CashRegisterService.php
Normal file
121
app/Services/CashRegisterService.php
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
<?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)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/Services/ProductService.php
Normal file
64
app/Services/ProductService.php
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
<?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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
100
app/Services/ReportService.php
Normal file
100
app/Services/ReportService.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?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();
|
||||||
|
}
|
||||||
|
}
|
||||||
158
app/Services/SaleService.php
Normal file
158
app/Services/SaleService.php
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@
|
|||||||
"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
593
composer.lock
generated
@ -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": "0cdf26aa072a7f833793cfba72e94e81",
|
"content-hash": "9f4c9c1c470ced74308c732439eb5b3d",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "brick/math",
|
"name": "brick/math",
|
||||||
@ -265,6 +265,162 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@ -809,6 +965,67 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@ -2968,6 +3185,272 @@
|
|||||||
],
|
],
|
||||||
"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",
|
||||||
@ -3841,6 +4324,112 @@
|
|||||||
},
|
},
|
||||||
"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",
|
||||||
@ -10494,5 +11083,5 @@
|
|||||||
"php": "^8.3"
|
"php": "^8.3"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.6.0"
|
"plugin-api-version": "2.9.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('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');
|
||||||
|
}
|
||||||
|
};
|
||||||
35
database/migrations/2025_12_30_151443_create_sales_table.php
Normal file
35
database/migrations/2025_12_30_151443_create_sales_table.php
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
<?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']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,35 @@
|
|||||||
|
<?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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
<?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();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::rename('inventories', 'catalog_items');
|
||||||
|
Schema::table('catalog_items', function (Blueprint $table) {
|
||||||
|
$table->string('type', 50)->default('product')->after('id');
|
||||||
|
$table->boolean('is_stockable')->default(false)->after('barcode');
|
||||||
|
$table->boolean('track_serials')->default(false)->after('stock');
|
||||||
|
$table->json('attributes')->nullable()->after('track_serials');
|
||||||
|
$table->text('description')->nullable()->after('name');
|
||||||
|
|
||||||
|
$table->string('sku')->nullable()->change();
|
||||||
|
$table->integer('stock')->nullable()->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('prices', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['inventory_id']);
|
||||||
|
$table->renameColumn('inventory_id', 'catalog_item_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
Schema::table('prices', function (Blueprint $table) {
|
||||||
|
$table->foreign('catalog_item_id')
|
||||||
|
->references('id')
|
||||||
|
->on('catalog_items')
|
||||||
|
->onDelete('cascade');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('inventory_serials', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['inventory_id']);
|
||||||
|
$table->renameColumn('inventory_id', 'catalog_item_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('inventory_serials', function (Blueprint $table) {
|
||||||
|
$table->foreign('catalog_item_id')
|
||||||
|
->references('id')
|
||||||
|
->on('catalog_items')
|
||||||
|
->onDelete('cascade');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('sale_details', function (Blueprint $table) {
|
||||||
|
$table->dropForeign(['inventory_id']);
|
||||||
|
$table->renameColumn('inventory_id', 'catalog_item_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
Schema::table('sale_details', function (Blueprint $table) {
|
||||||
|
$table->foreign('catalog_item_id')
|
||||||
|
->references('id')
|
||||||
|
->on('catalog_items')
|
||||||
|
->onDelete('cascade');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('catalog_items', function (Blueprint $table) {
|
||||||
|
// Quitar lo agregado
|
||||||
|
$table->dropColumn([
|
||||||
|
'type',
|
||||||
|
'is_stockable',
|
||||||
|
'track_serials',
|
||||||
|
'attributes',
|
||||||
|
'description',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Revertir cambios (ajusta si antes eran NOT NULL)
|
||||||
|
$table->string('sku')->nullable(false)->change();
|
||||||
|
$table->integer('stock')->nullable(false)->change();
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::rename('catalog_items', 'inventories');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('settings_global', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->string('business_name', 255);
|
||||||
|
$table->string('logo_path', 255)->nullable();
|
||||||
|
$table->json('contact_info')->nullable();
|
||||||
|
$table->json('invoice')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('settings_global');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1,4 +1,7 @@
|
|||||||
<?php namespace Database\Seeders;
|
<?php
|
||||||
|
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
@ -6,6 +9,7 @@
|
|||||||
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;
|
||||||
|
|
||||||
@ -25,6 +29,19 @@ 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'
|
||||||
]);
|
]);
|
||||||
@ -66,6 +83,55 @@ 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',
|
||||||
@ -90,7 +156,48 @@ 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
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
<?php namespace Database\Seeders;
|
<?php
|
||||||
|
|
||||||
|
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
|
||||||
*/
|
*/
|
||||||
@ -21,34 +24,40 @@ class UserSeeder extends Seeder
|
|||||||
*/
|
*/
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$developer = UserSecureSupport::create('developer@notsoweb.com');
|
$developer = UserSecureSupport::create('developer@golsystems.com');
|
||||||
|
|
||||||
User::create([
|
User::firstOrCreate(
|
||||||
'name' => 'Developer',
|
['email' => $developer->email],
|
||||||
'paternal' => 'Notsoweb',
|
[
|
||||||
'maternal' => 'Software',
|
'name' => 'Developer',
|
||||||
'email' => $developer->email,
|
'paternal' => 'Golsystems',
|
||||||
'password' => $developer->hash,
|
'maternal' => 'Dev',
|
||||||
])->assignRole(__('developer'));
|
'password' => $developer->hash,
|
||||||
|
]
|
||||||
|
)->assignRole('developer');
|
||||||
|
|
||||||
$admin = UserSecureSupport::create('admin@notsoweb.com');
|
$admin = UserSecureSupport::create('admin@golsystems.com');
|
||||||
|
|
||||||
User::create([
|
User::firstOrCreate(
|
||||||
'name' => 'Admin',
|
['email' => $admin->email],
|
||||||
'paternal' => 'Notsoweb',
|
[
|
||||||
'maternal' => 'Software',
|
'name' => 'Admin',
|
||||||
'email' => $admin->email,
|
'paternal' => 'Golsystems',
|
||||||
'password' => $admin->hash,
|
'maternal' => 'Dev',
|
||||||
])->assignRole(__('admin'));
|
'password' => 'SoyAdmin123..',
|
||||||
|
]
|
||||||
|
)->assignRole('admin');
|
||||||
|
|
||||||
$demo = UserSecureSupport::create('demo@notsoweb.com');
|
$operadorPdv = UserSecureSupport::create('opv@golsystems.com');
|
||||||
|
|
||||||
User::create([
|
User::firstOrCreate(
|
||||||
'name' => 'Demo',
|
['email' => $operadorPdv->email],
|
||||||
'paternal' => 'Notsoweb',
|
[
|
||||||
'maternal' => 'Software',
|
'name' => 'Operador PDV',
|
||||||
'email' => $demo->email,
|
'paternal' => 'Golsystems',
|
||||||
'password' => $demo->hash,
|
'maternal' => 'Dev',
|
||||||
]);
|
'password' => $operadorPdv->hash,
|
||||||
|
]
|
||||||
|
)->assignRole('operador_pdv');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,16 +3,20 @@ 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:
|
||||||
|
|||||||
@ -34,6 +34,11 @@ 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"]
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
#!/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}
|
||||||
@ -82,7 +81,6 @@ 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"
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,14 @@
|
|||||||
<?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;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -18,7 +28,47 @@
|
|||||||
/** 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 */
|
||||||
// Tus rutas públicas
|
// Formulario de datos fiscales para facturación
|
||||||
|
Route::prefix('facturacion')->group(function () {
|
||||||
|
Route::get('/{invoiceNumber}', [FacturaDataController::class, 'show']);
|
||||||
|
Route::post('/{invoiceNumber}', [FacturaDataController::class, 'store']);
|
||||||
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user