Compare commits
80 Commits
test/stres
...
homo
| Author | SHA1 | Date | |
|---|---|---|---|
| e9521e65d0 | |||
| d0625f25e5 | |||
| 1fdcfc1547 | |||
| ca4ea0aa6c | |||
| bb1f9c8e91 | |||
| 99fe94fa6a | |||
| a4b5c2eb19 | |||
| 2feca2bed5 | |||
| a35ff69140 | |||
| 75152b53a4 | |||
| 99594b17e7 | |||
| 85edea0661 | |||
| ab5ea7dd33 | |||
| c0057c237a | |||
| e4146288f1 | |||
| f11ba5a470 | |||
| e4db36e650 | |||
| ffa3f10b18 | |||
| 7acef66ee7 | |||
| 18f1217daa | |||
| ac44e82454 | |||
| 1e7a9b6876 | |||
| deea4fd4af | |||
| eda2343380 | |||
| 0baad641d5 | |||
| ddb8c3743f | |||
| c2233389d0 | |||
| 334ffe92e7 | |||
| a19ace3115 | |||
| acc0a68736 | |||
| dcf105c316 | |||
| f74bf41931 | |||
| 34217be630 | |||
| ee906d2d2e | |||
| 836aa1afd7 | |||
| 6896646c54 | |||
| 5e541f7cd0 | |||
| 80b8707771 | |||
| 58b15f3026 | |||
| 2e9355c85c | |||
| fa1e11a69f | |||
| 6a00d8d09c | |||
| fbfcb5cf63 | |||
| adce4ab59e | |||
| 5250d1a818 | |||
| 457eba68a9 | |||
| 52b0b339d6 | |||
| 9175df1d7f | |||
| 38341b2bda | |||
| 34a0a14404 | |||
| af516ce9e3 | |||
| dca35e7b0c | |||
| df853de843 | |||
| 4ba464bf85 | |||
| 208e929262 | |||
| ffe47d903e | |||
| 24983439b1 | |||
| d179a0f74e | |||
| 636e39e98e | |||
| bda94d02af | |||
| bfb16ef60e | |||
| 1b6303237a | |||
| 9da92c880a | |||
| 63012838a4 | |||
| 27544a23be | |||
| a71c80248c | |||
| 1ca8fb20af | |||
| ba167559a6 | |||
| 495515b1f3 | |||
| 781e282f16 | |||
| 7fb4c9674a | |||
| 0d85c3df19 | |||
| a93b260043 | |||
| c209e716e3 | |||
| b1f42775d6 | |||
| 0f6f39cce7 | |||
| 1ec7ab3e35 | |||
| ba0e2dd00c | |||
| c792e7d306 | |||
| 6ee3f22e41 |
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Administrator\Requests\StoreAdministratorRequest;
|
||||||
|
use App\Domains\Administrator\Requests\UpdateAdministratorRequest;
|
||||||
|
use App\Domains\Administrator\Resources\AdministratorResource;
|
||||||
|
use App\Domains\Administrator\Services\AdministratorService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AdminAppAdministratorController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AdministratorService $administratorService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return AdministratorResource::collection($this->administratorService->list(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->string('search')->trim()->toString() ?: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreAdministratorRequest $request): AdministratorResource
|
||||||
|
{
|
||||||
|
return AdministratorResource::make($this->administratorService->create(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateAdministratorRequest $request, int $administrator): AdministratorResource
|
||||||
|
{
|
||||||
|
return AdministratorResource::make($this->administratorService->update(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$administrator,
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $administrator): Response
|
||||||
|
{
|
||||||
|
$this->administratorService->delete($request->user()->tenant()->firstOrFail(), $administrator, $request->user());
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreAdministratorRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateAdministratorRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$administratorId = (int) $this->route('administrator');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($administratorId),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin User */
|
||||||
|
class AdministratorResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'nombre_apellido' => $this->nombre_apellido,
|
||||||
|
'dni' => $this->dni,
|
||||||
|
'email' => $this->email,
|
||||||
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'role' => $this->whenLoaded('role', fn () => [
|
||||||
|
'codigo' => $this->role?->codigo,
|
||||||
|
'nombre' => $this->role?->nombre,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Domains/Administrator/Services/AdministratorService.php
Normal file
99
app/Domains/Administrator/Services/AdministratorService.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AdministratorService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
||||||
|
|
||||||
|
/** @return Collection<int, User> */
|
||||||
|
public function list(Tenant $tenant, ?string $search = null): Collection
|
||||||
|
{
|
||||||
|
return $this->query($tenant)->with('role')
|
||||||
|
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
||||||
|
$query->where('nombre_apellido', 'like', "%{$search}%")
|
||||||
|
->orWhere('dni', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
}))
|
||||||
|
->orderBy('nombre_apellido')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function create(Tenant $tenant, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): User {
|
||||||
|
$administrator = User::query()->create([
|
||||||
|
...$this->attributes($data),
|
||||||
|
'password' => Str::random(64),
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
||||||
|
$administrator->email,
|
||||||
|
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $administrator->load('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function update(Tenant $tenant, int $administratorId, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $administratorId, $data): User {
|
||||||
|
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||||
|
$administrator->update($this->attributes($data));
|
||||||
|
|
||||||
|
return $administrator->load('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $administratorId, User $actor): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($tenant, $administratorId, $actor): void {
|
||||||
|
// Serialize deletions for this tenant, including requests already authenticated
|
||||||
|
// when another administrator removes their account.
|
||||||
|
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||||
|
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||||
|
if ($administrator->is($actor)) {
|
||||||
|
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
||||||
|
}
|
||||||
|
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get();
|
||||||
|
if ($activeAdministrators->count() <= 1) {
|
||||||
|
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
||||||
|
}
|
||||||
|
abort_unless($activeAdministrators->contains('id', $actor->id), 403);
|
||||||
|
$administrator->tokens()->delete();
|
||||||
|
$administrator->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function query(Tenant $tenant): Builder
|
||||||
|
{
|
||||||
|
return User::query()->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('rol_codigo', RoleCode::AdminApp->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function attributes(array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...Arr::only($data, ['nombre_apellido', 'dni']),
|
||||||
|
'email' => mb_strtolower(trim((string) $data['email'])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Domains/Administrator/documentacion/README.md
Normal file
63
app/Domains/Administrator/documentacion/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Administradores de AdminApp
|
||||||
|
|
||||||
|
CRUD de usuarios con rol `adminapp`, limitado al tenant del usuario autenticado.
|
||||||
|
Todos los administradores del tenant pueden gestionar esta sección.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Base: `/api/v1/adminapp/tenant/administrators`.
|
||||||
|
Requieren `auth:sanctum` y `adminapp.tenant`.
|
||||||
|
|
||||||
|
- `GET /`: listado ordenado por nombre; acepta `search` por nombre, DNI o email.
|
||||||
|
- `POST /`: alta; responde `201` con `data`.
|
||||||
|
- `PUT /{administrator}`: actualización de los tres campos; responde `200` con `data`.
|
||||||
|
- `DELETE /{administrator}`: baja lógica; responde `204`.
|
||||||
|
|
||||||
|
Alta y actualización reciben:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre_apellido": "Ada Lovelace",
|
||||||
|
"dni": "12345678",
|
||||||
|
"email": "ada@example.test"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nombre (hasta 255 caracteres), DNI (hasta 50) y email (hasta 255) son obligatorios.
|
||||||
|
El email se normaliza a minúsculas antes de validar y debe ser único entre
|
||||||
|
usuarios activos, sin importar su tenant o rol. Se permite reutilizar el email
|
||||||
|
de un usuario eliminado. Rol y tenant no son editables desde esta API.
|
||||||
|
|
||||||
|
Las respuestas incluyen `id`, `nombre_apellido`, `dni`, `email`, `rol_codigo`
|
||||||
|
y `role` (`codigo`, `nombre`). Nunca incluyen contraseña ni datos de escaneo.
|
||||||
|
|
||||||
|
## Alta y acceso
|
||||||
|
|
||||||
|
Se genera una contraseña aleatoria y un intento de establecimiento de contraseña
|
||||||
|
con motivo `administrator_created`, reutilizando `createForAdminAppEmail`.
|
||||||
|
El evento usa el canal `adminapp`; el listener existente envía el email después
|
||||||
|
del commit mediante la cola `emails`. Requiere la configuración de correo,
|
||||||
|
dominio AdminApp y worker existentes. No se envían contraseñas en texto plano.
|
||||||
|
|
||||||
|
## Eliminación y aislamiento
|
||||||
|
|
||||||
|
Las consultas de usuarios se limitan por tenant y rol `adminapp`. IDs ajenos,
|
||||||
|
usuarios eliminados y usuarios de otros roles devuelven `404`.
|
||||||
|
La validación de campos devuelve `422`; falta de autenticación, `401`, y rol
|
||||||
|
no autorizado, `403`.
|
||||||
|
|
||||||
|
No se permite eliminar al propio usuario ni dejar al tenant sin administradores
|
||||||
|
(`422`, error `administrator`). La eliminación bloquea la fila del tenant dentro
|
||||||
|
de una transacción para serializar bajas concurrentes. También verifica que el
|
||||||
|
actor siga activo, revoca tokens y aplica el borrado lógico existente en `users`.
|
||||||
|
|
||||||
|
No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el frontend.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
||||||
|
|
||||||
|
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
||||||
|
contraseña, restricciones de rol y tenant, baja lógica, tokens y protecciones de
|
||||||
|
eliminación. El caso de petición autenticada antes de la baja del actor se simula;
|
||||||
|
no es una prueba con conexiones concurrentes reales.
|
||||||
10
app/Domains/Administrator/routes/api.php
Normal file
10
app/Domains/Administrator/routes/api.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Administrator\Controllers\AdminAppAdministratorController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::apiResource('administrators', AdminAppAdministratorController::class)->except('show');
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
||||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -26,6 +27,7 @@ class ResetPasswordController extends Controller
|
|||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
$data['password'],
|
$data['password'],
|
||||||
|
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||||
)) {
|
)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.password_reset_invalid'),
|
'codigo' => __('api.auth.password_reset_invalid'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
||||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -25,6 +26,7 @@ class ValidateResetPasswordAttemptController extends Controller
|
|||||||
$result = $this->resetPasswordAttemptService->validateCode(
|
$result = $this->resetPasswordAttemptService->validateCode(
|
||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
|
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||||
);
|
);
|
||||||
|
|
||||||
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class ResetPasswordAttempt extends Model
|
|||||||
|
|
||||||
public const REASON_STAFF_CREATED = 'staff_created';
|
public const REASON_STAFF_CREATED = 'staff_created';
|
||||||
|
|
||||||
|
public const REASON_ADMINISTRATOR_CREATED = 'administrator_created';
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
public const STATUS_VALIDATED = 'validated';
|
public const STATUS_VALIDATED = 'validated';
|
||||||
|
|||||||
@@ -13,16 +13,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
|
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'rol_codigo' => RoleCode::User->value,
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Requests;
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
@@ -13,15 +14,26 @@ class RegisterUserRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function prepareForValidation(): void
|
||||||
* @return array<string, mixed>
|
{
|
||||||
*/
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
||||||
'dni' => ['nullable', 'string', 'max:255'],
|
'dni' => ['nullable', 'string', 'max:255'],
|
||||||
'telefono' => ['nullable', 'string', 'max:255'],
|
'telefono' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
class UpdateProfileRequest extends FormRequest
|
class UpdateProfileRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -12,6 +13,13 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -19,11 +27,13 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
Rule::unique('users', 'email')->ignore($this->user()->id),
|
Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($this->user()->id),
|
||||||
],
|
],
|
||||||
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
||||||
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
||||||
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
'password' => ['nullable', 'string', Password::min(8)->mixedCase()->symbols()],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminCredentialVerifier
|
|||||||
public function verify(string $email, string $password): bool
|
public function verify(string $email, string $password): bool
|
||||||
{
|
{
|
||||||
$admin = User::query()
|
$admin = User::query()
|
||||||
->where('email', mb_strtolower(trim($email)))
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::Admin->value)
|
->where('rol_codigo', RoleCode::Admin->value)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Auth\Services;
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
@@ -124,12 +125,12 @@ class GoogleAuthService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->where('google_id', $googleId)->first();
|
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->where('email', $email)->first();
|
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$user->forceFill(['google_id' => $googleId])->save();
|
$user->forceFill(['google_id' => $googleId])->save();
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class PasswordLoginService
|
|||||||
null,
|
null,
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$userAgent,
|
$userAgent,
|
||||||
null,
|
RoleCode::Scanner,
|
||||||
true,
|
true,
|
||||||
PermissionCode::ScanTickets->value,
|
PermissionCode::ScanTickets->value,
|
||||||
PasswordResetRequested::CHANNEL_SCANNER,
|
PasswordResetRequested::CHANNEL_SCANNER,
|
||||||
@@ -120,7 +120,7 @@ class PasswordLoginService
|
|||||||
$passwordResetChannel,
|
$passwordResetChannel,
|
||||||
): array {
|
): array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $normalizedEmail)
|
->where('active_email', $normalizedEmail)
|
||||||
->when(
|
->when(
|
||||||
$requiredRole !== null,
|
$requiredRole !== null,
|
||||||
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', RoleCode::User->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -65,7 +66,7 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::AdminApp->value)
|
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
->whereNotNull('tenant_codigo')
|
->whereNotNull('tenant_codigo')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -113,7 +114,7 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::Scanner->value)
|
->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
->whereNotNull('tenant_codigo')
|
->whereNotNull('tenant_codigo')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -152,14 +153,15 @@ class ResetPasswordAttemptService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateCode(string $email, string $code): string
|
public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', $role->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -207,14 +209,15 @@ class ResetPasswordAttemptService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetPassword(string $email, string $code, string $password): bool
|
public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint, $role): bool {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', $role->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Route::prefix('v1/adminapp')->group(function (): void {
|
|||||||
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->defaults('reset_role', 'adminapp')
|
||||||
->middleware('throttle:10,1');
|
->middleware('throttle:10,1');
|
||||||
Route::post('password/reset', ResetPasswordController::class)
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->defaults('reset_role', 'adminapp')
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->get('me', AdminAppMeController::class);
|
->get('me', AdminAppMeController::class);
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Route::prefix('v1/scanner')->group(function (): void {
|
|||||||
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->defaults('reset_role', 'scanner')
|
||||||
->middleware('throttle:10,1');
|
->middleware('throttle:10,1');
|
||||||
Route::post('password/reset', ResetPasswordController::class)
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->defaults('reset_role', 'scanner')
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||||
->get('me', ScannerMeController::class);
|
->get('me', ScannerMeController::class);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Desfile\Services;
|
|||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ class InvitationPurchaseProvisioner
|
|||||||
$allocation['type'],
|
$allocation['type'],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->createPurchaseItem(
|
$purchaseItemId = $this->createPurchaseItem(
|
||||||
$purchaseId,
|
$purchaseId,
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$variant,
|
$variant,
|
||||||
@@ -70,6 +71,7 @@ class InvitationPurchaseProvisioner
|
|||||||
);
|
);
|
||||||
$this->createTicketAndCommitStock(
|
$this->createTicketAndCommitStock(
|
||||||
$purchaseId,
|
$purchaseId,
|
||||||
|
$purchaseItemId,
|
||||||
$userId,
|
$userId,
|
||||||
(int) $catalogItem->id,
|
(int) $catalogItem->id,
|
||||||
$variant,
|
$variant,
|
||||||
@@ -102,7 +104,11 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
private function userId(DateTimeInterface $now): int
|
private function userId(DateTimeInterface $now): int
|
||||||
{
|
{
|
||||||
$user = DB::table('users')->where('email', self::USER_EMAIL)->first();
|
$user = DB::table('users')
|
||||||
|
->where('active_email', self::USER_EMAIL)
|
||||||
|
->where('rol_codigo', 'user')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($user !== null) {
|
if ($user !== null) {
|
||||||
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
||||||
@@ -326,12 +332,13 @@ class InvitationPurchaseProvisioner
|
|||||||
int $seat,
|
int $seat,
|
||||||
string $type,
|
string $type,
|
||||||
DateTimeInterface $now,
|
DateTimeInterface $now,
|
||||||
): void {
|
): int {
|
||||||
if (DB::table('compra_items')
|
$existingId = DB::table('compra_items')
|
||||||
->where('compra_id', $purchaseId)
|
->where('compra_id', $purchaseId)
|
||||||
->where('source_variant_id', $variant->id)
|
->where('source_variant_id', $variant->id)
|
||||||
->exists()) {
|
->value('id');
|
||||||
return;
|
if ($existingId !== null) {
|
||||||
|
return (int) $existingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes = [
|
$attributes = [
|
||||||
@@ -341,7 +348,7 @@ class InvitationPurchaseProvisioner
|
|||||||
['name' => 'Asiento', 'value' => (string) $seat],
|
['name' => 'Asiento', 'value' => (string) $seat],
|
||||||
];
|
];
|
||||||
|
|
||||||
DB::table('compra_items')->insert([
|
return DB::table('compra_items')->insertGetId([
|
||||||
'compra_id' => $purchaseId,
|
'compra_id' => $purchaseId,
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
@@ -367,13 +374,18 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
private function createTicketAndCommitStock(
|
private function createTicketAndCommitStock(
|
||||||
int $purchaseId,
|
int $purchaseId,
|
||||||
|
int $purchaseItemId,
|
||||||
int $userId,
|
int $userId,
|
||||||
int $catalogItemId,
|
int $catalogItemId,
|
||||||
object $variant,
|
object $variant,
|
||||||
DateTimeInterface $now,
|
DateTimeInterface $now,
|
||||||
): void {
|
): void {
|
||||||
|
$purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id')
|
||||||
|
? ['source_purchase_item_id' => $purchaseItemId]
|
||||||
|
: ['source_purchase_id' => $purchaseId];
|
||||||
|
|
||||||
if (DB::table('tickets')
|
if (DB::table('tickets')
|
||||||
->where('source_purchase_id', $purchaseId)
|
->where($purchaseReference)
|
||||||
->where('source_variant_id', $variant->id)
|
->where('source_variant_id', $variant->id)
|
||||||
->exists()) {
|
->exists()) {
|
||||||
return;
|
return;
|
||||||
@@ -420,7 +432,7 @@ class InvitationPurchaseProvisioner
|
|||||||
'ticket' => (string) Str::uuid(),
|
'ticket' => (string) Str::uuid(),
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'source_purchase_id' => $purchaseId,
|
...$purchaseReference,
|
||||||
'source_catalog_item_id' => $catalogItemId,
|
'source_catalog_item_id' => $catalogItemId,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\TicketFilterFormResource;
|
||||||
|
use App\Domains\Forms\Services\TicketFilterFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TicketFilterFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TicketFilterFormService $formService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): TicketFilterFormResource
|
||||||
|
{
|
||||||
|
$tenant = $request->user('sanctum')->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return TicketFilterFormResource::make($this->formService->get($tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\TicketFormResource;
|
||||||
|
use App\Domains\Forms\Services\TicketFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TicketFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected TicketFormService $ticketFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): TicketFormResource
|
||||||
|
{
|
||||||
|
return TicketFormResource::make(
|
||||||
|
$this->ticketFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Domains/Forms/Resources/TicketFilterFormResource.php
Normal file
21
app/Domains/Forms/Resources/TicketFilterFormResource.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TicketFilterFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => $this->resource['code'],
|
||||||
|
'action' => $this->resource['action'],
|
||||||
|
'method' => $this->resource['method'],
|
||||||
|
'fields' => $this->resource['fields'],
|
||||||
|
'columns' => $this->resource['columns'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Domains/Forms/Resources/TicketFormResource.php
Normal file
18
app/Domains/Forms/Resources/TicketFormResource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TicketFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'statuses' => $this->resource['statuses'],
|
||||||
|
'categories' => $this->resource['categories'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,27 +6,18 @@ use App\Domains\Purchase\Models\Purchase;
|
|||||||
|
|
||||||
class SaleFormService
|
class SaleFormService
|
||||||
{
|
{
|
||||||
/** @return array{statuses: list<array{code: string, name: string}>} */
|
/** @return array{statuses: list<array{value: string, label: string, real_statuses: list<string>}>} */
|
||||||
public function get(): array
|
public function get(): array
|
||||||
{
|
{
|
||||||
$names = [
|
|
||||||
Purchase::STATUS_CREATED => 'Creada',
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
|
||||||
Purchase::STATUS_IN_REVIEW => 'En revisión',
|
|
||||||
Purchase::STATUS_PAID => 'Confirmada',
|
|
||||||
Purchase::STATUS_CANCELLED => 'Cancelada',
|
|
||||||
Purchase::STATUS_REJECTED => 'Rechazada',
|
|
||||||
Purchase::STATUS_EXPIRED => 'Vencida',
|
|
||||||
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'statuses' => array_map(
|
'statuses' => array_map(
|
||||||
fn (string $status): array => [
|
fn (string $code, array $definition): array => [
|
||||||
'code' => $status,
|
'value' => $code,
|
||||||
'name' => $names[$status],
|
'label' => $definition['name'],
|
||||||
|
'real_statuses' => $definition['statuses'],
|
||||||
],
|
],
|
||||||
Purchase::statuses(),
|
array_keys(Purchase::adminStatuses()),
|
||||||
|
array_values(Purchase::adminStatuses()),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
152
app/Domains/Forms/Services/TicketFilterFormService.php
Normal file
152
app/Domains/Forms/Services/TicketFilterFormService.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
||||||
|
|
||||||
|
class TicketFilterFormService
|
||||||
|
{
|
||||||
|
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TicketFormService $ticketFormService,
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$fields = $this->commonFields();
|
||||||
|
|
||||||
|
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
|
||||||
|
$fields = [
|
||||||
|
...$this->fiestaFutbolInfantilFields($tenant),
|
||||||
|
...$this->commonFields(includeDate: false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'tickets_filter',
|
||||||
|
'action' => '/api/v1/adminapp/tenant/tickets',
|
||||||
|
'method' => 'GET',
|
||||||
|
'fields' => $fields,
|
||||||
|
'columns' => $this->columnService->publicColumns($tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function fiestaFutbolInfantilFields(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$form = $this->ticketFormService->getForFilters($tenant);
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'name' => 'category',
|
||||||
|
'query_param' => 'category',
|
||||||
|
'label' => 'Categoría',
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => 'Categoría',
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $category): array => [
|
||||||
|
'value' => $category['value'],
|
||||||
|
'label' => $category['label'],
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'field' => 'product',
|
||||||
|
'disabled' => $category['products'] === [],
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'field' => 'type',
|
||||||
|
'disabled' => $product['types'] === [],
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $type): array => [
|
||||||
|
'value' => $type['value'],
|
||||||
|
'label' => $type['label'],
|
||||||
|
'children' => [[
|
||||||
|
'field' => 'size',
|
||||||
|
'disabled' => ($type['sizes'] ?? []) === [],
|
||||||
|
'options' => $type['sizes'] ?? [],
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
$product['types'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$category['products'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'field' => 'date',
|
||||||
|
'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true),
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$form['categories'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
$this->dependentSelect('product', 'Producto', 'category'),
|
||||||
|
$this->dependentSelect('type', 'Tipo', 'product'),
|
||||||
|
$this->dependentSelect('size', 'Talle', 'type'),
|
||||||
|
[
|
||||||
|
...$this->dependentSelect('date', 'Fecha', 'category'),
|
||||||
|
'type' => 'date',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function commonFields(bool $includeDate = true): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...($includeDate ? [[
|
||||||
|
'name' => 'date',
|
||||||
|
'query_param' => 'date',
|
||||||
|
'label' => 'Fecha',
|
||||||
|
'type' => 'date',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
]] : []),
|
||||||
|
[
|
||||||
|
'name' => 'status',
|
||||||
|
'query_param' => 'status',
|
||||||
|
'label' => 'Estado',
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => 'Estado',
|
||||||
|
'options' => [
|
||||||
|
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
||||||
|
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
||||||
|
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function dependentSelect(string $name, string $label, string $dependency): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'query_param' => $name,
|
||||||
|
'label' => $label,
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => $label,
|
||||||
|
'depends_on' => $dependency,
|
||||||
|
'disabled' => true,
|
||||||
|
'options' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
400
app/Domains/Forms/Services/TicketFormService.php
Normal file
400
app/Domains/Forms/Services/TicketFormService.php
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class TicketFormService
|
||||||
|
{
|
||||||
|
private const PRODUCT = 'product';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string|null, product: string, type: string|null, order: int}>
|
||||||
|
*/
|
||||||
|
private const CATEGORY_PRESENTATIONS = [
|
||||||
|
'entradas' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => null,
|
||||||
|
'order' => 1,
|
||||||
|
],
|
||||||
|
'alojamientos' => [
|
||||||
|
'label' => 'Camping',
|
||||||
|
'product' => 'tipo_alojamiento',
|
||||||
|
'type' => null,
|
||||||
|
'order' => 2,
|
||||||
|
],
|
||||||
|
'camping' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => 'tipo_alojamiento',
|
||||||
|
'type' => null,
|
||||||
|
'order' => 2,
|
||||||
|
],
|
||||||
|
'comidas' => [
|
||||||
|
'label' => 'Comida',
|
||||||
|
'product' => 'event_date',
|
||||||
|
'type' => 'horario',
|
||||||
|
'order' => 3,
|
||||||
|
],
|
||||||
|
'comida' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => 'event_date',
|
||||||
|
'type' => 'horario',
|
||||||
|
'order' => 3,
|
||||||
|
],
|
||||||
|
'merchandising' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => 'color',
|
||||||
|
'order' => 4,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string|null, product: string, type: string|null, size: string|null, order: int}>
|
||||||
|
*/
|
||||||
|
private const FILTER_CATEGORY_PRESENTATIONS = [
|
||||||
|
'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1],
|
||||||
|
'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
|
||||||
|
'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
|
||||||
|
'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
|
||||||
|
'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
|
||||||
|
'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$items = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('has_tickets', true)
|
||||||
|
->whereHas('category')
|
||||||
|
->with($this->relations())
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->build($items, self::CATEGORY_PRESENTATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return active catalog options plus soft-deleted sources still referenced by
|
||||||
|
* tickets, so historical tickets never become impossible to filter.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getForFilters(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$historicalVariantIds = Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereNotNull('source_variant_id')
|
||||||
|
->distinct()
|
||||||
|
->pluck('source_variant_id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
$historicalCatalogItemIds = Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereNotNull('source_catalog_item_id')
|
||||||
|
->distinct()
|
||||||
|
->pluck('source_catalog_item_id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->merge(
|
||||||
|
Variant::withTrashed()
|
||||||
|
->whereKey($historicalVariantIds)
|
||||||
|
->pluck('catalog_item_id')
|
||||||
|
->map(fn ($id): int => (int) $id),
|
||||||
|
)
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$items = CatalogItem::withTrashed()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('category')
|
||||||
|
->where(function ($query) use ($historicalCatalogItemIds): void {
|
||||||
|
$query
|
||||||
|
->where(function ($activeQuery): void {
|
||||||
|
$activeQuery
|
||||||
|
->whereNull('catalog_items.deleted_at')
|
||||||
|
->where('has_tickets', true);
|
||||||
|
})
|
||||||
|
->orWhereIn('catalog_items.id', $historicalCatalogItemIds);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'category',
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants' => fn ($query) => $query
|
||||||
|
->withTrashed()
|
||||||
|
->where(function ($variantQuery) use ($historicalVariantIds): void {
|
||||||
|
$variantQuery
|
||||||
|
->whereNull('variantes.deleted_at')
|
||||||
|
->orWhereIn('variantes.id', $historicalVariantIds);
|
||||||
|
}),
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.eventDate',
|
||||||
|
])
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, CatalogItem> $items
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function build(Collection $items, array $presentations, bool $includeSizes = false): array
|
||||||
|
{
|
||||||
|
$categories = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$sourceCategory = trim((string) $item->category?->nombre);
|
||||||
|
$categoryValue = mb_strtolower($sourceCategory);
|
||||||
|
$presentation = $presentations[$categoryValue] ?? [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => null,
|
||||||
|
'size' => null,
|
||||||
|
'order' => PHP_INT_MAX,
|
||||||
|
];
|
||||||
|
|
||||||
|
$categories[$categoryValue] ??= [
|
||||||
|
'value' => $categoryValue,
|
||||||
|
'label' => $presentation['label'] ?? $sourceCategory,
|
||||||
|
'order' => $presentation['order'],
|
||||||
|
'products' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->products(
|
||||||
|
$item,
|
||||||
|
$presentation['product'],
|
||||||
|
$presentation['type'],
|
||||||
|
$presentation['size'] ?? null,
|
||||||
|
) as $product) {
|
||||||
|
$productValue = $product['value'];
|
||||||
|
$existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [
|
||||||
|
'value' => $productValue,
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => [],
|
||||||
|
'sizes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($product['types'] as $type) {
|
||||||
|
$existingProduct['types'][$type['value']] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($product['sizes'] as $size) {
|
||||||
|
$existingProduct['sizes'][$size['value']] = $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories[$categoryValue]['products'][$productValue] = $existingProduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order']
|
||||||
|
?: $left['label'] <=> $right['label']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'statuses' => [
|
||||||
|
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
||||||
|
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
||||||
|
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
||||||
|
],
|
||||||
|
'categories' => array_values(array_map(
|
||||||
|
fn (array $category): array => [
|
||||||
|
'value' => $category['value'],
|
||||||
|
'label' => $category['label'],
|
||||||
|
'products' => array_values(array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => array_values($product['types']),
|
||||||
|
...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []),
|
||||||
|
],
|
||||||
|
$category['products'],
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
$categories,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function relations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category',
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.eventDate',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>,
|
||||||
|
* sizes: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array
|
||||||
|
{
|
||||||
|
if ($productCode === self::PRODUCT) {
|
||||||
|
return [[
|
||||||
|
'value' => $item->slug,
|
||||||
|
'label' => $item->nombre,
|
||||||
|
'types' => $this->types($item, $typeCode, $sizeCode),
|
||||||
|
'sizes' => $this->types($item, $sizeCode),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = [];
|
||||||
|
|
||||||
|
foreach ($item->variants as $variant) {
|
||||||
|
foreach ($this->variantOptions($variant, $productCode) as $productOption) {
|
||||||
|
$productValue = $productOption['value'];
|
||||||
|
$products[$productValue] ??= [
|
||||||
|
'value' => $productValue,
|
||||||
|
'label' => $this->optionLabel($productOption['label'], $productCode),
|
||||||
|
'types' => [],
|
||||||
|
'sizes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
|
||||||
|
$this->mergeTypeOption(
|
||||||
|
$products[$productValue]['types'],
|
||||||
|
$typeOption,
|
||||||
|
$variant,
|
||||||
|
$sizeCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
|
||||||
|
$products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => array_values($product['types']),
|
||||||
|
'sizes' => array_values($product['sizes']),
|
||||||
|
],
|
||||||
|
$products,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
|
||||||
|
foreach ($item->variants as $variant) {
|
||||||
|
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
|
||||||
|
$this->mergeTypeOption($types, $typeOption, $variant, $sizeCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(function (array $type) use ($sizeCode): array {
|
||||||
|
if ($sizeCode !== null) {
|
||||||
|
$type['sizes'] = array_values($type['sizes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}, $types));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $types
|
||||||
|
* @param array{value: string, label: string} $typeOption
|
||||||
|
*/
|
||||||
|
private function mergeTypeOption(
|
||||||
|
array &$types,
|
||||||
|
array $typeOption,
|
||||||
|
Variant $variant,
|
||||||
|
?string $sizeCode,
|
||||||
|
): void {
|
||||||
|
$typeValue = $typeOption['value'];
|
||||||
|
$types[$typeValue] ??= [
|
||||||
|
...$typeOption,
|
||||||
|
...($sizeCode !== null ? ['sizes' => []] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
|
||||||
|
$types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{value: string, label: string}> */
|
||||||
|
private function variantOptions(Variant $variant, ?string $attributeCode): array
|
||||||
|
{
|
||||||
|
if ($attributeCode === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection = $variant->selectionOptions($variant->catalogItem->itemAttributes)
|
||||||
|
->get($attributeCode);
|
||||||
|
|
||||||
|
if ($selection === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_is_list($selection) ? $selection : [$selection];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function optionLabel(string $label, string $attributeCode): string
|
||||||
|
{
|
||||||
|
if ($attributeCode !== 'event_date') {
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$day, $month] = array_pad(explode('/', $label), 2, null);
|
||||||
|
|
||||||
|
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm
|
|||||||
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
|
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
|
||||||
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
|
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
|
||||||
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
|
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
|
||||||
|
- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil.
|
||||||
|
|
||||||
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
|
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`:
|
|||||||
- `GET /event`.
|
- `GET /event`.
|
||||||
- `GET /sale`.
|
- `GET /sale`.
|
||||||
- `GET /staff`.
|
- `GET /staff`.
|
||||||
|
- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets.
|
||||||
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
|
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
|
||||||
|
|
||||||
## Dependencias
|
## Dependencias
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
|
|||||||
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\TicketFilterFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\TicketFormController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/forms')
|
Route::prefix('v1/adminapp/forms')
|
||||||
@@ -13,6 +15,13 @@ Route::prefix('v1/adminapp/forms')
|
|||||||
Route::get('event', EventFormController::class);
|
Route::get('event', EventFormController::class);
|
||||||
Route::get('sale', SaleFormController::class);
|
Route::get('sale', SaleFormController::class);
|
||||||
Route::get('staff', StaffFormController::class);
|
Route::get('staff', StaffFormController::class);
|
||||||
|
Route::get('tickets-filter', TicketFilterFormController::class)
|
||||||
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
|
->name('adminapp.forms.tickets-filter');
|
||||||
|
Route::get(
|
||||||
|
'fiesta-futbol-infantil/ticket',
|
||||||
|
TicketFormController::class
|
||||||
|
);
|
||||||
Route::get(
|
Route::get(
|
||||||
'fiesta-futbol-infantil/merchandise',
|
'fiesta-futbol-infantil/merchandise',
|
||||||
MerchandiseFormController::class
|
MerchandiseFormController::class
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ namespace App\Domains\Integration\Controllers;
|
|||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
use App\Domains\Integration\Requests\StoreClientIntegrationRequest;
|
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||||
use App\Domains\Integration\Services\ClientIntegrationService;
|
use App\Domains\Integration\Services\ClientIntegrationService;
|
||||||
|
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
@@ -35,7 +36,7 @@ class ClientIntegrationController extends Controller
|
|||||||
}
|
}
|
||||||
|
|
||||||
public function store(
|
public function store(
|
||||||
StoreClientIntegrationRequest $request,
|
ConfigureIntegrationRequest $request,
|
||||||
Client $client,
|
Client $client,
|
||||||
string $integrationCode,
|
string $integrationCode,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
@@ -61,4 +62,11 @@ class ClientIntegrationController extends Controller
|
|||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
|
||||||
|
{
|
||||||
|
$service->detach($client, $integrationCode);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ class IntegrationController extends Controller
|
|||||||
|
|
||||||
public function destroy(Integration $integration)
|
public function destroy(Integration $integration)
|
||||||
{
|
{
|
||||||
|
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
|
||||||
$integration->delete();
|
$integration->delete();
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||||
|
use App\Domains\Integration\Resources\IntegrationAssociationResource;
|
||||||
|
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class WebsiteTypeIntegrationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly IntegrationAssociationService $service) {}
|
||||||
|
|
||||||
|
public function index(WebsiteType $websiteType)
|
||||||
|
{
|
||||||
|
return IntegrationAssociationResource::collection($websiteType->integrations()->with('integrationInstance')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
|
||||||
|
|
||||||
|
return new IntegrationAssociationResource($this->service->configure(
|
||||||
|
$websiteType,
|
||||||
|
$integration,
|
||||||
|
$request->validated('integration_data'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
$this->service->detach($websiteType, $integrationCode);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,22 +3,15 @@
|
|||||||
namespace App\Domains\Integration\Models;
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
class ClientIntegration extends Model
|
class ClientIntegration extends Model
|
||||||
{
|
{
|
||||||
protected $hidden = ['integration_data'];
|
|
||||||
|
|
||||||
protected $fillable = [
|
protected $fillable = [
|
||||||
'client_id',
|
'client_id',
|
||||||
'integration_code',
|
'integration_code',
|
||||||
'integration_data',
|
'integration_instance_id',
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'integration_data' => EncryptedIntegrationData::class,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/** @return BelongsTo<Client, $this> */
|
/** @return BelongsTo<Client, $this> */
|
||||||
@@ -32,4 +25,10 @@ class ClientIntegration extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||||
|
public function integrationInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IntegrationInstance::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Integration\Models;
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Integration extends Model
|
class Integration extends Model
|
||||||
{
|
{
|
||||||
@@ -13,16 +14,29 @@ class Integration extends Model
|
|||||||
'name',
|
'name',
|
||||||
'url',
|
'url',
|
||||||
'integration_data_schema',
|
'integration_data_schema',
|
||||||
'requires_client_configuration',
|
'requires_configuration',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'integration_data_schema' => 'array',
|
'integration_data_schema' => 'array',
|
||||||
'requires_client_configuration' => 'boolean',
|
'requires_configuration' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function clientIntegrations()
|
/** @return HasMany<IntegrationInstance, $this> */
|
||||||
|
public function instances(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function clientIntegrations(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
|
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function websiteTypeIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Domains/Integration/Models/IntegrationInstance.php
Normal file
41
app/Domains/Integration/Models/IntegrationInstance.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class IntegrationInstance extends Model
|
||||||
|
{
|
||||||
|
// The ciphertext changes whenever credentials are saved, so old tokens cannot be reused.
|
||||||
|
public function tokenCacheKey(): string
|
||||||
|
{
|
||||||
|
return 'integration_token:instance:'.$this->id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $fillable = ['integration_code', 'name', 'integration_data'];
|
||||||
|
|
||||||
|
protected $hidden = ['integration_data'];
|
||||||
|
|
||||||
|
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function clientIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ClientIntegration::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function websiteTypeIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Domains/Integration/Models/WebsiteTypeIntegration.php
Normal file
30
app/Domains/Integration/Models/WebsiteTypeIntegration.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WebsiteTypeIntegration extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['website_type_code', 'integration_code', 'integration_instance_id'];
|
||||||
|
|
||||||
|
/** @return BelongsTo<WebsiteType, $this> */
|
||||||
|
public function websiteType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||||
|
public function integrationInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IntegrationInstance::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Domains/Integration/Policies/IntegrationPolicy.php
Normal file
14
app/Domains/Integration/Policies/IntegrationPolicy.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Policies;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
|
||||||
|
class IntegrationPolicy
|
||||||
|
{
|
||||||
|
public function manage(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->rol_codigo === RoleCode::Admin->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,20 +6,19 @@ use App\Domains\Integration\Models\Integration;
|
|||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class StoreClientIntegrationRequest extends FormRequest
|
class ConfigureIntegrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
protected ?Integration $integrationModel = null;
|
protected ?Integration $integrationModel = null;
|
||||||
|
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
return $this->user()?->can('manage', Integration::class) ?? false;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
protected function prepareForValidation(): void
|
||||||
{
|
{
|
||||||
$integrationCode = $this->route('integration_code');
|
|
||||||
$this->integrationModel = Integration::query()
|
$this->integrationModel = Integration::query()
|
||||||
->where('integration_code', $integrationCode)
|
->where('integration_code', $this->route('integration_code'))
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $this->integrationModel) {
|
if (! $this->integrationModel) {
|
||||||
@@ -31,7 +30,7 @@ class StoreClientIntegrationRequest extends FormRequest
|
|||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$rules = [];
|
$rules = ['integration_data' => ['present', 'array']];
|
||||||
|
|
||||||
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
||||||
$rules['integration_data.'.$field] = $rule;
|
$rules['integration_data.'.$field] = $rule;
|
||||||
@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
'requires_configuration' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Integration\Requests;
|
namespace App\Domains\Integration\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateIntegrationRequest extends FormRequest
|
class UpdateIntegrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -19,9 +20,8 @@ class UpdateIntegrationRequest extends FormRequest
|
|||||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
'requires_configuration' => ['sometimes', 'boolean'],
|
||||||
// the code shouldn't ideally be updatable, but if it is:
|
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
|
||||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IntegrationAssociationResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'client_id' => $this->when(isset($this->client_id), $this->client_id),
|
||||||
|
'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code),
|
||||||
|
'integration_code' => $this->integration_code,
|
||||||
|
'integration_instance_id' => $this->integration_instance_id,
|
||||||
|
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IntegrationInstanceResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'integration_code' => $this->integration_code,
|
||||||
|
'name' => $this->name,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ namespace App\Domains\Integration\Services;
|
|||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Models\ClientIntegration;
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
@@ -32,9 +34,9 @@ abstract class BaseIntegrationService
|
|||||||
protected ?Integration $integration = null;
|
protected ?Integration $integration = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The client-owned integration configuration.
|
* The effective integration instance.
|
||||||
*/
|
*/
|
||||||
protected ?ClientIntegration $clientIntegration = null;
|
protected ?IntegrationInstance $integrationInstance = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the integration code.
|
* Set the integration code.
|
||||||
@@ -85,7 +87,7 @@ abstract class BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the integration definition and its client-owned configuration.
|
* Load the integration definition and its effective instance configuration.
|
||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
@@ -95,6 +97,7 @@ abstract class BaseIntegrationService
|
|||||||
throw new Exception('Integration code is not set.');
|
throw new Exception('Integration code is not set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->integrationInstance = null;
|
||||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||||
if (! $this->integration) {
|
if (! $this->integration) {
|
||||||
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
||||||
@@ -104,11 +107,18 @@ abstract class BaseIntegrationService
|
|||||||
throw new Exception('Client context is not set.');
|
throw new Exception('Client context is not set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
|
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
|
||||||
->where('integration_code', $this->integrationCode)
|
->where('integration_code', $this->integrationCode)
|
||||||
->first();
|
->first()?->integrationInstance;
|
||||||
|
|
||||||
if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
|
if (! $this->integrationInstance && $this->tenant?->website_type_code) {
|
||||||
|
$this->integrationInstance = WebsiteTypeIntegration::with('integrationInstance')
|
||||||
|
->where('website_type_code', $this->tenant->website_type_code)
|
||||||
|
->where('integration_code', $this->integrationCode)
|
||||||
|
->first()?->integrationInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->integrationInstance && $this->integration->requires_configuration) {
|
||||||
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -131,15 +141,15 @@ abstract class BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get an integration setting from the client-owned configuration.
|
* Get an integration setting from the effective instance configuration.
|
||||||
*/
|
*/
|
||||||
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
|
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
|
||||||
return $default;
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->clientIntegration->integration_data[$key] ?? $default;
|
return $this->integrationInstance->integration_data[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ class ClientIntegrationService
|
|||||||
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
|
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
|
||||||
{
|
{
|
||||||
return $client->integrations()
|
return $client->integrations()
|
||||||
|
->with(['integration', 'integrationInstance'])
|
||||||
->where('integration_code', $integrationCode)
|
->where('integration_code', $integrationCode)
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
@@ -20,7 +21,7 @@ class ClientIntegrationService
|
|||||||
/** @return Collection<int, ClientIntegration> */
|
/** @return Collection<int, ClientIntegration> */
|
||||||
public function getAllForClient(Client $client): Collection
|
public function getAllForClient(Client $client): Collection
|
||||||
{
|
{
|
||||||
return $client->integrations()->with('integration')->get();
|
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateOrCreateIntegration(
|
public function updateOrCreateIntegration(
|
||||||
@@ -29,13 +30,7 @@ class ClientIntegrationService
|
|||||||
array $data,
|
array $data,
|
||||||
): ClientIntegration {
|
): ClientIntegration {
|
||||||
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
||||||
$clientIntegration = ClientIntegration::query()->updateOrCreate(
|
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
|
||||||
[
|
|
||||||
'client_id' => $client->id,
|
|
||||||
'integration_code' => $integration->integration_code,
|
|
||||||
],
|
|
||||||
['integration_data' => $data],
|
|
||||||
);
|
|
||||||
|
|
||||||
$service = $this->resolveService($integration->integration_code);
|
$service = $this->resolveService($integration->integration_code);
|
||||||
$service?->forClient($client)->onSetup();
|
$service?->forClient($client)->onSetup();
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IntegrationAssociationService
|
||||||
|
{
|
||||||
|
public function configure(Client|WebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|WebsiteTypeIntegration
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|WebsiteTypeIntegration {
|
||||||
|
$instance = IntegrationInstance::create([
|
||||||
|
'integration_code' => $integration->integration_code,
|
||||||
|
'name' => $integration->name.' / '.$this->ownerName($owner),
|
||||||
|
'integration_data' => $integrationData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->associate($owner, $integration->integration_code, $instance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|WebsiteTypeIntegration {
|
||||||
|
$association = $owner->integrations()
|
||||||
|
->where('integration_code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
$previousInstanceId = $association?->integration_instance_id;
|
||||||
|
|
||||||
|
$instanceIds = array_values(array_unique(array_filter([
|
||||||
|
$previousInstanceId,
|
||||||
|
$instance->id,
|
||||||
|
])));
|
||||||
|
sort($instanceIds);
|
||||||
|
|
||||||
|
$instances = IntegrationInstance::query()
|
||||||
|
->whereKey($instanceIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
$instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id);
|
||||||
|
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
|
||||||
|
|
||||||
|
$association = $owner->integrations()->updateOrCreate(
|
||||||
|
['integration_code' => $code],
|
||||||
|
['integration_instance_id' => $instance->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($previousInstanceId && $previousInstanceId !== $instance->id) {
|
||||||
|
$this->deleteIfUnused($previousInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $association->load('integrationInstance');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detach(Client|WebsiteType $owner, string $code): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($owner, $code): void {
|
||||||
|
$association = $owner->integrations()
|
||||||
|
->where('integration_code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $association) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instanceId = $association->integration_instance_id;
|
||||||
|
$association->delete();
|
||||||
|
$this->deleteIfUnused($instanceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteIfUnused(int $instanceId): void
|
||||||
|
{
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId);
|
||||||
|
|
||||||
|
if ($instance
|
||||||
|
&& ! $instance->clientIntegrations()->exists()
|
||||||
|
&& ! $instance->websiteTypeIntegrations()->exists()) {
|
||||||
|
$instance->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownerName(Client|WebsiteType $owner): string
|
||||||
|
{
|
||||||
|
return $owner instanceof Client ? $owner->name : $owner->nombre;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IntegrationInstanceService
|
||||||
|
{
|
||||||
|
public function create(array $data): IntegrationInstance
|
||||||
|
{
|
||||||
|
return IntegrationInstance::create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(IntegrationInstance $instance, array $data): IntegrationInstance
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($instance, $data): IntegrationInstance {
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||||
|
$cacheKey = $instance->tokenCacheKey();
|
||||||
|
$instance->update($data);
|
||||||
|
if (array_key_exists('integration_data', $data)) {
|
||||||
|
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(IntegrationInstance $instance): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($instance): void {
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||||
|
abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.');
|
||||||
|
$cacheKey = $instance->tokenCacheKey();
|
||||||
|
$instance->delete();
|
||||||
|
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
private ?Mailer $mailer = null;
|
private ?Mailer $mailer = null;
|
||||||
|
|
||||||
private bool $usesClientMailer = false;
|
private bool $usesInstanceMailer = false;
|
||||||
|
|
||||||
public function __construct(?MailFactory $mailFactory = null)
|
public function __construct(?MailFactory $mailFactory = null)
|
||||||
{
|
{
|
||||||
@@ -40,12 +40,12 @@ class MailService extends BaseIntegrationService
|
|||||||
{
|
{
|
||||||
parent::forTenant($tenantCode);
|
parent::forTenant($tenantCode);
|
||||||
|
|
||||||
if ($this->clientIntegration) {
|
if ($this->integrationInstance) {
|
||||||
$this->mailer = $this->resolveMailer();
|
$this->mailer = $this->resolveMailer();
|
||||||
$this->usesClientMailer = true;
|
$this->usesInstanceMailer = true;
|
||||||
} else {
|
} else {
|
||||||
$this->mailer = $this->mailFactory->mailer();
|
$this->mailer = $this->mailFactory->mailer();
|
||||||
$this->usesClientMailer = false;
|
$this->usesInstanceMailer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -56,12 +56,12 @@ class MailService extends BaseIntegrationService
|
|||||||
parent::forClient($client);
|
parent::forClient($client);
|
||||||
$this->tenant = $this->clientContext?->tenants()->first();
|
$this->tenant = $this->clientContext?->tenants()->first();
|
||||||
|
|
||||||
if ($this->clientIntegration) {
|
if ($this->integrationInstance) {
|
||||||
$this->mailer = $this->resolveMailer();
|
$this->mailer = $this->resolveMailer();
|
||||||
$this->usesClientMailer = true;
|
$this->usesInstanceMailer = true;
|
||||||
} else {
|
} else {
|
||||||
$this->mailer = $this->mailFactory->mailer();
|
$this->mailer = $this->mailFactory->mailer();
|
||||||
$this->usesClientMailer = false;
|
$this->usesInstanceMailer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -122,8 +122,8 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
public function mailerName(): string
|
public function mailerName(): string
|
||||||
{
|
{
|
||||||
return $this->usesClientMailer
|
return $this->usesInstanceMailer
|
||||||
? 'client-smtp'
|
? 'integration-smtp'
|
||||||
: (string) config('mail.default');
|
: (string) config('mail.default');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,7 +187,7 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
private function resolveMailer(): Mailer
|
private function resolveMailer(): Mailer
|
||||||
{
|
{
|
||||||
$data = $this->clientIntegration?->integration_data;
|
$data = $this->integrationInstance?->integration_data;
|
||||||
|
|
||||||
if (! is_array($data)) {
|
if (! is_array($data)) {
|
||||||
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
||||||
@@ -205,7 +205,7 @@ class MailService extends BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mailer = $this->mailFactory->build([
|
$mailer = $this->mailFactory->build([
|
||||||
'name' => 'client-smtp-'.$this->clientContext?->id,
|
'name' => 'integration-smtp-'.$this->integrationInstance?->id,
|
||||||
'transport' => 'smtp',
|
'transport' => 'smtp',
|
||||||
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
||||||
'host' => $data['MAIL_HOST'],
|
'host' => $data['MAIL_HOST'],
|
||||||
|
|||||||
@@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
*/
|
*/
|
||||||
public function getToken(): string
|
public function getToken(): string
|
||||||
{
|
{
|
||||||
if (! $this->clientIntegration || ! $this->clientContext) {
|
if (! $this->integrationInstance) {
|
||||||
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
|
|
||||||
$token = Cache::get($cacheKey);
|
$token = Cache::get($cacheKey);
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
// Calculate TTL and subtract a buffer of 60 seconds
|
// Calculate TTL and subtract a buffer of 60 seconds
|
||||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
@@ -220,11 +220,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
*/
|
*/
|
||||||
public function clearToken(): void
|
public function clearToken(): void
|
||||||
{
|
{
|
||||||
if (! $this->clientContext) {
|
if (! $this->integrationInstance) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
Cache::forget($cacheKey);
|
Cache::forget($cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,48 @@
|
|||||||
# Dominio Integration
|
# Dominio Integration
|
||||||
|
|
||||||
## Propósito
|
## Modelo
|
||||||
|
|
||||||
Gestiona integraciones externas disponibles y su configuración por cliente. Un cliente puede agrupar múltiples tenants que comparten las mismas credenciales. Incluye correo y pagos mediante Telepagos.
|
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
|
||||||
|
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
|
||||||
|
- `ClientIntegration` y `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
|
||||||
|
|
||||||
## Modelo y seguridad
|
Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación.
|
||||||
|
|
||||||
- `Integration`: definición global de una integración.
|
## Resolución
|
||||||
- `ClientIntegration`: configuración y credenciales de una integración para un cliente.
|
|
||||||
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
|
|
||||||
- `ClientIntegrationService`: consulta y configura integraciones del cliente.
|
|
||||||
|
|
||||||
## Servicios externos
|
`BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de sitio del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia.
|
||||||
|
|
||||||
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
|
`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
|
||||||
- `MailService`: envío de correo usando la integración configurada.
|
|
||||||
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
|
||||||
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
|
|
||||||
|
|
||||||
## Endpoints
|
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
|
||||||
|
|
||||||
- CRUD global bajo `/integrations`.
|
## Administración
|
||||||
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
|
||||||
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
|
|
||||||
|
|
||||||
## Logging de Telepagos
|
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
|
||||||
|
|
||||||
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
| Método | Ruta | Operación |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
|
||||||
|
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
|
||||||
|
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
|
||||||
|
| GET | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. |
|
||||||
|
| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. |
|
||||||
|
| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
|
||||||
|
|
||||||
## Dependencias y reglas
|
Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
|
||||||
|
|
||||||
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.
|
Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
|
||||||
|
|
||||||
|
## Webhooks y contexto operativo
|
||||||
|
|
||||||
|
`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL.
|
||||||
|
|
||||||
|
`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas.
|
||||||
|
|||||||
@@ -1,22 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||||
use App\Domains\Integration\Controllers\IntegrationController;
|
|
||||||
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||||
|
use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::group(['prefix' => 'integrations'], function () {
|
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
|
||||||
Route::get('/', [IntegrationController::class, 'index']);
|
Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
|
||||||
Route::post('/', [IntegrationController::class, 'store']);
|
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']);
|
||||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||||
|
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||||
|
|||||||
@@ -32,13 +32,14 @@ class NotificationMailService
|
|||||||
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
||||||
$user = User::query()->findOrFail($userId);
|
$user = User::query()->findOrFail($userId);
|
||||||
$brand = $tenant->websiteType ?? $tenant;
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
->send(
|
->send(
|
||||||
$user->email,
|
$user->email,
|
||||||
"Bienvenido a {$brand->nombre}",
|
"Bienvenido a {$brand->nombre}",
|
||||||
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
|
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
|
||||||
$brand,
|
$brand,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,12 +100,25 @@ class NotificationMailService
|
|||||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||||
$brand = $tenant->websiteType ?? $tenant;
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
|
||||||
|
[$subject, $template] = match ($attempt->reason) {
|
||||||
|
ResetPasswordAttempt::REASON_STAFF_CREATED => [
|
||||||
|
'Tu cuenta de escáner está lista', 'scanner-created',
|
||||||
|
],
|
||||||
|
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [
|
||||||
|
'Tu cuenta de administrador está lista', 'administrator-created',
|
||||||
|
],
|
||||||
|
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [
|
||||||
|
'Desbloqueá tu cuenta', 'account-locked',
|
||||||
|
],
|
||||||
|
default => ['Código para recuperar tu contraseña', 'password-reset'],
|
||||||
|
};
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
->send(
|
->send(
|
||||||
$attempt->user->email,
|
$attempt->user->email,
|
||||||
"Código para recuperar tu contraseña - {$brand->nombre}",
|
"{$subject} - {$brand->nombre}",
|
||||||
view('mail.notifications.password-reset', [
|
view("mail.notifications.{$template}", [
|
||||||
'attempt' => $attempt,
|
'attempt' => $attempt,
|
||||||
'recoveryUrl' => $recoveryUrl,
|
'recoveryUrl' => $recoveryUrl,
|
||||||
'brand' => $brand,
|
'brand' => $brand,
|
||||||
@@ -138,8 +152,7 @@ class NotificationMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<int, Ticket> $tickets */
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
$tickets = Ticket::query()
|
$tickets = $purchase->tickets()
|
||||||
->where('source_purchase_id', $purchase->getKey())
|
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
->with(TicketPresentationResolver::RELATIONS)
|
->with(TicketPresentationResolver::RELATIONS)
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@@ -51,6 +52,14 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public const STATUS_SUPERSEDED = 'superseded';
|
public const STATUS_SUPERSEDED = 'superseded';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_INCOMPLETE = 'incomplete';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_AWAITING_PAYMENT = 'awaiting_payment';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_CONFIRMED = 'confirmed';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public static function statuses(): array
|
public static function statuses(): array
|
||||||
{
|
{
|
||||||
@@ -66,6 +75,68 @@ class Purchase extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{name: string, statuses: list<string>}>
|
||||||
|
*/
|
||||||
|
public static function adminStatuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ADMIN_STATUS_INCOMPLETE => [
|
||||||
|
'name' => 'Por completar datos',
|
||||||
|
'statuses' => [self::STATUS_CREATED],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_AWAITING_PAYMENT => [
|
||||||
|
'name' => 'Esperando pago',
|
||||||
|
'statuses' => [self::STATUS_PENDING_PAYMENT, self::STATUS_IN_REVIEW],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_CONFIRMED => [
|
||||||
|
'name' => 'Confirmado',
|
||||||
|
'statuses' => [self::STATUS_PAID],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_CANCELLED => [
|
||||||
|
'name' => 'Anulado',
|
||||||
|
'statuses' => [
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
self::STATUS_REJECTED,
|
||||||
|
self::STATUS_EXPIRED,
|
||||||
|
self::STATUS_SUPERSEDED,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function adminStatusCodes(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::adminStatuses());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function realStatusesForAdminStatus(string $adminStatus): array
|
||||||
|
{
|
||||||
|
return self::adminStatuses()[$adminStatus]['statuses'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adminStatusFor(string $realStatus): ?string
|
||||||
|
{
|
||||||
|
foreach (self::adminStatuses() as $adminStatus => $definition) {
|
||||||
|
if (in_array($realStatus, $definition['statuses'], true)) {
|
||||||
|
return $adminStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adminStatusNameFor(string $realStatus): ?string
|
||||||
|
{
|
||||||
|
$adminStatus = self::adminStatusFor($realStatus);
|
||||||
|
|
||||||
|
return $adminStatus === null
|
||||||
|
? null
|
||||||
|
: self::adminStatuses()[$adminStatus]['name'];
|
||||||
|
}
|
||||||
|
|
||||||
protected $table = 'compras';
|
protected $table = 'compras';
|
||||||
|
|
||||||
/** @var array<int, string> */
|
/** @var array<int, string> */
|
||||||
@@ -115,12 +186,15 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return HasManyThrough<Ticket, PurchaseItem, $this> */
|
||||||
* @return HasMany<Ticket, $this>
|
public function tickets(): HasManyThrough
|
||||||
*/
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
return $this->hasManyThrough(
|
||||||
|
Ticket::class,
|
||||||
|
PurchaseItem::class,
|
||||||
|
'compra_id',
|
||||||
|
'source_purchase_item_id',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<StockReservation, $this> */
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ namespace App\Domains\Purchase\Models;
|
|||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'compra_id',
|
'compra_id',
|
||||||
@@ -56,6 +58,12 @@ class PurchaseItem extends Model
|
|||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Ticket, $this> */
|
||||||
|
public function tickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'source_purchase_item_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Attachment, $this> */
|
/** @return BelongsTo<Attachment, $this> */
|
||||||
public function imageAttachment(): BelongsTo
|
public function imageAttachment(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ class ReleaseCheckoutService
|
|||||||
): Purchase {
|
): Purchase {
|
||||||
$purchase = $this->lockPurchase($purchase);
|
$purchase = $this->lockPurchase($purchase);
|
||||||
|
|
||||||
|
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||||
|
&& $targetStatus === Purchase::STATUS_CANCELLED) {
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
if ($purchase->status === Purchase::STATUS_EXPIRED
|
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||||
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||||
throw new PurchaseExpiredException;
|
throw new PurchaseExpiredException;
|
||||||
@@ -72,6 +77,14 @@ class ReleaseCheckoutService
|
|||||||
|
|
||||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||||
|
|
||||||
|
// Leaving checkout is allowed at the exact instant the reservation
|
||||||
|
// expires. Finish the expiration while holding the purchase lock so
|
||||||
|
// the request is idempotent with the scheduled expiration job.
|
||||||
|
if ($targetStatus === Purchase::STATUS_CANCELLED
|
||||||
|
&& $this->hasOverdueActiveReservation($purchase)) {
|
||||||
|
$targetStatus = Purchase::STATUS_EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
if ($targetStatus === Purchase::STATUS_CANCELLED
|
if ($targetStatus === Purchase::STATUS_CANCELLED
|
||||||
&& $cart?->status === 'active'
|
&& $cart?->status === 'active'
|
||||||
&& in_array($purchase->status, [
|
&& in_array($purchase->status, [
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Sale\Controllers\AdminApp;
|
namespace App\Domains\Sale\Controllers\AdminApp;
|
||||||
|
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
||||||
|
use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest;
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
|
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
|
||||||
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
|
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
|
||||||
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
||||||
@@ -67,11 +68,13 @@ class SaleController extends Controller
|
|||||||
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function modifications(Request $request): AnonymousResourceCollection
|
public function modifications(
|
||||||
{
|
AdminAppSaleModificationIndexRequest $request,
|
||||||
|
): AnonymousResourceCollection {
|
||||||
return SaleModificationResource::collection(
|
return SaleModificationResource::collection(
|
||||||
$this->saleService->modifications(
|
$this->saleService->modifications(
|
||||||
$request->user()->tenant()->firstOrFail()
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,7 +96,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->salePdfService->downloadModifications(
|
return $this->salePdfService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant),
|
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,7 +119,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->saleExcelService->downloadModifications(
|
return $this->saleExcelService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant),
|
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class AdminAppSaleIndexRequest extends FormRequest
|
|||||||
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||||
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
|
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::adminStatusCodes())],
|
||||||
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
|
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
|
||||||
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
|
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
|
||||||
'page' => ['sometimes', 'integer', 'min:1'],
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
|
class AdminAppSaleModificationIndexRequest extends AdminAppSaleIndexRequest
|
||||||
|
{
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$rules = parent::rules();
|
||||||
|
|
||||||
|
unset($rules['sort_by'], $rules['sort_direction']);
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,19 +3,14 @@
|
|||||||
namespace App\Domains\Sale\Requests;
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
use App\Domains\Shared\Rules\ValidTimezone;
|
use App\Domains\Shared\Rules\ValidTimezone;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class AdminAppSaleModificationPdfRequest extends FormRequest
|
class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, list<string>> */
|
/** @return array<string, list<string>> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
...parent::rules(),
|
||||||
'timezone' => ['required', 'string', new ValidTimezone],
|
'timezone' => ['required', 'string', new ValidTimezone],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ class SaleModificationResource extends JsonResource
|
|||||||
'attribute' => $this->attribute,
|
'attribute' => $this->attribute,
|
||||||
'old_value' => $this->old_value,
|
'old_value' => $this->old_value,
|
||||||
'new_value' => $this->new_value,
|
'new_value' => $this->new_value,
|
||||||
|
'admin_status' => is_string($this->new_value)
|
||||||
|
? Purchase::adminStatusFor($this->new_value)
|
||||||
|
: null,
|
||||||
|
'status_label' => is_string($this->new_value)
|
||||||
|
? Purchase::adminStatusNameFor($this->new_value)
|
||||||
|
: null,
|
||||||
'changed_at' => $this->changed_at->utc()->toIso8601String(),
|
'changed_at' => $this->changed_at->utc()->toIso8601String(),
|
||||||
'date' => $this->changed_at->format('Y-m-d'),
|
'date' => $this->changed_at->format('Y-m-d'),
|
||||||
'time' => $this->changed_at->format('H:i:s'),
|
'time' => $this->changed_at->format('H:i:s'),
|
||||||
@@ -31,6 +37,8 @@ class SaleModificationResource extends JsonResource
|
|||||||
'id' => $sale->id,
|
'id' => $sale->id,
|
||||||
'customer_name' => $sale->nombre_apellido,
|
'customer_name' => $sale->nombre_apellido,
|
||||||
'status' => $sale->status,
|
'status' => $sale->status,
|
||||||
|
'admin_status' => Purchase::adminStatusFor($sale->status),
|
||||||
|
'status_label' => Purchase::adminStatusNameFor($sale->status),
|
||||||
] : null,
|
] : null,
|
||||||
'modified_by' => $user ? [
|
'modified_by' => $user ? [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class SaleResource extends JsonResource
|
|||||||
'customer_name' => $this->nombre_apellido,
|
'customer_name' => $this->nombre_apellido,
|
||||||
'quantity' => (int) ($this->quantity ?? 0),
|
'quantity' => (int) ($this->quantity ?? 0),
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
|
'admin_status' => Purchase::adminStatusFor($this->status),
|
||||||
|
'status_label' => Purchase::adminStatusNameFor($this->status),
|
||||||
'total' => number_format((float) $this->total, 2, '.', ''),
|
'total' => number_format((float) $this->total, 2, '.', ''),
|
||||||
'tickets_count' => $ticketsCount,
|
'tickets_count' => $ticketsCount,
|
||||||
'has_generated_tickets' => $ticketsCount > 0,
|
'has_generated_tickets' => $ticketsCount > 0,
|
||||||
|
|||||||
@@ -201,11 +201,6 @@ class AdminAppSaleExcelService
|
|||||||
|
|
||||||
private function saleStatus(string $status): string
|
private function saleStatus(string $status): string
|
||||||
{
|
{
|
||||||
return match ($status) {
|
return Purchase::adminStatusNameFor($status) ?? $status;
|
||||||
Purchase::STATUS_PAID => 'Confirmado',
|
|
||||||
Purchase::STATUS_CREATED => 'Por completar datos',
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW => 'Esperando pago',
|
|
||||||
default => 'Anulado',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,24 @@ class AdminAppSaleService
|
|||||||
return $this->salesQuery($tenant, $filters)->get();
|
return $this->salesQuery($tenant, $filters)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return LengthAwarePaginator<ValueChange> */
|
/**
|
||||||
public function modifications(Tenant $tenant): LengthAwarePaginator
|
* @param array<string, mixed> $filters
|
||||||
|
* @return LengthAwarePaginator<ValueChange>
|
||||||
|
*/
|
||||||
|
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant)
|
return $this->modificationsQuery($tenant, $filters)
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Collection<int, ValueChange> */
|
/**
|
||||||
public function modificationsForExport(Tenant $tenant): Collection
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Collection<int, ValueChange>
|
||||||
|
*/
|
||||||
|
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant)->get();
|
return $this->modificationsQuery($tenant, $filters)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $filters */
|
/** @param array<string, mixed> $filters */
|
||||||
@@ -126,7 +132,6 @@ class AdminAppSaleService
|
|||||||
|
|
||||||
return Purchase::query()
|
return Purchase::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('status', '!=', Purchase::STATUS_SUPERSEDED)
|
|
||||||
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||||
$term = trim($search);
|
$term = trim($search);
|
||||||
|
|
||||||
@@ -144,12 +149,10 @@ class AdminAppSaleService
|
|||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
$filters['status'] ?? null,
|
$filters['status'] ?? null,
|
||||||
fn (Builder $query, string $status): Builder => $status === Purchase::STATUS_PENDING_PAYMENT
|
fn (Builder $query, string $status): Builder => $query->whereIn(
|
||||||
? $query->whereIn('status', [
|
'status',
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::realStatusesForAdminStatus($status),
|
||||||
Purchase::STATUS_IN_REVIEW,
|
)
|
||||||
])
|
|
||||||
: $query->where('status', $status)
|
|
||||||
)
|
)
|
||||||
->select('compras.*')
|
->select('compras.*')
|
||||||
->selectRaw(
|
->selectRaw(
|
||||||
@@ -162,12 +165,50 @@ class AdminAppSaleService
|
|||||||
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Builder<ValueChange> */
|
/**
|
||||||
protected function modificationsQuery(Tenant $tenant): Builder
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Builder<ValueChange>
|
||||||
|
*/
|
||||||
|
protected function modificationsQuery(Tenant $tenant, array $filters): Builder
|
||||||
{
|
{
|
||||||
return ValueChange::query()
|
return ValueChange::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('trackable_type', (new Purchase)->getMorphClass())
|
->where('trackable_type', (new Purchase)->getMorphClass())
|
||||||
|
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||||
|
$term = trim($search);
|
||||||
|
|
||||||
|
$query->where(function (Builder $query) use ($term): void {
|
||||||
|
$query
|
||||||
|
->where('trackable_id', 'like', "%{$term}%")
|
||||||
|
->orWhereHasMorph(
|
||||||
|
'trackable',
|
||||||
|
[Purchase::class],
|
||||||
|
function (Builder $sales) use ($term): void {
|
||||||
|
$sales
|
||||||
|
->where('nombre_apellido', 'like', "%{$term}%")
|
||||||
|
->orWhere('created_at', 'like', "%{$term}%");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when(
|
||||||
|
$filters['id'] ?? null,
|
||||||
|
fn (Builder $query, int $id): Builder => $query->where('trackable_id', $id)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$filters['sale_date'] ?? null,
|
||||||
|
fn (Builder $query, string $date): Builder => $query->whereHasMorph(
|
||||||
|
'trackable',
|
||||||
|
[Purchase::class],
|
||||||
|
fn (Builder $sales): Builder => $sales->whereDate('created_at', $date),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$filters['status'] ?? null,
|
||||||
|
fn (Builder $query, string $status): Builder => $query
|
||||||
|
->where('attribute', 'status')
|
||||||
|
->whereIn('new_value', Purchase::realStatusesForAdminStatus($status))
|
||||||
|
)
|
||||||
->with(['trackable', 'user'])
|
->with(['trackable', 'user'])
|
||||||
->orderByDesc('changed_at')
|
->orderByDesc('changed_at')
|
||||||
->orderByDesc('id');
|
->orderByDesc('id');
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
|||||||
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
||||||
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
||||||
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
|
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
|
||||||
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
|
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación de ventas.
|
||||||
|
- `AdminAppSaleModificationIndexRequest`: valida los filtros compartidos por el historial y sus exportaciones.
|
||||||
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
||||||
- `SaleController`: entrada HTTP del panel.
|
- `SaleController`: entrada HTTP del panel.
|
||||||
|
|
||||||
@@ -27,3 +28,5 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
|||||||
## Consideraciones
|
## Consideraciones
|
||||||
|
|
||||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
||||||
|
|
||||||
|
El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Staff\Requests;
|
namespace App\Domains\Staff\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ class StoreStaffRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -23,7 +31,12 @@ class StoreStaffRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'dni' => ['required', 'string', 'max:50'],
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
'category_ids' => $categoryRules,
|
'category_ids' => $categoryRules,
|
||||||
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Staff\Requests;
|
namespace App\Domains\Staff\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ class UpdateStaffRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -28,7 +36,9 @@ class UpdateStaffRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('users', 'email')->ignore($staffId),
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($staffId),
|
||||||
],
|
],
|
||||||
'category_ids' => $categoryRules,
|
'category_ids' => $categoryRules,
|
||||||
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ class StaffService
|
|||||||
|
|
||||||
public function delete(Tenant $tenant, int $staffId): void
|
public function delete(Tenant $tenant, int $staffId): void
|
||||||
{
|
{
|
||||||
$this->find($tenant, $staffId)->delete();
|
$staff = $this->find($tenant, $staffId);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($staff): void {
|
||||||
|
$staff->tokens()->delete();
|
||||||
|
$staff->delete();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find(Tenant $tenant, int $staffId): User
|
public function find(Tenant $tenant, int $staffId): User
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Tenant\Models;
|
namespace App\Domains\Tenant\Models;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -36,6 +37,12 @@ class WebsiteType extends Model
|
|||||||
|
|
||||||
protected $table = 'website_type';
|
protected $table = 'website_type';
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function integrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class, 'website_type_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return BelongsTo<Attachment, $this>
|
* @return BelongsTo<Attachment, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class TenantResource extends JsonResource
|
|||||||
'site_title' => $this->site_title
|
'site_title' => $this->site_title
|
||||||
?? $this->websiteType?->site_title
|
?? $this->websiteType?->site_title
|
||||||
?? 'ShopitFront',
|
?? 'ShopitFront',
|
||||||
|
'asset_url' => config('filesystems.disks.s3.url'),
|
||||||
'address' => $this->address,
|
'address' => $this->address,
|
||||||
'phone' => $this->phone,
|
'phone' => $this->phone,
|
||||||
'favicon' => ($this->favicon ?? $this->websiteType?->favicon)
|
'favicon' => ($this->favicon ?? $this->websiteType?->favicon)
|
||||||
|
|||||||
53
app/Domains/Ticket/Controllers/AdminApp/TicketController.php
Normal file
53
app/Domains/Ticket/Controllers/AdminApp/TicketController.php
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Requests\AdminAppTicketExportRequest;
|
||||||
|
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
|
||||||
|
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketPdfService;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class TicketController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppTicketService $ticketService,
|
||||||
|
private readonly AdminAppTicketPdfService $ticketPdfService,
|
||||||
|
private readonly AdminAppTicketExcelService $ticketExcelService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return new AdminAppTicketCollection(
|
||||||
|
$this->ticketService->search($tenant, $request->validated())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadPdf(AdminAppTicketExportRequest $request): Response
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->ticketPdfService->download(
|
||||||
|
$tenant,
|
||||||
|
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
||||||
|
$request->validated('timezone'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function downloadExcel(AdminAppTicketExportRequest $request): StreamedResponse
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return $this->ticketExcelService->download(
|
||||||
|
$tenant,
|
||||||
|
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
||||||
|
$request->validated('timezone'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -42,7 +42,7 @@ class GenerateTicketsForPaidPurchase
|
|||||||
$user,
|
$user,
|
||||||
$purchaseItem->cantidad,
|
$purchaseItem->cantidad,
|
||||||
$purchaseItem->source_variant_id,
|
$purchaseItem->source_variant_id,
|
||||||
$purchase->getKey(),
|
$purchaseItem->getKey(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Domains\Ticket\Models;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Services\ResolvedTicketValidity;
|
use App\Domains\Ticket\Services\ResolvedTicketValidity;
|
||||||
use App\Domains\Ticket\Services\ResolvedValidityGroup;
|
use App\Domains\Ticket\Services\ResolvedValidityGroup;
|
||||||
@@ -21,7 +21,7 @@ use Illuminate\Support\Collection;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
'ticket',
|
'ticket',
|
||||||
'source_purchase_id',
|
'source_purchase_item_id',
|
||||||
'source_catalog_item_id',
|
'source_catalog_item_id',
|
||||||
'source_variant_id',
|
'source_variant_id',
|
||||||
'used_at',
|
'used_at',
|
||||||
@@ -56,7 +56,7 @@ class Ticket extends Model
|
|||||||
return [
|
return [
|
||||||
'source_catalog_item_id' => 'integer',
|
'source_catalog_item_id' => 'integer',
|
||||||
'source_variant_id' => 'integer',
|
'source_variant_id' => 'integer',
|
||||||
'source_purchase_id' => 'integer',
|
'source_purchase_item_id' => 'integer',
|
||||||
'used_at' => 'datetime',
|
'used_at' => 'datetime',
|
||||||
'scanner_user_id' => 'integer',
|
'scanner_user_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
@@ -78,13 +78,13 @@ class Ticket extends Model
|
|||||||
/** @return BelongsTo<User, $this> */
|
/** @return BelongsTo<User, $this> */
|
||||||
public function scannerUser(): BelongsTo
|
public function scannerUser(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(User::class, 'scanner_user_id');
|
return $this->belongsTo(User::class, 'scanner_user_id')->withTrashed();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Purchase, $this> */
|
/** @return BelongsTo<PurchaseItem, $this> */
|
||||||
public function sourcePurchase(): BelongsTo
|
public function sourcePurchaseItem(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Purchase::class, 'source_purchase_id');
|
return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<CatalogItem, $this> */
|
/** @return BelongsTo<CatalogItem, $this> */
|
||||||
|
|||||||
17
app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php
Normal file
17
app/Domains/Ticket/Requests/AdminAppTicketExportRequest.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Shared\Rules\ValidTimezone;
|
||||||
|
|
||||||
|
class AdminAppTicketExportRequest extends AdminAppTicketIndexRequest
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...parent::rules(),
|
||||||
|
'timezone' => ['required', 'string', new ValidTimezone],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
47
app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php
Normal file
47
app/Domains/Ticket/Requests/AdminAppTicketIndexRequest.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class AdminAppTicketIndexRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$tenant = $this->user()?->tenant()->first();
|
||||||
|
$sortableKeys = $tenant === null
|
||||||
|
? []
|
||||||
|
: app(AdminAppTicketColumnService::class)->sortableKeys($tenant);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'category' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'product' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'type' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||||
|
'size' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
|
'status' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
Rule::in([
|
||||||
|
Ticket::STATUS_ACTIVE,
|
||||||
|
Ticket::STATUS_USED,
|
||||||
|
Ticket::STATUS_EXPIRED,
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
||||||
|
'sort_by' => ['sometimes', 'nullable', 'string', Rule::in($sortableKeys)],
|
||||||
|
'sort_direction' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketResult;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\ResourceCollection;
|
||||||
|
|
||||||
|
class AdminAppTicketCollection extends ResourceCollection
|
||||||
|
{
|
||||||
|
/** @var class-string<AdminAppTicketResource> */
|
||||||
|
public $collects = AdminAppTicketResource::class;
|
||||||
|
|
||||||
|
private readonly int $scannedTickets;
|
||||||
|
|
||||||
|
private readonly int $totalTickets;
|
||||||
|
|
||||||
|
public function __construct(AdminAppTicketResult $result)
|
||||||
|
{
|
||||||
|
parent::__construct($result->tickets);
|
||||||
|
|
||||||
|
$this->scannedTickets = $result->scannedTickets;
|
||||||
|
$this->totalTickets = $result->totalTickets;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{scanned_tickets: int, total_tickets: int} */
|
||||||
|
public function with(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'scanned_tickets' => $this->scannedTickets,
|
||||||
|
'total_tickets' => $this->totalTickets,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Resources\TicketResource;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketRowService;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
/** @mixin Ticket */
|
||||||
|
class AdminAppTicketResource extends TicketResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$rowService = app(AdminAppTicketRowService::class);
|
||||||
|
$details = $rowService->details($this->resource);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...parent::toArray($request),
|
||||||
|
...$details,
|
||||||
|
'values' => $rowService->values($this->resource, $details),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
103
app/Domains/Ticket/Services/AdminAppTicketColumnService.php
Normal file
103
app/Domains/Ticket/Services/AdminAppTicketColumnService.php
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
|
||||||
|
class AdminAppTicketColumnService
|
||||||
|
{
|
||||||
|
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
|
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
|
||||||
|
public function columns(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL
|
||||||
|
? ['order_number', 'category', 'product', 'type', 'date', 'size', 'amount', 'client', 'id', 'status', 'scanned_by']
|
||||||
|
: ['order_number', 'product', 'amount', 'client', 'id', 'status', 'scanned_by'];
|
||||||
|
|
||||||
|
$columns = array_map(fn (string $key): array => $this->definitions()[$key], $keys);
|
||||||
|
|
||||||
|
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
|
||||||
|
$columns = array_map(function (array $column): array {
|
||||||
|
if (in_array($column['key'], ['product', 'type', 'date', 'size'], true)) {
|
||||||
|
$column['sortable'] = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $column;
|
||||||
|
}, $columns);
|
||||||
|
} else {
|
||||||
|
$widths = [
|
||||||
|
'order_number' => '11%',
|
||||||
|
'product' => '15%',
|
||||||
|
'amount' => '10%',
|
||||||
|
'client' => '15%',
|
||||||
|
'id' => '8%',
|
||||||
|
'status' => '8%',
|
||||||
|
'scanned_by' => '11%',
|
||||||
|
];
|
||||||
|
$columns = array_map(function (array $column) use ($widths): array {
|
||||||
|
$column['width'] = $widths[$column['key']];
|
||||||
|
|
||||||
|
return $column;
|
||||||
|
}, $columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $columns;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string}> */
|
||||||
|
public function publicColumns(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return array_map(function (array $column): array {
|
||||||
|
unset($column['excel_width']);
|
||||||
|
|
||||||
|
return $column;
|
||||||
|
}, $this->columns($tenant));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function sortableKeys(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (array $column): string => $column['sort_param'],
|
||||||
|
array_filter($this->columns($tenant), fn (array $column): bool => $column['sortable']),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
|
||||||
|
private function definitions(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'order_number' => $this->column('order_number', 'N° de orden', 'order_number', '10.5%', 14),
|
||||||
|
'category' => $this->column('category', 'Categoría', 'text', '11%', 18),
|
||||||
|
'product' => $this->column('product', 'Producto', 'text', '11%', 22),
|
||||||
|
'type' => $this->column('type', 'Tipo', 'text', '8%', 18),
|
||||||
|
'date' => $this->column('date', 'Fecha', 'text', '7%', 14),
|
||||||
|
'size' => $this->column('size', 'Talle', 'text', '6%', 12),
|
||||||
|
'amount' => $this->column('amount', 'Importe', 'currency', '8%', 15),
|
||||||
|
'client' => $this->column('client', 'Cliente', 'text', '11%', 30),
|
||||||
|
'id' => $this->column('id', 'ID', 'text', '6%', 12),
|
||||||
|
'status' => $this->column('status', 'Estado', 'status', '7%', 13),
|
||||||
|
'scanned_by' => $this->column('scanned_by', 'Escaneado por', 'text', '8%', 28),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int} */
|
||||||
|
private function column(
|
||||||
|
string $key,
|
||||||
|
string $label,
|
||||||
|
string $type,
|
||||||
|
string $width,
|
||||||
|
int $excelWidth,
|
||||||
|
): array {
|
||||||
|
return [
|
||||||
|
'key' => $key,
|
||||||
|
'label' => $label,
|
||||||
|
'type' => $type,
|
||||||
|
'sortable' => true,
|
||||||
|
'sort_param' => $key,
|
||||||
|
'width' => $width,
|
||||||
|
'excel_width' => $excelWidth,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
105
app/Domains/Ticket/Services/AdminAppTicketExcelService.php
Normal file
105
app/Domains/Ticket/Services/AdminAppTicketExcelService.php
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||||
|
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
|
class AdminAppTicketExcelService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppTicketReportService $reportService,
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param Collection<int, Ticket> $tickets */
|
||||||
|
public function download(Tenant $tenant, Collection $tickets, string $timeZone): StreamedResponse
|
||||||
|
{
|
||||||
|
$generatedAt = now();
|
||||||
|
$rows = $this->reportService->rows($tickets);
|
||||||
|
$columns = $this->columnService->columns($tenant);
|
||||||
|
$spreadsheet = new Spreadsheet;
|
||||||
|
$spreadsheet->getProperties()
|
||||||
|
->setCreator('Shopit')
|
||||||
|
->setTitle('Listado de tickets')
|
||||||
|
->setSubject($tenant->nombre);
|
||||||
|
$sheet = $spreadsheet->getActiveSheet();
|
||||||
|
$sheet->setTitle('Tickets');
|
||||||
|
$sheet->fromArray([array_column($columns, 'label')], null, 'A1');
|
||||||
|
|
||||||
|
foreach ($rows as $index => $ticket) {
|
||||||
|
$row = $index + 2;
|
||||||
|
foreach ($columns as $columnIndex => $column) {
|
||||||
|
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row;
|
||||||
|
$value = $ticket[$column['key']] ?? null;
|
||||||
|
|
||||||
|
if ($column['type'] === 'currency' && $value !== null) {
|
||||||
|
$sheet->setCellValue($coordinate, (float) $value);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($column['type'] === 'date' && $value instanceof CarbonInterface) {
|
||||||
|
$sheet->setCellValue(
|
||||||
|
$coordinate,
|
||||||
|
Date::dateTimeToExcel($value->copy()->timezone($timeZone)),
|
||||||
|
);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sheet->setCellValueExplicit(
|
||||||
|
$coordinate,
|
||||||
|
$this->reportService->displayValue($value, $column['type'], $timeZone),
|
||||||
|
DataType::TYPE_STRING,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastRow = max(2, $rows->count() + 1);
|
||||||
|
$lastColumn = Coordinate::stringFromColumnIndex(count($columns));
|
||||||
|
foreach ($columns as $columnIndex => $column) {
|
||||||
|
$letter = Coordinate::stringFromColumnIndex($columnIndex + 1);
|
||||||
|
if ($column['type'] === 'currency') {
|
||||||
|
$sheet->getStyle("{$letter}2:{$letter}{$lastRow}")
|
||||||
|
->getNumberFormat()->setFormatCode('$ #,##0.00');
|
||||||
|
}
|
||||||
|
if ($column['type'] === 'date') {
|
||||||
|
$sheet->getStyle("{$letter}2:{$letter}{$lastRow}")
|
||||||
|
->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
|
||||||
|
}
|
||||||
|
$sheet->getColumnDimension($letter)->setWidth($column['excel_width']);
|
||||||
|
}
|
||||||
|
$sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([
|
||||||
|
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||||
|
'fill' => [
|
||||||
|
'fillType' => Fill::FILL_SOLID,
|
||||||
|
'startColor' => ['rgb' => '26382E'],
|
||||||
|
],
|
||||||
|
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||||
|
]);
|
||||||
|
$sheet->getRowDimension(1)->setRowHeight(24);
|
||||||
|
$sheet->freezePane('A2');
|
||||||
|
$sheet->setAutoFilter("A1:{$lastColumn}{$lastRow}");
|
||||||
|
|
||||||
|
$filename = 'tickets_'.$tenant->codigo.'_'
|
||||||
|
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx';
|
||||||
|
|
||||||
|
return response()->streamDownload(function () use ($spreadsheet): void {
|
||||||
|
(new Xlsx($spreadsheet))->save('php://output');
|
||||||
|
$spreadsheet->disconnectWorksheets();
|
||||||
|
}, $filename, [
|
||||||
|
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Domains/Ticket/Services/AdminAppTicketPdfService.php
Normal file
56
app/Domains/Ticket/Services/AdminAppTicketPdfService.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Barryvdh\DomPDF\PDF as DomPdf;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppTicketPdfService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppTicketReportService $reportService,
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param Collection<int, Ticket> $tickets */
|
||||||
|
public function download(Tenant $tenant, Collection $tickets, string $timeZone): Response
|
||||||
|
{
|
||||||
|
$generatedAt = now();
|
||||||
|
$columns = $this->columnService->columns($tenant);
|
||||||
|
$rows = $this->reportService->rows($tickets);
|
||||||
|
$pdf = Pdf::loadView('pdf.adminapp.tickets', [
|
||||||
|
'tenant' => $tenant,
|
||||||
|
'columns' => $columns,
|
||||||
|
'tickets' => $this->reportService->displayRows($rows, $columns, $timeZone),
|
||||||
|
'generatedAt' => $generatedAt,
|
||||||
|
'timeZone' => $timeZone,
|
||||||
|
])->setPaper('a3', 'landscape');
|
||||||
|
|
||||||
|
$this->addPageNumbers($pdf);
|
||||||
|
|
||||||
|
return $pdf->download(
|
||||||
|
'tickets_'.$tenant->codigo.'_'
|
||||||
|
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function addPageNumbers(DomPdf $pdf): void
|
||||||
|
{
|
||||||
|
$pdf->render();
|
||||||
|
$domPdf = $pdf->getDomPDF();
|
||||||
|
$font = $domPdf->getFontMetrics()->getFont('DejaVu Sans');
|
||||||
|
|
||||||
|
$domPdf->getCanvas()->page_text(
|
||||||
|
565,
|
||||||
|
805,
|
||||||
|
'Página {PAGE_NUM} de {PAGE_COUNT}',
|
||||||
|
$font,
|
||||||
|
7,
|
||||||
|
[0.48, 0.52, 0.49],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Domains/Ticket/Services/AdminAppTicketReportService.php
Normal file
35
app/Domains/Ticket/Services/AdminAppTicketReportService.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppTicketReportService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AdminAppTicketRowService $rowService) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function rows(Collection $tickets): Collection
|
||||||
|
{
|
||||||
|
return $this->rowService->rows($tickets);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
* @param list<array<string, mixed>> $columns
|
||||||
|
* @return Collection<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
public function displayRows(Collection $rows, array $columns, string $timeZone): Collection
|
||||||
|
{
|
||||||
|
return $this->rowService->displayRows($rows, $columns, $timeZone);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function displayValue(mixed $value, string $type, string $timeZone): string
|
||||||
|
{
|
||||||
|
return $this->rowService->displayValue($value, $type, $timeZone);
|
||||||
|
}
|
||||||
|
}
|
||||||
16
app/Domains/Ticket/Services/AdminAppTicketResult.php
Normal file
16
app/Domains/Ticket/Services/AdminAppTicketResult.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
final readonly class AdminAppTicketResult
|
||||||
|
{
|
||||||
|
/** @param LengthAwarePaginator<Ticket> $tickets */
|
||||||
|
public function __construct(
|
||||||
|
public LengthAwarePaginator $tickets,
|
||||||
|
public int $scannedTickets,
|
||||||
|
public int $totalTickets,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
210
app/Domains/Ticket/Services/AdminAppTicketRowService.php
Normal file
210
app/Domains/Ticket/Services/AdminAppTicketRowService.php
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppTicketRowService
|
||||||
|
{
|
||||||
|
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
|
private const CATEGORY_PRESENTATIONS = [
|
||||||
|
'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null],
|
||||||
|
'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null],
|
||||||
|
'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'date' => null, 'size' => null],
|
||||||
|
'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null],
|
||||||
|
'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null],
|
||||||
|
'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'date' => null, 'size' => 'talle'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function details(Ticket $ticket): array
|
||||||
|
{
|
||||||
|
$purchaseItem = $ticket->sourcePurchaseItem;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'source_purchase_item_id' => $ticket->source_purchase_item_id,
|
||||||
|
'order_number' => $purchaseItem?->compra_id,
|
||||||
|
'product' => $purchaseItem?->item_nombre
|
||||||
|
?? $ticket->sourceCatalogItem?->nombre
|
||||||
|
?? $ticket->name,
|
||||||
|
'amount' => $purchaseItem?->precio_unitario,
|
||||||
|
'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido,
|
||||||
|
'status' => $ticket->status,
|
||||||
|
'scanned_by' => $ticket->scannerUser?->nombre_apellido,
|
||||||
|
'variant_properties' => $this->variantProperties($ticket),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed>|null $details */
|
||||||
|
public function values(Ticket $ticket, ?array $details = null): array
|
||||||
|
{
|
||||||
|
$details ??= $this->details($ticket);
|
||||||
|
$presentation = $this->presentation($ticket, $details);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'order_number' => $details['order_number'],
|
||||||
|
'category' => $presentation['category'],
|
||||||
|
'product' => $presentation['product'],
|
||||||
|
'type' => $presentation['type'],
|
||||||
|
'date' => $presentation['date'],
|
||||||
|
'size' => $presentation['size'],
|
||||||
|
'amount' => $details['amount'] === null ? null : (float) $details['amount'],
|
||||||
|
'client' => $details['client'] ?? 'Sin nombre',
|
||||||
|
'id' => $ticket->id,
|
||||||
|
'status' => $details['status'],
|
||||||
|
'scanned_by' => $details['scanned_by'] ?? '-',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
* @return Collection<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public function rows(Collection $tickets): Collection
|
||||||
|
{
|
||||||
|
return $tickets->values()->map(fn (Ticket $ticket): array => $this->values($ticket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, array<string, mixed>> $rows
|
||||||
|
* @param list<array<string, mixed>> $columns
|
||||||
|
* @return Collection<int, array<string, string>>
|
||||||
|
*/
|
||||||
|
public function displayRows(Collection $rows, array $columns, string $timeZone): Collection
|
||||||
|
{
|
||||||
|
return $rows->map(fn (array $row): array => collect($columns)
|
||||||
|
->mapWithKeys(fn (array $column): array => [
|
||||||
|
$column['key'] => $this->displayValue(
|
||||||
|
$row[$column['key']] ?? null,
|
||||||
|
$column['type'],
|
||||||
|
$timeZone,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function displayValue(mixed $value, string $type, string $timeZone): string
|
||||||
|
{
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return '-';
|
||||||
|
}
|
||||||
|
|
||||||
|
return match ($type) {
|
||||||
|
'order_number' => '#'.$value,
|
||||||
|
'currency' => '$'.number_format((float) $value, 2, ',', '.'),
|
||||||
|
'status' => match ((string) $value) {
|
||||||
|
Ticket::STATUS_USED => 'Usado',
|
||||||
|
Ticket::STATUS_EXPIRED => 'Vencido',
|
||||||
|
default => 'Activo',
|
||||||
|
},
|
||||||
|
default => (string) $value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $details */
|
||||||
|
private function presentation(Ticket $ticket, array $details): array
|
||||||
|
{
|
||||||
|
$sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-';
|
||||||
|
|
||||||
|
if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) {
|
||||||
|
return [
|
||||||
|
'category' => $sourceCategory,
|
||||||
|
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
|
||||||
|
'type' => $this->allPropertyLabels($details) ?: '-',
|
||||||
|
'date' => '-',
|
||||||
|
'size' => '-',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$configuration = self::CATEGORY_PRESENTATIONS[mb_strtolower($sourceCategory)] ?? null;
|
||||||
|
if ($configuration === null) {
|
||||||
|
return [
|
||||||
|
'category' => $sourceCategory,
|
||||||
|
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
|
||||||
|
'type' => $this->allPropertyLabels($details) ?: '-',
|
||||||
|
'date' => '-',
|
||||||
|
'size' => '-',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'category' => $configuration['category'] ?? $sourceCategory,
|
||||||
|
'product' => $configuration['product'] === 'product'
|
||||||
|
? (string) ($details['product'] ?: $ticket->name ?: '-')
|
||||||
|
: ($this->propertyLabels($details, $configuration['product']) ?: '-'),
|
||||||
|
'type' => $configuration['type'] === null
|
||||||
|
? '-'
|
||||||
|
: ($this->propertyLabels($details, $configuration['type']) ?: '-'),
|
||||||
|
'date' => $configuration['date'] === null
|
||||||
|
? '-'
|
||||||
|
: ($this->propertyLabels($details, $configuration['date']) ?: '-'),
|
||||||
|
'size' => $configuration['size'] === null
|
||||||
|
? '-'
|
||||||
|
: ($this->propertyLabels($details, $configuration['size']) ?: '-'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $details */
|
||||||
|
private function propertyLabels(array $details, string $code): string
|
||||||
|
{
|
||||||
|
$property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code);
|
||||||
|
$labels = collect($property['values'] ?? [])->pluck('label')->filter();
|
||||||
|
|
||||||
|
if ($code === 'event_date') {
|
||||||
|
$labels = $labels->map(function (string $label): string {
|
||||||
|
[$day, $month] = array_pad(explode('/', $label), 2, null);
|
||||||
|
|
||||||
|
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return $labels->implode(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $details */
|
||||||
|
private function allPropertyLabels(array $details): string
|
||||||
|
{
|
||||||
|
return collect($details['variant_properties'] ?? [])
|
||||||
|
->flatMap(fn (array $property): array => $property['values'] ?? [])
|
||||||
|
->pluck('label')
|
||||||
|
->filter()
|
||||||
|
->implode(', ');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{code: string, label: string, values: list<array{value: string, label: string}>}> */
|
||||||
|
private function variantProperties(Ticket $ticket): array
|
||||||
|
{
|
||||||
|
$variant = $ticket->sourceVariant;
|
||||||
|
if ($variant === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$itemAttributes = $variant->definitions
|
||||||
|
->map(fn ($definition) => $definition->itemAttribute)
|
||||||
|
->filter()
|
||||||
|
->merge($variant->catalogItem?->itemAttributes ?? collect())
|
||||||
|
->unique('id')
|
||||||
|
->values();
|
||||||
|
|
||||||
|
return $variant->selectionOptions($itemAttributes)
|
||||||
|
->map(function (array $selection, string $attributeCode) use ($itemAttributes): array {
|
||||||
|
$itemAttribute = $itemAttributes->first(
|
||||||
|
fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo
|
||||||
|
=== $attributeCode,
|
||||||
|
);
|
||||||
|
$values = array_is_list($selection) ? $selection : [$selection];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => $attributeCode,
|
||||||
|
'label' => $itemAttribute?->attribute?->nombre
|
||||||
|
?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode),
|
||||||
|
'values' => array_values($values),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
404
app/Domains/Ticket/Services/AdminAppTicketService.php
Normal file
404
app/Domains/Ticket/Services/AdminAppTicketService.php
Normal file
@@ -0,0 +1,404 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Ticket\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class AdminAppTicketService
|
||||||
|
{
|
||||||
|
private const RELATIONS = [
|
||||||
|
...TicketValidityResolver::RELATIONS,
|
||||||
|
...TicketPresentationResolver::RELATIONS,
|
||||||
|
'user',
|
||||||
|
'scannerUser',
|
||||||
|
'sourceCatalogItem.category',
|
||||||
|
'sourcePurchaseItem.purchase',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
private readonly AdminAppTicketRowService $rowService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
|
*/
|
||||||
|
public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult
|
||||||
|
{
|
||||||
|
$query = $this->baseQuery($tenant, $filters);
|
||||||
|
$countQuery = clone $query;
|
||||||
|
|
||||||
|
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
||||||
|
|
||||||
|
if (($filters['sort_by'] ?? null) && ! $databaseSorted) {
|
||||||
|
$matchingTickets = (clone $query)
|
||||||
|
->with(self::RELATIONS)
|
||||||
|
->get();
|
||||||
|
$matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters);
|
||||||
|
$tickets = $this->paginate($matchingTickets, $filters);
|
||||||
|
$scannedTickets = $matchingTickets->whereNotNull('used_at')->count();
|
||||||
|
} else {
|
||||||
|
$tickets = (clone $query)
|
||||||
|
->with(self::RELATIONS)
|
||||||
|
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
|
||||||
|
->paginateFromRequest()
|
||||||
|
->withQueryString();
|
||||||
|
$scannedTickets = $countQuery->whereNotNull('used_at')->count();
|
||||||
|
}
|
||||||
|
|
||||||
|
return new AdminAppTicketResult(
|
||||||
|
tickets: $tickets,
|
||||||
|
scannedTickets: $scannedTickets,
|
||||||
|
totalTickets: $tickets->total(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
|
* @return Collection<int, Ticket>
|
||||||
|
*/
|
||||||
|
public function ticketsForExport(Tenant $tenant, array $filters = []): Collection
|
||||||
|
{
|
||||||
|
$query = $this->baseQuery($tenant, $filters);
|
||||||
|
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
||||||
|
$tickets = $query
|
||||||
|
->with(self::RELATIONS)
|
||||||
|
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
||||||
|
* @return Builder<Ticket>
|
||||||
|
*/
|
||||||
|
private function baseQuery(Tenant $tenant, array $filters): Builder
|
||||||
|
{
|
||||||
|
$search = trim((string) ($filters['q'] ?? ''));
|
||||||
|
|
||||||
|
$query = Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->when($search !== '', function (Builder $query) use ($search): void {
|
||||||
|
$this->applySearchFilter($query, $search);
|
||||||
|
})
|
||||||
|
->when($filters['category'] ?? null, function (Builder $query, string $category): void {
|
||||||
|
$query->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery
|
||||||
|
->whereRaw('LOWER(nombre) = ?', [mb_strtolower(trim($category))]));
|
||||||
|
})
|
||||||
|
->when($filters['product'] ?? null, function (Builder $query, string $product) use ($filters): void {
|
||||||
|
$this->applyProductFilter($query, (string) ($filters['category'] ?? ''), $product);
|
||||||
|
})
|
||||||
|
->when($filters['type'] ?? null, function (Builder $query, string $type) use ($filters): void {
|
||||||
|
$this->applyTypeFilter($query, (string) ($filters['category'] ?? ''), $type);
|
||||||
|
})
|
||||||
|
->when($filters['date'] ?? null, function (Builder $query, string $date) use ($tenant): void {
|
||||||
|
if ($tenant->codigo === 'fiesta_futbol_infantil') {
|
||||||
|
$this->applyEventDateFilter($query, $date);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
|
||||||
|
->whereDate('created_at', $date));
|
||||||
|
})
|
||||||
|
->when($filters['size'] ?? null, function (Builder $query, string $size) use ($filters): void {
|
||||||
|
$this->applySizeFilter($query, (string) ($filters['category'] ?? ''), $size);
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->applyStatusFilter($query, $filters['status'] ?? null);
|
||||||
|
|
||||||
|
return $query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Builder<Ticket> $query */
|
||||||
|
private function applySearchFilter(Builder $query, string $search): void
|
||||||
|
{
|
||||||
|
$containsPattern = '%'.mb_strtolower($search).'%';
|
||||||
|
$amount = $this->searchAmount($search);
|
||||||
|
|
||||||
|
$query->where(function (Builder $searchQuery) use ($search, $containsPattern, $amount): void {
|
||||||
|
$searchQuery
|
||||||
|
->where(function (Builder $clientQuery) use ($containsPattern): void {
|
||||||
|
$clientQuery
|
||||||
|
->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
|
||||||
|
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]))
|
||||||
|
->orWhere(function (Builder $fallbackClientQuery) use ($containsPattern): void {
|
||||||
|
$fallbackClientQuery
|
||||||
|
->where(function (Builder $missingPurchaseClientQuery): void {
|
||||||
|
$missingPurchaseClientQuery
|
||||||
|
->whereDoesntHave('sourcePurchaseItem.purchase')
|
||||||
|
->orWhereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
|
||||||
|
->whereNull('nombre_apellido'));
|
||||||
|
})
|
||||||
|
->whereHas('user', fn (Builder $userQuery): Builder => $userQuery
|
||||||
|
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]));
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->orWhereHas('scannerUser', fn (Builder $scannerQuery): Builder => $scannerQuery
|
||||||
|
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]));
|
||||||
|
|
||||||
|
if (ctype_digit($search)) {
|
||||||
|
$searchQuery
|
||||||
|
->orWhere('tickets.id', (int) $search)
|
||||||
|
->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery
|
||||||
|
->where('compra_id', (int) $search));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($amount !== null) {
|
||||||
|
$searchQuery->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery
|
||||||
|
->where('precio_unitario', $amount));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function searchAmount(string $search): ?string
|
||||||
|
{
|
||||||
|
$value = preg_replace('/[\s$]/u', '', trim($search));
|
||||||
|
if ($value === null || $value === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\d{1,3}(?:\.\d{3})+(?:,\d{1,2})?$/', $value) === 1) {
|
||||||
|
$value = str_replace(['.', ','], ['', '.'], $value);
|
||||||
|
} elseif (preg_match('/^\d{1,3}(?:,\d{3})+(?:\.\d{1,2})?$/', $value) === 1) {
|
||||||
|
$value = str_replace(',', '', $value);
|
||||||
|
} elseif (preg_match('/^\d+(?:[.,]\d{1,2})?$/', $value) === 1) {
|
||||||
|
$value = str_replace(',', '.', $value);
|
||||||
|
} else {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return number_format((float) $value, 2, '.', '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Builder<Ticket> $query */
|
||||||
|
private function applyProductFilter(Builder $query, string $category, string $product): void
|
||||||
|
{
|
||||||
|
$category = $this->normalizedCategory($category);
|
||||||
|
|
||||||
|
if (in_array($category, ['alojamientos', 'camping'], true)) {
|
||||||
|
$this->whereVariantDefinition($query, 'tipo_alojamiento', $product);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (in_array($category, ['comidas', 'comida'], true)) {
|
||||||
|
$this->whereVariantDefinition($query, 'horario', $product);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->whereHas('sourceCatalogItem', fn (Builder $itemQuery): Builder => $itemQuery
|
||||||
|
->where('slug', $product));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Builder<Ticket> $query */
|
||||||
|
private function applyTypeFilter(Builder $query, string $category, string $type): void
|
||||||
|
{
|
||||||
|
$attribute = match ($this->normalizedCategory($category)) {
|
||||||
|
'comidas', 'comida' => 'servicio',
|
||||||
|
'merchandising' => 'color',
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($attribute !== null) {
|
||||||
|
$this->whereVariantDefinition($query, $attribute, $type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Builder<Ticket> $query */
|
||||||
|
private function applyEventDateFilter(Builder $query, string $date): void
|
||||||
|
{
|
||||||
|
$query
|
||||||
|
->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery
|
||||||
|
->whereRaw('LOWER(nombre) IN (?, ?)', ['comidas', 'comida']))
|
||||||
|
->whereHas('sourceVariant', function (Builder $variantQuery) use ($date): void {
|
||||||
|
$variantQuery->where(function (Builder $dateQuery) use ($date): void {
|
||||||
|
$dateQuery
|
||||||
|
->whereHas('eventDate', fn (Builder $eventDateQuery): Builder => $eventDateQuery
|
||||||
|
->whereDate('date', $date))
|
||||||
|
->orWhereHas('eventDates', fn (Builder $eventDateQuery): Builder => $eventDateQuery
|
||||||
|
->whereDate('date', $date));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Builder<Ticket> $query */
|
||||||
|
private function applySizeFilter(Builder $query, string $category, string $size): void
|
||||||
|
{
|
||||||
|
if ($this->normalizedCategory($category) === 'merchandising') {
|
||||||
|
$this->whereVariantDefinition($query, 'talle', $size);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Builder<Ticket> $query */
|
||||||
|
private function whereVariantDefinition(Builder $query, string $attribute, string $value): void
|
||||||
|
{
|
||||||
|
$query->whereHas('sourceVariant.definitions', fn (Builder $definitionQuery): Builder => $definitionQuery
|
||||||
|
->where('value', $value)
|
||||||
|
->whereHas('itemAttribute.attribute', fn (Builder $attributeQuery): Builder => $attributeQuery
|
||||||
|
->where('codigo', $attribute)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Builder<Ticket> $query */
|
||||||
|
private function applyStatusFilter(Builder $query, ?string $status): void
|
||||||
|
{
|
||||||
|
if ($status === null || $status === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($status === Ticket::STATUS_USED) {
|
||||||
|
$query->whereNotNull('used_at');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matchingIds = (clone $query)
|
||||||
|
->whereNull('used_at')
|
||||||
|
->with(TicketValidityResolver::RELATIONS)
|
||||||
|
->get()
|
||||||
|
->filter(fn (Ticket $ticket): bool => $ticket->status === $status)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
$query->whereIn('tickets.id', $matchingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizedCategory(string $category): string
|
||||||
|
{
|
||||||
|
return mb_strtolower(trim($category));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<Ticket> $query
|
||||||
|
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
|
*/
|
||||||
|
private function applyDatabaseSort(Builder $query, Tenant $tenant, array $filters): bool
|
||||||
|
{
|
||||||
|
$sortBy = (string) ($filters['sort_by'] ?? '');
|
||||||
|
if ($sortBy === '') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
$sortExpression = match ($sortBy) {
|
||||||
|
'order_number' => $this->purchaseItemColumnQuery('compra_id'),
|
||||||
|
'id' => 'tickets.id',
|
||||||
|
'amount' => $this->purchaseItemColumnQuery('precio_unitario'),
|
||||||
|
'scanned_by' => User::query()
|
||||||
|
->withTrashed()
|
||||||
|
->select('nombre_apellido')
|
||||||
|
->whereColumn('users.id', 'tickets.scanner_user_id'),
|
||||||
|
'product' => $tenant->codigo === 'fiesta_futbol_infantil'
|
||||||
|
? null
|
||||||
|
: $this->purchaseItemColumnQuery('item_nombre'),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if ($sortExpression === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$query->orderBy($sortExpression, $direction)->orderByDesc('tickets.id');
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<PurchaseItem> */
|
||||||
|
private function purchaseItemColumnQuery(string $column): Builder
|
||||||
|
{
|
||||||
|
return PurchaseItem::query()
|
||||||
|
->select($column)
|
||||||
|
->whereColumn('compra_items.id', 'tickets.source_purchase_item_id')
|
||||||
|
->limit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
|
* @return Collection<int, Ticket>
|
||||||
|
*/
|
||||||
|
private function sortTickets(Collection $tickets, Tenant $tenant, array $filters): Collection
|
||||||
|
{
|
||||||
|
$sortBy = (string) ($filters['sort_by'] ?? '');
|
||||||
|
if ($sortBy === '') {
|
||||||
|
return $tickets;
|
||||||
|
}
|
||||||
|
|
||||||
|
$column = collect($this->columnService->columns($tenant))
|
||||||
|
->firstWhere('sort_param', $sortBy);
|
||||||
|
if ($column === null) {
|
||||||
|
return $tickets;
|
||||||
|
}
|
||||||
|
|
||||||
|
$direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? -1 : 1;
|
||||||
|
$values = $tickets->mapWithKeys(fn (Ticket $ticket): array => [
|
||||||
|
$ticket->getKey() => $this->rowService->values($ticket)[$column['key']] ?? null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $tickets->sort(function (Ticket $left, Ticket $right) use ($column, $direction, $values): int {
|
||||||
|
$leftValue = $values->get($left->getKey());
|
||||||
|
$rightValue = $values->get($right->getKey());
|
||||||
|
|
||||||
|
if ($leftValue === null || $leftValue === '') {
|
||||||
|
return $rightValue === null || $rightValue === '' ? $right->id <=> $left->id : 1;
|
||||||
|
}
|
||||||
|
if ($rightValue === null || $rightValue === '') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$comparison = $this->compareValues($leftValue, $rightValue, $column['type']);
|
||||||
|
|
||||||
|
return $comparison === 0
|
||||||
|
? $right->id <=> $left->id
|
||||||
|
: $comparison * $direction;
|
||||||
|
})->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function compareValues(mixed $left, mixed $right, string $type): int
|
||||||
|
{
|
||||||
|
if (in_array($type, ['currency', 'order_number'], true)) {
|
||||||
|
return (float) $left <=> (float) $right;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'date') {
|
||||||
|
$leftTimestamp = $left instanceof \DateTimeInterface ? $left->getTimestamp() : strtotime((string) $left);
|
||||||
|
$rightTimestamp = $right instanceof \DateTimeInterface ? $right->getTimestamp() : strtotime((string) $right);
|
||||||
|
|
||||||
|
return $leftTimestamp <=> $rightTimestamp;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($type === 'status') {
|
||||||
|
$left = $this->rowService->displayValue($left, $type, 'UTC');
|
||||||
|
$right = $this->rowService->displayValue($right, $type, 'UTC');
|
||||||
|
}
|
||||||
|
|
||||||
|
return strnatcasecmp((string) $left, (string) $right);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, Ticket> $tickets
|
||||||
|
* @param array{page?: int, per_page?: int} $filters
|
||||||
|
* @return LengthAwarePaginator<Ticket>
|
||||||
|
*/
|
||||||
|
private function paginate(Collection $tickets, array $filters): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$page = (int) ($filters['page'] ?? 1);
|
||||||
|
$perPage = (int) ($filters['per_page'] ?? 15);
|
||||||
|
|
||||||
|
return (new LengthAwarePaginator(
|
||||||
|
$tickets->forPage($page, $perPage)->values(),
|
||||||
|
$tickets->count(),
|
||||||
|
$perPage,
|
||||||
|
$page,
|
||||||
|
['path' => request()->url()],
|
||||||
|
))->withQueryString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@ class ScannerTicketService
|
|||||||
->where('tenant_code', $scanner->tenant_codigo)
|
->where('tenant_code', $scanner->tenant_codigo)
|
||||||
->where('ticket', $ticketUuid);
|
->where('ticket', $ticketUuid);
|
||||||
|
|
||||||
if ($scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) {
|
if ($this->requiresCategoryValidation($scanner)) {
|
||||||
$categoryIds = $this->scannerCategoryIds($scanner);
|
$categoryIds = $this->scannerCategoryIds($scanner);
|
||||||
|
|
||||||
$query->where(function (Builder $query) use ($scanner, $categoryIds): void {
|
$query->where(function (Builder $query) use ($scanner, $categoryIds): void {
|
||||||
@@ -158,7 +158,7 @@ class ScannerTicketService
|
|||||||
|
|
||||||
private function scannerCanScan(User $scanner, Ticket $ticket): bool
|
private function scannerCanScan(User $scanner, Ticket $ticket): bool
|
||||||
{
|
{
|
||||||
if (! $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation()) {
|
if (! $this->requiresCategoryValidation($scanner)) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,4 +169,9 @@ class ScannerTicketService
|
|||||||
->where('categorias.id', $categoryId)
|
->where('categorias.id', $categoryId)
|
||||||
->exists();
|
->exists();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function requiresCategoryValidation(User $scanner): bool
|
||||||
|
{
|
||||||
|
return $scanner->tenant()->firstOrFail()->requiresScannerCategoryValidation();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,13 +23,13 @@ class TicketGeneratorService
|
|||||||
User $user,
|
User $user,
|
||||||
int $quantity = 1,
|
int $quantity = 1,
|
||||||
?int $sourceVariantId = null,
|
?int $sourceVariantId = null,
|
||||||
?int $sourcePurchaseId = null,
|
?int $sourcePurchaseItemId = null,
|
||||||
): Collection {
|
): Collection {
|
||||||
if ($quantity < 1) {
|
if ($quantity < 1) {
|
||||||
throw TicketGenerationException::invalidQuantity();
|
throw TicketGenerationException::invalidQuantity();
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseId): Collection {
|
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseItemId): Collection {
|
||||||
$targets = $this->resolveTargets(
|
$targets = $this->resolveTargets(
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$quantity,
|
$quantity,
|
||||||
@@ -37,7 +37,7 @@ class TicketGeneratorService
|
|||||||
);
|
);
|
||||||
|
|
||||||
return $targets->map(function (array $target) use (
|
return $targets->map(function (array $target) use (
|
||||||
$sourcePurchaseId,
|
$sourcePurchaseItemId,
|
||||||
$user,
|
$user,
|
||||||
): Ticket {
|
): Ticket {
|
||||||
$item = $target['catalog_item'];
|
$item = $target['catalog_item'];
|
||||||
@@ -45,7 +45,7 @@ class TicketGeneratorService
|
|||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'tenant_code' => $item->tenant_code,
|
'tenant_code' => $item->tenant_code,
|
||||||
'ticket' => (string) Str::uuid(),
|
'ticket' => (string) Str::uuid(),
|
||||||
'source_purchase_id' => $sourcePurchaseId,
|
'source_purchase_item_id' => $sourcePurchaseItemId,
|
||||||
'source_catalog_item_id' => $item->getKey(),
|
'source_catalog_item_id' => $item->getKey(),
|
||||||
'source_variant_id' => $variant?->getKey(),
|
'source_variant_id' => $variant?->getKey(),
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ Genera, valida, consulta y exporta entradas asociadas a compras pagadas de produ
|
|||||||
|
|
||||||
## Modelo
|
## Modelo
|
||||||
|
|
||||||
- `Ticket`: pertenece a tenant y usuario, y conserva referencias a compra, producto, variante y usuario escáner.
|
- `Ticket`: pertenece a tenant y usuario, y conserva referencias al ítem de compra que lo generó, producto,
|
||||||
|
variante y usuario escáner. La compra se obtiene a través de su ítem.
|
||||||
- El nombre y la descripción se calculan dinámicamente desde el producto y la variante; los tickets no
|
- El nombre y la descripción se calculan dinámicamente desde el producto y la variante; los tickets no
|
||||||
persisten una copia de esos textos.
|
persisten una copia de esos textos.
|
||||||
- `ValidityTime`: define ventanas absolutas o relativas de vigencia para fechas de evento y opciones de atributos.
|
- `ValidityTime`: define ventanas absolutas o relativas de vigencia para fechas de evento y opciones de atributos.
|
||||||
@@ -57,6 +58,12 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
|
|||||||
- `GET /tickets`.
|
- `GET /tickets`.
|
||||||
- `POST /tickets/pdf`.
|
- `POST /tickets/pdf`.
|
||||||
|
|
||||||
|
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
|
||||||
|
`adminapp.tickets`:
|
||||||
|
|
||||||
|
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
|
||||||
|
`scanned_tickets` y `total_tickets` para el tenant autenticado.
|
||||||
|
|
||||||
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
|
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|||||||
18
app/Domains/Ticket/routes/adminapp.php
Normal file
18
app/Domains/Ticket/routes/adminapp.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Controllers\AdminApp\TicketController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('tickets', [TicketController::class, 'index'])
|
||||||
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
|
->name('adminapp.tickets.index');
|
||||||
|
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
|
||||||
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
|
->name('adminapp.tickets.pdf');
|
||||||
|
Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
|
||||||
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
|
->name('adminapp.tickets.excel');
|
||||||
|
});
|
||||||
@@ -11,3 +11,4 @@ Route::prefix('tenants/{tenant:codigo}')
|
|||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/scanner.php';
|
require __DIR__.'/scanner.php';
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Http\Middleware;
|
namespace App\Http\Middleware;
|
||||||
|
|
||||||
use App\Domains\Authorization\Enums\PermissionCode;
|
use App\Domains\Authorization\Enums\PermissionCode;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Auth\Access\AuthorizationException;
|
use Illuminate\Auth\Access\AuthorizationException;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -19,6 +20,7 @@ class EnsureScannerTenant
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
! $user
|
! $user
|
||||||
|
|| $user->rol_codigo !== RoleCode::Scanner->value
|
||||||
|| ! $user->tenant_codigo
|
|| ! $user->tenant_codigo
|
||||||
|| ! $user->hasPermission(PermissionCode::ScanTickets->value)
|
|| ! $user->hasPermission(PermissionCode::ScanTickets->value)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Policies\IntegrationPolicy;
|
||||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
@@ -13,6 +15,7 @@ use Illuminate\Cache\RateLimiting\Limit;
|
|||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Event;
|
use Illuminate\Support\Facades\Event;
|
||||||
|
use Illuminate\Support\Facades\Gate;
|
||||||
use Illuminate\Support\Facades\RateLimiter;
|
use Illuminate\Support\Facades\RateLimiter;
|
||||||
use Illuminate\Support\ServiceProvider;
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
@@ -31,6 +34,10 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
|
Gate::policy(
|
||||||
|
Integration::class,
|
||||||
|
IntegrationPolicy::class,
|
||||||
|
);
|
||||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||||
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
||||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private const MENU_CODE = 'adminapp.tickets';
|
||||||
|
|
||||||
|
private const TENANT_CODE = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
if (! DB::table('menues')->where('code', 'main.adminapp')->exists()) {
|
||||||
|
// Reference data is added by seeders on fresh installations.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
|
||||||
|
DB::transaction(function () use ($now): void {
|
||||||
|
DB::table('menues')->updateOrInsert(
|
||||||
|
['code' => self::MENU_CODE],
|
||||||
|
[
|
||||||
|
'label' => 'Tickets',
|
||||||
|
'parent_menu_code' => 'main.adminapp',
|
||||||
|
'content_type' => 'dynamic',
|
||||||
|
'static_content_schema' => null,
|
||||||
|
'route' => '/admin/tickets',
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
DB::table('tenants_menues')
|
||||||
|
->where('menu_code', self::MENU_CODE)
|
||||||
|
->where('tenant_code', '!=', self::TENANT_CODE)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
if (DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
|
||||||
|
DB::table('tenants_menues')->updateOrInsert(
|
||||||
|
[
|
||||||
|
'tenant_code' => self::TENANT_CODE,
|
||||||
|
'menu_code' => self::MENU_CODE,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'static_content' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('roles')
|
||||||
|
->whereIn('codigo', ['admin', 'adminapp'])
|
||||||
|
->pluck('codigo')
|
||||||
|
->each(function (string $roleCode): void {
|
||||||
|
DB::table('roles_menues')->updateOrInsert([
|
||||||
|
'rol_codigo' => $roleCode,
|
||||||
|
'menu_codigo' => self::MENU_CODE,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::transaction(function (): void {
|
||||||
|
DB::table('tenants_menues')
|
||||||
|
->where('menu_code', self::MENU_CODE)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
DB::table('roles_menues')
|
||||||
|
->where('menu_codigo', self::MENU_CODE)
|
||||||
|
->delete();
|
||||||
|
|
||||||
|
DB::table('menues')
|
||||||
|
->where('code', self::MENU_CODE)
|
||||||
|
->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('source_purchase_item_id')
|
||||||
|
->nullable()
|
||||||
|
->after('source_purchase_id')
|
||||||
|
->constrained('compra_items')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('tickets')
|
||||||
|
->whereNotNull('source_purchase_id')
|
||||||
|
->whereNull('source_purchase_item_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function (Collection $tickets): void {
|
||||||
|
$purchaseIds = $tickets->pluck('source_purchase_id')->unique()->values();
|
||||||
|
$itemsByPurchase = DB::table('compra_items')
|
||||||
|
->whereIn('compra_id', $purchaseIds)
|
||||||
|
->get(['id', 'compra_id', 'source_catalog_item_id', 'source_variant_id'])
|
||||||
|
->groupBy('compra_id');
|
||||||
|
$bundleIds = $itemsByPurchase->flatten(1)
|
||||||
|
->pluck('source_catalog_item_id')
|
||||||
|
->filter()
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
$componentsByBundle = DB::table('bundle_components')
|
||||||
|
->whereIn('bundle_catalog_item_id', $bundleIds)
|
||||||
|
->get([
|
||||||
|
'bundle_catalog_item_id',
|
||||||
|
'component_catalog_item_id',
|
||||||
|
'component_variant_id',
|
||||||
|
])
|
||||||
|
->groupBy('bundle_catalog_item_id');
|
||||||
|
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
$candidates = collect($itemsByPurchase->get($ticket->source_purchase_id, []))
|
||||||
|
->filter(function (object $item) use ($ticket, $componentsByBundle): bool {
|
||||||
|
if ($this->sameCatalogTarget($item, $ticket)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($componentsByBundle->get($item->source_catalog_item_id, []))
|
||||||
|
->contains(fn (object $component): bool => $this->sameBundleTarget($component, $ticket));
|
||||||
|
})
|
||||||
|
->pluck('id')
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($candidates->count() === 1) {
|
||||||
|
DB::table('tickets')->where('id', $ticket->id)->update([
|
||||||
|
'source_purchase_item_id' => $candidates->sole(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('source_purchase_item_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sameCatalogTarget(object $item, object $ticket): bool
|
||||||
|
{
|
||||||
|
return (int) $item->source_catalog_item_id === (int) $ticket->source_catalog_item_id
|
||||||
|
&& $this->sameNullableId($item->source_variant_id, $ticket->source_variant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sameBundleTarget(object $component, object $ticket): bool
|
||||||
|
{
|
||||||
|
return (int) $component->component_catalog_item_id === (int) $ticket->source_catalog_item_id
|
||||||
|
&& $this->sameNullableId($component->component_variant_id, $ticket->source_variant_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function sameNullableId(mixed $left, mixed $right): bool
|
||||||
|
{
|
||||||
|
return $left === null || $right === null
|
||||||
|
? $left === null && $right === null
|
||||||
|
: (int) $left === (int) $right;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$unresolved = DB::table('tickets')
|
||||||
|
->whereNotNull('source_purchase_id')
|
||||||
|
->whereNull('source_purchase_item_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(20)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
if ($unresolved->isNotEmpty()) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'No se puede eliminar tickets.source_purchase_id: hay tickets sin un compra_item inequívoco. '
|
||||||
|
.'IDs: '.$unresolved->implode(', ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$inconsistent = DB::table('tickets')
|
||||||
|
->join('compra_items', 'compra_items.id', '=', 'tickets.source_purchase_item_id')
|
||||||
|
->whereNotNull('tickets.source_purchase_id')
|
||||||
|
->whereColumn('tickets.source_purchase_id', '!=', 'compra_items.compra_id')
|
||||||
|
->orderBy('tickets.id')
|
||||||
|
->limit(20)
|
||||||
|
->pluck('tickets.id');
|
||||||
|
|
||||||
|
if ($inconsistent->isNotEmpty()) {
|
||||||
|
throw new RuntimeException(
|
||||||
|
'No se puede eliminar tickets.source_purchase_id: hay referencias de compra inconsistentes. '
|
||||||
|
.'IDs: '.$inconsistent->implode(', ')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('source_purchase_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('source_purchase_id')
|
||||||
|
->nullable()
|
||||||
|
->after('description')
|
||||||
|
->constrained('compras')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('tickets')
|
||||||
|
->whereNotNull('source_purchase_item_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function (Collection $tickets): void {
|
||||||
|
$purchaseIdsByItem = DB::table('compra_items')
|
||||||
|
->whereIn('id', $tickets->pluck('source_purchase_item_id'))
|
||||||
|
->pluck('compra_id', 'id');
|
||||||
|
|
||||||
|
foreach ($tickets as $ticket) {
|
||||||
|
DB::table('tickets')->where('id', $ticket->id)->update([
|
||||||
|
'source_purchase_id' => $purchaseIdsByItem->get($ticket->source_purchase_item_id),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?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('tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['source_purchase_item_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->foreign('source_purchase_item_id')
|
||||||
|
->references('id')
|
||||||
|
->on('compra_items')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['source_purchase_item_id']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('tickets', function (Blueprint $table): void {
|
||||||
|
$table->foreign('source_purchase_item_id')
|
||||||
|
->references('id')
|
||||||
|
->on('compra_items')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->nullOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?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('users', function (Blueprint $table): void {
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropSoftDeletes();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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('users', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['email']);
|
||||||
|
$table->string('active_email')
|
||||||
|
->nullable()
|
||||||
|
->storedAs('CASE WHEN `deleted_at` IS NULL THEN LOWER(`email`) ELSE NULL END');
|
||||||
|
$table->unique('active_email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['active_email']);
|
||||||
|
$table->dropColumn('active_email');
|
||||||
|
$table->unique('email');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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('users', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['google_id']);
|
||||||
|
$table->string('active_google_id')
|
||||||
|
->nullable()
|
||||||
|
->storedAs('CASE WHEN `deleted_at` IS NULL THEN `google_id` ELSE NULL END');
|
||||||
|
$table->unique('active_google_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique(['active_google_id']);
|
||||||
|
$table->dropColumn('active_google_id');
|
||||||
|
$table->unique('google_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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('integration_instances', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('integration_code');
|
||||||
|
$table->string('name');
|
||||||
|
$table->longText('integration_data')->nullable(); // Encrypted JSON.
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->foreign('integration_code')->references('integration_code')->on('integrations')->restrictOnDelete();
|
||||||
|
// Allows associations to enforce one instance per integration and owner.
|
||||||
|
$table->unique(['id', 'integration_code'], 'integration_instances_id_code_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('integration_instances');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
private const MENU_CODE = 'adminapp.staff';
|
||||||
|
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('menues')
|
||||||
|
->where('code', self::MENU_CODE)
|
||||||
|
->update([
|
||||||
|
'label' => 'Usuarios',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('menues')
|
||||||
|
->where('code', self::MENU_CODE)
|
||||||
|
->update([
|
||||||
|
'label' => 'Staff',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?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('users', function (Blueprint $table): void {
|
||||||
|
$table->unique(['active_email', 'rol_codigo']);
|
||||||
|
$table->dropUnique(['active_email']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->unique('active_email');
|
||||||
|
$table->dropUnique(['active_email', 'rol_codigo']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user