Compare commits
109 Commits
feature/ti
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bd77727f58 | |||
| 8595300071 | |||
| 5503559899 | |||
| 7323707424 | |||
| 830f65c402 | |||
| df6892c662 | |||
| de08ee4ac7 | |||
| 1269ebcda0 | |||
| 04e54fefad | |||
| a46bf905e8 | |||
| 353fd34db2 | |||
| f8a3296821 | |||
| 9aa0c1f3b9 | |||
| 708037677a | |||
| e6b5da366c | |||
| 69bea10519 | |||
| 3d6548ce4a | |||
| 8ad9b3a4e3 | |||
| 3a4f6b64d2 | |||
| 44178d72a5 | |||
| d02ad5fce2 | |||
| 69f2dbe056 | |||
| 24d87623cc | |||
| 51937ca93f | |||
| dcf4383fbf | |||
| ff43ba32f3 | |||
| 9fa4da7de2 | |||
| c27d9628fd | |||
| b1e09b71ad | |||
| 7d45734ad7 | |||
| 91022c5897 | |||
| 61314d5166 | |||
| 0d54887602 | |||
| 54827fbc58 | |||
| 18a0d14fa8 | |||
| b4a7b80430 | |||
| 2bd3bf9dc1 | |||
| e42bc545a5 | |||
| aa14129fe7 | |||
| 289ceba3af | |||
| 28ab6c9ae4 | |||
| 67deca095e | |||
| caed44fcc8 | |||
| adec5d1725 | |||
| a62e989bb4 | |||
| 9c14153199 | |||
| 756f4dad0a | |||
| 205d77bc0b | |||
| 4f7ede1072 | |||
| 4bb4f526e4 | |||
| c0c19c9c01 | |||
| b4da6e3747 | |||
| 1880fc8147 | |||
| 96df431d60 | |||
| 106cf017dc | |||
| d5fdae9a24 | |||
| 4abb6c67fd | |||
| 6384c0046d | |||
| 2ddb046c26 | |||
| 5d00dc439e | |||
| 210c854fee | |||
| beb5d18b29 | |||
| de259f4286 | |||
| f19bca64d0 | |||
| 18f739b712 | |||
| 1564985259 | |||
| 471a941587 | |||
| 4c647968cb | |||
| 55dcc2e37f | |||
| cac4fcf2b2 | |||
| 64e965aff7 | |||
| 002f08a8fa | |||
| 531dbe52b9 | |||
| a885a2fc0e | |||
| 0c49bae752 | |||
| 67bcb420e4 | |||
| 34058e6a81 | |||
| 8a65d358a4 | |||
| 193e10dc48 | |||
| b607c1b673 | |||
| e9521e65d0 | |||
| d0625f25e5 | |||
| 1fdcfc1547 | |||
| ca4ea0aa6c | |||
| bb1f9c8e91 | |||
| 99fe94fa6a | |||
| a4b5c2eb19 | |||
| 2feca2bed5 | |||
| a35ff69140 | |||
| 75152b53a4 | |||
| 99594b17e7 | |||
| 85edea0661 | |||
| ab5ea7dd33 | |||
| c0057c237a | |||
| e4146288f1 | |||
| f11ba5a470 | |||
| e4db36e650 | |||
| ffa3f10b18 | |||
| 7acef66ee7 | |||
| 18f1217daa | |||
| ac44e82454 | |||
| 1e7a9b6876 | |||
| deea4fd4af | |||
| c4ff695f2e | |||
| 703158bd06 | |||
| eda2343380 | |||
| 0baad641d5 | |||
| ddb8c3743f | |||
| 900ef6cf98 |
@@ -45,6 +45,7 @@ COMMANDS_LOG_LEVEL=info
|
||||
COMMANDS_LOG_DAYS=30
|
||||
EMAILS_LOG_LEVEL=info
|
||||
EMAILS_LOG_DAYS=30
|
||||
EMAIL_DELIVERY_LEASE_SECONDS=300
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# Project Conventions
|
||||
|
||||
## Test database safety
|
||||
|
||||
- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`.
|
||||
- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database.
|
||||
- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations.
|
||||
|
||||
## Architecture
|
||||
|
||||
This project uses a domain-oriented structure under `app/Domains`.
|
||||
|
||||
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\Requests\ResetPasswordRequest;
|
||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -26,6 +27,7 @@ class ResetPasswordController extends Controller
|
||||
$data['email'],
|
||||
$data['codigo'],
|
||||
$data['password'],
|
||||
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||
)) {
|
||||
throw ValidationException::withMessages([
|
||||
'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\Requests\ValidateResetPasswordAttemptRequest;
|
||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -25,6 +26,7 @@ class ValidateResetPasswordAttemptController extends Controller
|
||||
$result = $this->resetPasswordAttemptService->validateCode(
|
||||
$data['email'],
|
||||
$data['codigo'],
|
||||
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||
);
|
||||
|
||||
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||
|
||||
@@ -17,6 +17,8 @@ class ResetPasswordAttempt extends Model
|
||||
|
||||
public const REASON_STAFF_CREATED = 'staff_created';
|
||||
|
||||
public const REASON_ADMINISTRATOR_CREATED = 'administrator_created';
|
||||
|
||||
public const STATUS_PENDING = 'pending';
|
||||
|
||||
public const STATUS_VALIDATED = 'validated';
|
||||
|
||||
@@ -5,7 +5,9 @@ namespace App\Domains\Auth\Models;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Authorization\Models\Role;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Event\Models\EventDateChangeView;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\ScanAttempt;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
@@ -13,16 +15,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[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
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use HasApiTokens, HasFactory, Notifiable;
|
||||
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||
|
||||
protected $attributes = [
|
||||
'rol_codigo' => RoleCode::User->value,
|
||||
@@ -45,6 +48,18 @@ class User extends Authenticatable
|
||||
return $this->hasMany(LoginAttempt::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<ScanAttempt, $this> */
|
||||
public function scanAttempts(): HasMany
|
||||
{
|
||||
return $this->hasMany(ScanAttempt::class, 'scanner_user_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<EventDateChangeView, $this> */
|
||||
public function eventDateChangeViews(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventDateChangeView::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Role, $this>
|
||||
*/
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Auth\Requests;
|
||||
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
@@ -13,15 +14,26 @@ class RegisterUserRequest extends FormRequest
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
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 [
|
||||
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
||||
'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()],
|
||||
'dni' => ['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\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UpdateProfileRequest extends FormRequest
|
||||
{
|
||||
@@ -12,6 +13,13 @@ class UpdateProfileRequest extends FormRequest
|
||||
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
|
||||
{
|
||||
return [
|
||||
@@ -19,11 +27,13 @@ class UpdateProfileRequest extends FormRequest
|
||||
'email' => [
|
||||
'required',
|
||||
'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}$/'],
|
||||
'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()],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@ class UserResource extends JsonResource
|
||||
'telefono' => $this->telefono,
|
||||
'rol_codigo' => $this->rol_codigo,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
|
||||
->map(fn ($category) => [
|
||||
'id' => $category->id,
|
||||
'nombre' => $category->nombre,
|
||||
])->values()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class AdminCredentialVerifier
|
||||
public function verify(string $email, string $password): bool
|
||||
{
|
||||
$admin = User::query()
|
||||
->where('email', mb_strtolower(trim($email)))
|
||||
->where('active_email', mb_strtolower(trim($email)))
|
||||
->where('rol_codigo', RoleCode::Admin->value)
|
||||
->first();
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Auth\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Notification\Events\UserRegistered;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
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) {
|
||||
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) {
|
||||
$user->forceFill(['google_id' => $googleId])->save();
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ class PasswordLoginService
|
||||
null,
|
||||
$ipAddress,
|
||||
$userAgent,
|
||||
null,
|
||||
RoleCode::Scanner,
|
||||
true,
|
||||
PermissionCode::ScanTickets->value,
|
||||
PasswordResetRequested::CHANNEL_SCANNER,
|
||||
@@ -120,7 +120,7 @@ class PasswordLoginService
|
||||
$passwordResetChannel,
|
||||
): array {
|
||||
$user = User::query()
|
||||
->where('email', $normalizedEmail)
|
||||
->where('active_email', $normalizedEmail)
|
||||
->when(
|
||||
$requiredRole !== null,
|
||||
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
||||
|
||||
@@ -28,7 +28,8 @@ class ResetPasswordAttemptService
|
||||
try {
|
||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->where('active_email', mb_strtolower(trim($email)))
|
||||
->where('rol_codigo', RoleCode::User->value)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
@@ -65,7 +66,7 @@ class ResetPasswordAttemptService
|
||||
try {
|
||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->where('active_email', mb_strtolower(trim($email)))
|
||||
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||
->whereNotNull('tenant_codigo')
|
||||
->lockForUpdate()
|
||||
@@ -113,7 +114,7 @@ class ResetPasswordAttemptService
|
||||
try {
|
||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->where('active_email', mb_strtolower(trim($email)))
|
||||
->where('rol_codigo', RoleCode::Scanner->value)
|
||||
->whereNotNull('tenant_codigo')
|
||||
->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);
|
||||
|
||||
try {
|
||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
||||
return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string {
|
||||
$user = User::query()
|
||||
->where('email', $email)
|
||||
->where('active_email', mb_strtolower(trim($email)))
|
||||
->where('rol_codigo', $role->value)
|
||||
->lockForUpdate()
|
||||
->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);
|
||||
|
||||
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()
|
||||
->where('email', $email)
|
||||
->where('active_email', mb_strtolower(trim($email)))
|
||||
->where('rol_codigo', $role->value)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
|
||||
@@ -19,6 +19,16 @@ class ScannerContextService
|
||||
|
||||
$user->setRelation('tenant', $tenant);
|
||||
|
||||
if ($tenant->requiresScannerCategoryValidation()) {
|
||||
$categories = $user->scanCategories()
|
||||
->orderBy('nombre')
|
||||
->get();
|
||||
|
||||
if ($categories->isNotEmpty()) {
|
||||
$user->setRelation('scanCategories', $categories);
|
||||
}
|
||||
}
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ Route::prefix('v1/adminapp')->group(function (): void {
|
||||
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
||||
->middleware('throttle:5,1');
|
||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||
->defaults('reset_role', 'adminapp')
|
||||
->middleware('throttle:10,1');
|
||||
Route::post('password/reset', ResetPasswordController::class)
|
||||
->defaults('reset_role', 'adminapp')
|
||||
->middleware('throttle:5,1');
|
||||
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->get('me', AdminAppMeController::class);
|
||||
|
||||
@@ -12,8 +12,10 @@ Route::prefix('v1/scanner')->group(function (): void {
|
||||
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
||||
->middleware('throttle:5,1');
|
||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||
->defaults('reset_role', 'scanner')
|
||||
->middleware('throttle:10,1');
|
||||
Route::post('password/reset', ResetPasswordController::class)
|
||||
->defaults('reset_role', 'scanner')
|
||||
->middleware('throttle:5,1');
|
||||
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||
->get('me', ScannerMeController::class);
|
||||
|
||||
@@ -17,6 +17,7 @@ class AdminAppBootstrapResource extends JsonResource
|
||||
|
||||
return [
|
||||
'website_type_code' => $websiteType->codigo,
|
||||
'site_title' => $websiteType->site_title,
|
||||
'primary_color' => $websiteType->primary_color,
|
||||
'secondary_color' => $websiteType->secondary_color,
|
||||
'danger_color' => $websiteType->danger_color,
|
||||
|
||||
@@ -32,6 +32,7 @@ class TenantBootstrapService
|
||||
return $this->tenantInformationService->load(
|
||||
$tenant,
|
||||
[
|
||||
'eventDateChanges',
|
||||
'menues' => fn ($query) => $query->whereHas(
|
||||
'roles',
|
||||
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
||||
|
||||
@@ -400,6 +400,17 @@ class Cart extends Model
|
||||
]);
|
||||
}
|
||||
|
||||
if ($catalogItem->bundleComponents()
|
||||
->whereNotNull('component_variant_id')
|
||||
->whereHas('variant', fn ($query) => $query
|
||||
->whereNotNull('sales_disabled_at')
|
||||
->orWhereNotNull('replaced_by_variant_id'))
|
||||
->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
|
||||
]);
|
||||
}
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
@@ -430,6 +441,12 @@ class Cart extends Model
|
||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||
}
|
||||
|
||||
if (! $variant->isSellable()) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => [__('api.cart.variant_unavailable')],
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
|
||||
$variant->setRelation('catalogItem', $catalogItem);
|
||||
$variant->setRelation('inventory', $inventory);
|
||||
|
||||
99
app/Domains/Cart/Services/CartVariantReplacementService.php
Normal file
99
app/Domains/Cart/Services/CartVariantReplacementService.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CartVariantReplacementService
|
||||
{
|
||||
public function __construct(private readonly CatalogInventoryService $inventory) {}
|
||||
|
||||
public function replaceHistoricalVariants(Cart $cart): void
|
||||
{
|
||||
$items = $cart->items()
|
||||
->whereNotNull('variant_id')
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($items as $item) {
|
||||
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
|
||||
|
||||
if ($variant === null) {
|
||||
throw $this->unavailableVariant();
|
||||
}
|
||||
|
||||
$replacement = $this->latestReplacement($variant);
|
||||
|
||||
if ($replacement->is($variant)) {
|
||||
if (! $variant->isSellable()) {
|
||||
throw $this->unavailableVariant();
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $replacement->isSellable()) {
|
||||
throw $this->unavailableVariant();
|
||||
}
|
||||
|
||||
/** @var CartItem|null $targetItem */
|
||||
$targetItem = $cart->items()
|
||||
->whereKeyNot($item->getKey())
|
||||
->where('catalog_item_id', $item->catalog_item_id)
|
||||
->where('variant_id', $replacement->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
|
||||
if ($replacement->inventory_id !== $variant->inventory_id) {
|
||||
$available = $this->inventory->availableQuantity($replacement);
|
||||
|
||||
if ($available !== null && $available < $replacementQuantity) {
|
||||
throw $this->unavailableVariant();
|
||||
}
|
||||
}
|
||||
|
||||
if ($targetItem !== null) {
|
||||
$targetItem->cantidad += $item->cantidad;
|
||||
$targetItem->save();
|
||||
$item->delete();
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$item->update(['variant_id' => $replacement->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
private function latestReplacement(Variant $variant): Variant
|
||||
{
|
||||
$current = $variant;
|
||||
$visited = [];
|
||||
|
||||
while ($current->replaced_by_variant_id !== null) {
|
||||
if (isset($visited[$current->getKey()])) {
|
||||
throw $this->unavailableVariant();
|
||||
}
|
||||
|
||||
$visited[$current->getKey()] = true;
|
||||
$current = Variant::query()
|
||||
->lockForUpdate()
|
||||
->find($current->replaced_by_variant_id)
|
||||
?? throw $this->unavailableVariant();
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
private function unavailableVariant(): ValidationException
|
||||
{
|
||||
return ValidationException::withMessages([
|
||||
'cart_id' => [__('api.cart.cart_variant_unavailable')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Services;
|
||||
|
||||
use App\Domains\Cart\Models\Cart;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class InvalidateEventDateCartsService
|
||||
{
|
||||
public function __construct(private readonly StockReservationService $reservations) {}
|
||||
|
||||
/** @param Collection<int, int> $eventDateIds */
|
||||
public function invalidate(
|
||||
Tenant $tenant,
|
||||
Collection $eventDateIds,
|
||||
string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED,
|
||||
): void {
|
||||
DB::transaction(function () use ($tenant, $eventDateIds, $reason): void {
|
||||
$carts = Cart::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('status', Cart::STATUS_ACTIVE)
|
||||
->whereNull('current_purchase_id')
|
||||
->whereHas('currentStockReservation', fn ($reservation) => $reservation
|
||||
->where('status', StockReservation::STATUS_ACTIVE))
|
||||
->whereHas('items.variant', fn ($variant) => $variant
|
||||
->withTrashed()
|
||||
->where(fn ($dates) => $dates
|
||||
->whereIn('event_date_id', $eventDateIds)
|
||||
->orWhereHas('eventDates', fn ($date) => $date
|
||||
->whereIn('event_dates.id', $eventDateIds))))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($carts as $cart) {
|
||||
// Checkout keeps the cart and purchase attached to the same reservation.
|
||||
if (Purchase::query()
|
||||
->where('stock_reservation_id', $cart->current_stock_reservation_id)
|
||||
->exists()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->reservations->releaseCurrentCartReservation(
|
||||
$cart,
|
||||
$reason,
|
||||
);
|
||||
|
||||
// Reuse the existing expired-cart flow: stale mutations receive the
|
||||
// expiration response and the next GET replaces the whole cart.
|
||||
$cart->update(['status' => Cart::STATUS_EXPIRED]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,8 @@ class Attribute extends Model
|
||||
public function eventDates(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
|
||||
->whereNull('rescheduled_to_event_date_id')
|
||||
->whereNull('suspended_at')
|
||||
->orderBy('date')
|
||||
->orderBy('time_start');
|
||||
}
|
||||
|
||||
@@ -179,11 +179,27 @@ class CatalogItem extends Model
|
||||
{
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||
->where(function (Builder $unlimitedQuery): void {
|
||||
$unlimitedQuery
|
||||
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||
->where(function (Builder $selectionQuery): void {
|
||||
$selectionQuery
|
||||
->whereDoesntHave('variants')
|
||||
->orWhereHas('variants', fn (Builder $variantQuery): Builder => $variantQuery
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id'));
|
||||
});
|
||||
})
|
||||
->orWhereHas(
|
||||
'variants.inventory',
|
||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||
'variants',
|
||||
fn (Builder $variantQuery): Builder => $variantQuery
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->whereHas(
|
||||
'inventory',
|
||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||
)
|
||||
)
|
||||
->orWhere(function (Builder $directItemQuery): void {
|
||||
$directItemQuery
|
||||
@@ -205,9 +221,12 @@ class CatalogItem extends Model
|
||||
public function visibleVariants(?int $includedVariantId = null): Collection
|
||||
{
|
||||
return $this->variants
|
||||
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|
||||
|| $this->inventory_policy === InventoryPolicy::Unlimited
|
||||
|| ($variant->inventory?->availableStock() ?? 0) > 0)
|
||||
->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
|
||||
&& (($includedVariantId !== null && $variant->id === $includedVariantId)
|
||||
|| ($variant->isSellable() && (
|
||||
$this->inventory_policy === InventoryPolicy::Unlimited
|
||||
|| ($variant->inventory?->availableStock() ?? 0) > 0
|
||||
))))
|
||||
->values();
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||
|
||||
#[Fillable([
|
||||
'sold_units',
|
||||
'refunded_units',
|
||||
'reserved_stock',
|
||||
'real_stock',
|
||||
])]
|
||||
@@ -23,6 +24,7 @@ class Inventory extends Model
|
||||
|
||||
protected $attributes = [
|
||||
'sold_units' => 0,
|
||||
'refunded_units' => 0,
|
||||
'reserved_stock' => 0,
|
||||
'real_stock' => 0,
|
||||
];
|
||||
@@ -31,6 +33,7 @@ class Inventory extends Model
|
||||
{
|
||||
return [
|
||||
'sold_units' => 'integer',
|
||||
'refunded_units' => 'integer',
|
||||
'reserved_stock' => 'integer',
|
||||
'real_stock' => 'integer',
|
||||
];
|
||||
@@ -42,10 +45,10 @@ class Inventory extends Model
|
||||
return $this->hasOne(CatalogItem::class);
|
||||
}
|
||||
|
||||
/** @return HasOne<Variant, $this> */
|
||||
public function variant(): HasOne
|
||||
/** @return HasMany<Variant, $this> */
|
||||
public function variants(): HasMany
|
||||
{
|
||||
return $this->hasOne(Variant::class);
|
||||
return $this->hasMany(Variant::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<StockReservationLine, $this> */
|
||||
|
||||
@@ -20,6 +20,8 @@ use Illuminate\Support\Str;
|
||||
'catalog_item_id',
|
||||
'event_date_id',
|
||||
'inventory_id',
|
||||
'replaced_by_variant_id',
|
||||
'sales_disabled_at',
|
||||
'descripcion',
|
||||
'precio',
|
||||
])]
|
||||
@@ -37,6 +39,8 @@ class Variant extends Model
|
||||
'catalog_item_id' => 'integer',
|
||||
'event_date_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'replaced_by_variant_id' => 'integer',
|
||||
'sales_disabled_at' => 'datetime',
|
||||
'precio' => 'decimal:2',
|
||||
];
|
||||
}
|
||||
@@ -76,6 +80,39 @@ class Variant extends Model
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Variant, $this> */
|
||||
public function replacement(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'replaced_by_variant_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<Variant, $this> */
|
||||
public function replacedVariants(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'replaced_by_variant_id');
|
||||
}
|
||||
|
||||
public function isSellable(): bool
|
||||
{
|
||||
return $this->sales_disabled_at === null
|
||||
&& $this->replaced_by_variant_id === null
|
||||
&& $this->hasOnlyActiveEventDates();
|
||||
}
|
||||
|
||||
public function hasOnlyActiveEventDates(): bool
|
||||
{
|
||||
if (! $this->exists
|
||||
&& $this->event_date_id === null
|
||||
&& ! $this->relationLoaded('eventDates')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $this->selectedEventDates()->every(
|
||||
fn (EventDate $eventDate): bool => $eventDate->rescheduled_to_event_date_id === null
|
||||
&& $eventDate->suspended_at === null,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return HasMany<VariantDefinition, $this> */
|
||||
public function definitions(): HasMany
|
||||
{
|
||||
|
||||
@@ -63,9 +63,14 @@ class CatalogInventoryService
|
||||
|
||||
$selection->loadMissing('variants.inventory');
|
||||
|
||||
return $selection->variants->sum(
|
||||
fn (Variant $variant): int => $variant->inventory->availableStock(),
|
||||
);
|
||||
return $selection->variants
|
||||
->filter(fn (Variant $variant): bool => $variant->isSellable())
|
||||
->unique(fn (Variant $variant): string => $variant->inventory_id === null
|
||||
? 'object:'.spl_object_id($variant->inventory)
|
||||
: 'id:'.$variant->inventory_id)
|
||||
->sum(
|
||||
fn (Variant $variant): int => $variant->inventory->availableStock(),
|
||||
);
|
||||
}
|
||||
|
||||
$requirements = $this->inventoryRequirements($selection);
|
||||
|
||||
@@ -207,7 +207,8 @@ class CatalogService
|
||||
$visibleVariants = $catalogItem->visibleVariants();
|
||||
if ($catalogItem->type === CatalogItemType::Standard
|
||||
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
|
||||
&& ! $catalogItem->isAvailable()) {
|
||||
&& (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|
||||
|| ! $catalogItem->isAvailable())) {
|
||||
throw new NotFoundHttpException('Catalog item is out of stock.');
|
||||
}
|
||||
|
||||
@@ -324,9 +325,13 @@ class CatalogService
|
||||
->findOrFail($variant->catalog_item_id);
|
||||
$variant->delete();
|
||||
|
||||
if (! $catalogItem->variants()->exists()) {
|
||||
$sellableVariants = $catalogItem->variants()
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id');
|
||||
|
||||
if (! (clone $sellableVariants)->exists()) {
|
||||
$this->delete($catalogItem);
|
||||
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) {
|
||||
} elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
|
||||
$catalogItem->update(['precio' => $minimumPrice]);
|
||||
}
|
||||
|
||||
@@ -399,7 +404,11 @@ class CatalogService
|
||||
]);
|
||||
}
|
||||
|
||||
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
|
||||
if ($variantId !== null && ! $componentItem->variants()
|
||||
->whereKey($variantId)
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
"components.{$index}.variant_id" => [
|
||||
__('api.catalog.component_variant_invalid'),
|
||||
|
||||
@@ -19,6 +19,10 @@ class StockReservationService
|
||||
|
||||
public const REASON_CART_CHANGED = 'cart_changed';
|
||||
|
||||
public const REASON_EVENT_DATE_RESCHEDULED = 'event_date_rescheduled';
|
||||
|
||||
public const REASON_EVENT_DATE_SUSPENDED = 'event_date_suspended';
|
||||
|
||||
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
|
||||
|
||||
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
|
||||
|
||||
263
app/Domains/Catalog/Services/VariantReplacementService.php
Normal file
263
app/Domains/Catalog/Services/VariantReplacementService.php
Normal file
@@ -0,0 +1,263 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Models\BundleComponent;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\StockReservationLine;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class VariantReplacementService
|
||||
{
|
||||
/** @return Collection<int, Variant> */
|
||||
public function replaceEventDate(EventDate $source, EventDate $destination): Collection
|
||||
{
|
||||
$variants = Variant::query()
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->where(function ($query) use ($source): void {
|
||||
$query->where('event_date_id', $source->getKey())
|
||||
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
|
||||
->where('event_dates.id', $source->getKey()));
|
||||
})
|
||||
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
return $variants->map(function (Variant $variant) use ($source, $destination): Variant {
|
||||
$destinationDateIds = $variant->selectedEventDates()
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id === (int) $source->getKey()
|
||||
? (int) $destination->getKey()
|
||||
: (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
$replacement = $this->findEquivalent($variant, $destinationDateIds)
|
||||
?? $this->cloneWithDates($variant, $destinationDateIds);
|
||||
|
||||
$variant->update([
|
||||
'replaced_by_variant_id' => $replacement->getKey(),
|
||||
'sales_disabled_at' => now(),
|
||||
]);
|
||||
|
||||
BundleComponent::query()
|
||||
->where('component_variant_id', $variant->getKey())
|
||||
->update(['component_variant_id' => $replacement->getKey()]);
|
||||
|
||||
return $replacement;
|
||||
})->values();
|
||||
}
|
||||
|
||||
public function disableForSuspension(EventDate $eventDate): void
|
||||
{
|
||||
$variants = Variant::query()
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->where(function ($query) use ($eventDate): void {
|
||||
$query->where('event_date_id', $eventDate->getKey())
|
||||
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
|
||||
->where('event_dates.id', $eventDate->getKey()));
|
||||
})
|
||||
->with(['eventDates', 'eventDate', 'definitions', 'allAttachments'])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$remainingDateIds = $variant->selectedEventDates()
|
||||
->filter(fn (EventDate $date): bool => $date->suspended_at === null
|
||||
&& $date->rescheduled_to_event_date_id === null)
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->sort()
|
||||
->values();
|
||||
|
||||
if ($remainingDateIds->isEmpty()) {
|
||||
$variant->update(['sales_disabled_at' => now()]);
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$replacement = $this->findEquivalent($variant, $remainingDateIds);
|
||||
if ($replacement === null) {
|
||||
$replacement = $this->cloneWithDates($variant, $remainingDateIds);
|
||||
} else {
|
||||
$this->mergeInventoryInto($variant, $replacement);
|
||||
}
|
||||
|
||||
$variant->update([
|
||||
'replaced_by_variant_id' => $replacement->getKey(),
|
||||
'sales_disabled_at' => now(),
|
||||
]);
|
||||
|
||||
BundleComponent::query()
|
||||
->where('component_variant_id', $variant->getKey())
|
||||
->update(['component_variant_id' => $replacement->getKey()]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param Collection<int, int> $eventDateIds */
|
||||
private function findEquivalent(Variant $source, Collection $eventDateIds): ?Variant
|
||||
{
|
||||
$definitionSignature = $this->definitionSignature($source);
|
||||
$dateSignature = $eventDateIds->map(fn ($id): int => (int) $id)->sort()->values()->all();
|
||||
|
||||
return Variant::query()
|
||||
->where('catalog_item_id', $source->catalog_item_id)
|
||||
->whereKeyNot($source->getKey())
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->with(['eventDates', 'eventDate', 'definitions'])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->first(fn (Variant $candidate): bool => $this->definitionSignature($candidate) === $definitionSignature
|
||||
&& $candidate->selectedEventDates()
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->sort()
|
||||
->values()
|
||||
->all() === $dateSignature
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Collection<int, int> $eventDateIds */
|
||||
private function cloneWithDates(Variant $source, Collection $eventDateIds): Variant
|
||||
{
|
||||
$replacementInventory = $this->cloneInventory($source);
|
||||
$replacement = $source->replicate([
|
||||
'event_date_id',
|
||||
'inventory_id',
|
||||
'replaced_by_variant_id',
|
||||
'sales_disabled_at',
|
||||
]);
|
||||
$replacement->inventory_id = $replacementInventory->getKey();
|
||||
$replacement->event_date_id = $eventDateIds->count() === 1
|
||||
? $eventDateIds->first()
|
||||
: null;
|
||||
$replacement->save();
|
||||
$replacement->eventDates()->sync($eventDateIds->all());
|
||||
|
||||
$replacement->definitions()->createMany(
|
||||
$source->definitions
|
||||
->map(fn ($definition): array => [
|
||||
'item_attribute_id' => $definition->item_attribute_id,
|
||||
'value' => $definition->value,
|
||||
])
|
||||
->all(),
|
||||
);
|
||||
|
||||
$attachments = $source->allAttachments
|
||||
->mapWithKeys(fn ($attachment): array => [
|
||||
$attachment->getKey() => [
|
||||
'orden' => $attachment->pivot->orden,
|
||||
'is_enabled' => $attachment->pivot->is_enabled,
|
||||
],
|
||||
])
|
||||
->all();
|
||||
$replacement->allAttachments()->sync($attachments);
|
||||
|
||||
return $replacement->load(['eventDates', 'eventDate', 'definitions', 'allAttachments']);
|
||||
}
|
||||
|
||||
private function cloneInventory(Variant $source): Inventory
|
||||
{
|
||||
$activeLines = StockReservationLine::query()
|
||||
->where('inventory_id', $source->inventory_id)
|
||||
->whereHas('reservation', fn ($reservation) => $reservation
|
||||
->where('status', StockReservation::STATUS_ACTIVE))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
$sourceInventory = Inventory::query()
|
||||
->whereKey($source->inventory_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
$reservedStock = (int) $activeLines->sum('quantity');
|
||||
|
||||
if ($sourceInventory->reserved_stock !== $reservedStock) {
|
||||
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
|
||||
}
|
||||
|
||||
$replacementInventory = Inventory::query()->create([
|
||||
'sold_units' => $sourceInventory->sold_units,
|
||||
'refunded_units' => $sourceInventory->refunded_units,
|
||||
'reserved_stock' => $reservedStock,
|
||||
'real_stock' => $sourceInventory->real_stock,
|
||||
]);
|
||||
|
||||
if ($activeLines->isNotEmpty()) {
|
||||
StockReservationLine::query()
|
||||
->whereKey($activeLines->modelKeys())
|
||||
->update(['inventory_id' => $replacementInventory->getKey()]);
|
||||
}
|
||||
$sourceInventory->update(['reserved_stock' => 0]);
|
||||
|
||||
return $replacementInventory;
|
||||
}
|
||||
|
||||
private function mergeInventoryInto(Variant $source, Variant $destination): void
|
||||
{
|
||||
if ($source->inventory_id === $destination->inventory_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
$inventories = Inventory::query()
|
||||
->whereKey([$source->inventory_id, $destination->inventory_id])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->keyBy('id');
|
||||
$sourceInventory = $inventories->get($source->inventory_id);
|
||||
$destinationInventory = $inventories->get($destination->inventory_id);
|
||||
if ($sourceInventory === null || $destinationInventory === null) {
|
||||
throw new \LogicException('No se encontró el inventario de una variante.');
|
||||
}
|
||||
|
||||
$activeLines = StockReservationLine::query()
|
||||
->where('inventory_id', $source->inventory_id)
|
||||
->whereHas('reservation', fn ($reservation) => $reservation
|
||||
->where('status', StockReservation::STATUS_ACTIVE))
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
if ($sourceInventory->reserved_stock !== (int) $activeLines->sum('quantity')) {
|
||||
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
|
||||
}
|
||||
|
||||
$destinationInventory->update([
|
||||
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
|
||||
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
|
||||
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
|
||||
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
|
||||
]);
|
||||
if ($activeLines->isNotEmpty()) {
|
||||
StockReservationLine::query()
|
||||
->whereKey($activeLines->modelKeys())
|
||||
->update(['inventory_id' => $destinationInventory->getKey()]);
|
||||
}
|
||||
$sourceInventory->update([
|
||||
'real_stock' => 0,
|
||||
'reserved_stock' => 0,
|
||||
'sold_units' => 0,
|
||||
'refunded_units' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function definitionSignature(Variant $variant): array
|
||||
{
|
||||
return $variant->definitions
|
||||
->map(fn ($definition): string => $definition->item_attribute_id.'\0'.$definition->value)
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -104,7 +104,11 @@ class InvitationPurchaseProvisioner
|
||||
|
||||
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->tenant_codigo !== self::TENANT_CODE) {
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
namespace App\Domains\Event\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Event\Requests\RescheduleEventDateRequest;
|
||||
use App\Domains\Event\Requests\StoreEventDateRequest;
|
||||
use App\Domains\Event\Requests\UpdateEventRequest;
|
||||
use App\Domains\Event\Resources\EventDateResource;
|
||||
use App\Domains\Event\Resources\EventResource;
|
||||
use App\Domains\Event\Services\EventService;
|
||||
use App\Http\Controllers\Controller;
|
||||
@@ -28,4 +32,39 @@ class EventController extends Controller
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function storeDate(StoreEventDateRequest $request): EventDateResource
|
||||
{
|
||||
return EventDateResource::make(
|
||||
$this->eventService->createDateForTenant(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function rescheduleDate(
|
||||
RescheduleEventDateRequest $request,
|
||||
EventDate $eventDate,
|
||||
): EventDateResource {
|
||||
return EventDateResource::make(
|
||||
$this->eventService->rescheduleDateForTenant(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$eventDate,
|
||||
$request->validated(),
|
||||
$request->user(),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function suspendDate(Request $request, EventDate $eventDate): EventDateResource
|
||||
{
|
||||
return EventDateResource::make(
|
||||
$this->eventService->suspendDateForTenant(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$eventDate,
|
||||
$request->user(),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
22
app/Domains/Event/Controllers/EventDateNoticeController.php
Normal file
22
app/Domains/Event/Controllers/EventDateNoticeController.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Controllers;
|
||||
|
||||
use App\Domains\Event\Resources\EventDateNoticeResource;
|
||||
use App\Domains\Event\Services\EventDateNoticeService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class EventDateNoticeController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EventDateNoticeService $noticeService) {}
|
||||
|
||||
public function claim(Request $request, Tenant $tenant): AnonymousResourceCollection
|
||||
{
|
||||
return EventDateNoticeResource::collection(
|
||||
$this->noticeService->claimFor($request->user(), $tenant)
|
||||
);
|
||||
}
|
||||
}
|
||||
9
app/Domains/Event/Enums/EventDateChangeType.php
Normal file
9
app/Domains/Event/Enums/EventDateChangeType.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Enums;
|
||||
|
||||
enum EventDateChangeType: string
|
||||
{
|
||||
case Rescheduled = 'rescheduled';
|
||||
case Suspended = 'suspended';
|
||||
}
|
||||
12
app/Domains/Event/Enums/EventDateStatus.php
Normal file
12
app/Domains/Event/Enums/EventDateStatus.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Enums;
|
||||
|
||||
enum EventDateStatus: string
|
||||
{
|
||||
case Rescheduled = 'rescheduled';
|
||||
case Suspended = 'suspended';
|
||||
case Scheduled = 'scheduled';
|
||||
case InProgress = 'in_progress';
|
||||
case Completed = 'completed';
|
||||
}
|
||||
22
app/Domains/Event/Events/EventDateRescheduled.php
Normal file
22
app/Domains/Event/Events/EventDateRescheduled.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class EventDateRescheduled
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $tenantCode,
|
||||
public readonly int $sourceEventDateId,
|
||||
public readonly int $destinationEventDateId,
|
||||
public readonly string $previousDate,
|
||||
public readonly string $newDate,
|
||||
public readonly array $purchaseTickets,
|
||||
) {}
|
||||
}
|
||||
20
app/Domains/Event/Events/EventDateSuspended.php
Normal file
20
app/Domains/Event/Events/EventDateSuspended.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Events;
|
||||
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
|
||||
class EventDateSuspended
|
||||
{
|
||||
use Dispatchable;
|
||||
|
||||
/**
|
||||
* @param list<array{purchase_id: int, ticket_ids: list<int>}> $purchaseTickets
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly string $tenantCode,
|
||||
public readonly int $eventDateId,
|
||||
public readonly string $date,
|
||||
public readonly array $purchaseTickets,
|
||||
) {}
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Domains\Event\Models;
|
||||
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Event\Enums\EventDateStatus;
|
||||
use App\Domains\Event\Services\EffectiveEventDateResolver;
|
||||
use App\Domains\Event\Services\EventDateTextFormatter;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Enums\ValidityTimeType;
|
||||
@@ -21,6 +23,8 @@ use Illuminate\Support\Carbon;
|
||||
'date',
|
||||
'time_start',
|
||||
'time_end',
|
||||
'rescheduled_to_event_date_id',
|
||||
'suspended_at',
|
||||
])]
|
||||
class EventDate extends Model
|
||||
{
|
||||
@@ -28,6 +32,8 @@ class EventDate extends Model
|
||||
|
||||
public $timestamps = false;
|
||||
|
||||
protected $appends = ['status'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
|
||||
@@ -52,6 +58,8 @@ class EventDate extends Model
|
||||
return [
|
||||
'date' => 'date:Y-m-d',
|
||||
'validity_time_id' => 'integer',
|
||||
'rescheduled_to_event_date_id' => 'integer',
|
||||
'suspended_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -67,6 +75,35 @@ class EventDate extends Model
|
||||
return $this->belongsTo(ValidityTime::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<EventDate, $this> */
|
||||
public function rescheduledTo(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(self::class, 'rescheduled_to_event_date_id');
|
||||
}
|
||||
|
||||
public function effectiveDate(): ?self
|
||||
{
|
||||
return app(EffectiveEventDateResolver::class)->resolve($this);
|
||||
}
|
||||
|
||||
/** @return HasMany<EventDate, $this> */
|
||||
public function rescheduledFrom(): HasMany
|
||||
{
|
||||
return $this->hasMany(self::class, 'rescheduled_to_event_date_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<EventDateChange, $this> */
|
||||
public function changeHistory(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventDateChange::class, 'source_event_date_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<EventDateChange, $this> */
|
||||
public function destinationChangeHistory(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventDateChange::class, 'destination_event_date_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<Variant, $this> */
|
||||
public function variants(): HasMany
|
||||
{
|
||||
@@ -91,7 +128,32 @@ class EventDate extends Model
|
||||
|
||||
public function endsAt(): CarbonInterface
|
||||
{
|
||||
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
|
||||
$endsAt = Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
|
||||
|
||||
return $endsAt->lessThanOrEqualTo($this->startsAt())
|
||||
? $endsAt->addDay()
|
||||
: $endsAt;
|
||||
}
|
||||
|
||||
public function getStatusAttribute(): EventDateStatus
|
||||
{
|
||||
if ($this->rescheduled_to_event_date_id !== null) {
|
||||
return EventDateStatus::Rescheduled;
|
||||
}
|
||||
|
||||
if ($this->suspended_at !== null) {
|
||||
return EventDateStatus::Suspended;
|
||||
}
|
||||
|
||||
if (now()->lt($this->startsAt())) {
|
||||
return EventDateStatus::Scheduled;
|
||||
}
|
||||
|
||||
if (now()->lt($this->endsAt())) {
|
||||
return EventDateStatus::InProgress;
|
||||
}
|
||||
|
||||
return EventDateStatus::Completed;
|
||||
}
|
||||
|
||||
private function syncTenantDateText(): void
|
||||
@@ -104,7 +166,10 @@ class EventDate extends Model
|
||||
|
||||
$tenant->update([
|
||||
'event_date_text' => app(EventDateTextFormatter::class)->format(
|
||||
$tenant->eventDates()->pluck('date')
|
||||
$tenant->eventDates()
|
||||
->whereNull('rescheduled_to_event_date_id')
|
||||
->whereNull('suspended_at')
|
||||
->pluck('date')
|
||||
),
|
||||
]);
|
||||
}
|
||||
@@ -114,10 +179,6 @@ class EventDate extends Model
|
||||
$startsAt = $this->startsAt();
|
||||
$expiresAt = $this->endsAt();
|
||||
|
||||
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
|
||||
$expiresAt = $expiresAt->addDay();
|
||||
}
|
||||
|
||||
$attributes = [
|
||||
'type' => ValidityTimeType::FixedWindow,
|
||||
'start_time' => null,
|
||||
|
||||
68
app/Domains/Event/Models/EventDateChange.php
Normal file
68
app/Domains/Event/Models/EventDateChange.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'change_type',
|
||||
'source_event_date_id',
|
||||
'destination_event_date_id',
|
||||
'created_by_user_id',
|
||||
'previous_date',
|
||||
'new_date',
|
||||
])]
|
||||
class EventDateChange extends Model
|
||||
{
|
||||
public $timestamps = false;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'change_type' => EventDateChangeType::class,
|
||||
'source_event_date_id' => 'integer',
|
||||
'destination_event_date_id' => 'integer',
|
||||
'created_by_user_id' => 'integer',
|
||||
'previous_date' => 'date:Y-m-d',
|
||||
'new_date' => 'date:Y-m-d',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<EventDate, $this> */
|
||||
public function sourceEventDate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EventDate::class, 'source_event_date_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<EventDate, $this> */
|
||||
public function destinationEventDate(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EventDate::class, 'destination_event_date_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function createdBy(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by_user_id')->withTrashed();
|
||||
}
|
||||
|
||||
/** @return HasMany<EventDateChangeView, $this> */
|
||||
public function views(): HasMany
|
||||
{
|
||||
return $this->hasMany(EventDateChangeView::class);
|
||||
}
|
||||
}
|
||||
41
app/Domains/Event/Models/EventDateChangeView.php
Normal file
41
app/Domains/Event/Models/EventDateChangeView.php
Normal file
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'user_id',
|
||||
'event_date_change_id',
|
||||
'display_count',
|
||||
'last_displayed_at',
|
||||
])]
|
||||
class EventDateChangeView extends Model
|
||||
{
|
||||
protected $table = 'user_event_date_change_views';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'event_date_change_id' => 'integer',
|
||||
'display_count' => 'integer',
|
||||
'last_displayed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<EventDateChange, $this> */
|
||||
public function eventDateChange(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(EventDateChange::class);
|
||||
}
|
||||
}
|
||||
21
app/Domains/Event/Requests/RescheduleEventDateRequest.php
Normal file
21
app/Domains/Event/Requests/RescheduleEventDateRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class RescheduleEventDateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'date' => ['required', 'date_format:Y-m-d'],
|
||||
];
|
||||
}
|
||||
}
|
||||
23
app/Domains/Event/Requests/StoreEventDateRequest.php
Normal file
23
app/Domains/Event/Requests/StoreEventDateRequest.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreEventDateRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'date' => ['required', 'date_format:Y-m-d'],
|
||||
'start_time' => ['required', 'date_format:H:i'],
|
||||
'end_time' => ['required', 'date_format:H:i'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -19,11 +19,6 @@ class UpdateEventRequest extends FormRequest
|
||||
return [
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'location' => ['required', 'string', 'max:255'],
|
||||
'dates' => ['required', 'array', 'min:1'],
|
||||
'dates.*' => ['required', 'array:date,start_time,end_time'],
|
||||
'dates.*.date' => ['required', 'date_format:Y-m-d', 'distinct'],
|
||||
'dates.*.start_time' => ['required', 'date_format:H:i'],
|
||||
'dates.*.end_time' => ['required', 'date_format:H:i'],
|
||||
'social_media' => ['sometimes', 'array'],
|
||||
'social_media.*' => ['required', 'array:code,url,orden'],
|
||||
'social_media.*.code' => [
|
||||
@@ -38,6 +33,16 @@ class UpdateEventRequest extends FormRequest
|
||||
'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'],
|
||||
'contact.instagram_url' => ['nullable', 'url', 'max:2048'],
|
||||
'contact.facebook_url' => ['nullable', 'url', 'max:2048'],
|
||||
'allow_ticket_refund' => ['sometimes', 'boolean'],
|
||||
'allow_ticket_total_refund' => ['sometimes', 'boolean'],
|
||||
'allow_ticket_partial_refund' => ['sometimes', 'boolean'],
|
||||
'ticket_partial_refund_percentage' => [
|
||||
'sometimes',
|
||||
'numeric',
|
||||
'decimal:0,2',
|
||||
'min:0',
|
||||
'max:99.99',
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -54,6 +59,41 @@ class UpdateEventRequest extends FormRequest
|
||||
'The social media field is required.'
|
||||
);
|
||||
}
|
||||
|
||||
if (! array_key_exists('allow_ticket_refund', $input)) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ([
|
||||
'allow_ticket_total_refund',
|
||||
'allow_ticket_partial_refund',
|
||||
'ticket_partial_refund_percentage',
|
||||
] as $field) {
|
||||
if (! array_key_exists($field, $input)) {
|
||||
$validator->errors()->add($field, 'El campo es obligatorio.');
|
||||
}
|
||||
}
|
||||
|
||||
$totalEnabled = $this->boolean('allow_ticket_total_refund');
|
||||
$partialEnabled = $this->boolean('allow_ticket_partial_refund');
|
||||
|
||||
$refundEnabled = $this->boolean('allow_ticket_refund');
|
||||
|
||||
if ($refundEnabled && ! $totalEnabled && ! $partialEnabled) {
|
||||
$validator->errors()->add(
|
||||
'allow_ticket_refund',
|
||||
'Seleccioná al menos un tipo de reembolso.'
|
||||
);
|
||||
}
|
||||
|
||||
if ($refundEnabled
|
||||
&& $partialEnabled
|
||||
&& (float) ($input['ticket_partial_refund_percentage'] ?? 0) <= 0) {
|
||||
$validator->errors()->add(
|
||||
'ticket_partial_refund_percentage',
|
||||
'Ingresá un porcentaje mayor que cero para el reembolso parcial.'
|
||||
);
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
24
app/Domains/Event/Resources/EventDateChangeResource.php
Normal file
24
app/Domains/Event/Resources/EventDateChangeResource.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Resources;
|
||||
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin EventDateChange */
|
||||
class EventDateChangeResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->change_type->value,
|
||||
'source_event_date_id' => $this->source_event_date_id,
|
||||
'destination_event_date_id' => $this->destination_event_date_id,
|
||||
'previous_date' => $this->previous_date->format('Y-m-d'),
|
||||
'new_date' => $this->new_date?->format('Y-m-d'),
|
||||
'occurred_at' => $this->created_at->toISOString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
20
app/Domains/Event/Resources/EventDateNoticeResource.php
Normal file
20
app/Domains/Event/Resources/EventDateNoticeResource.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EventDateNoticeResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'type' => $this->resource['type'],
|
||||
'change_ids' => $this->resource['change_ids'],
|
||||
'title' => $this->resource['title'],
|
||||
'message' => $this->resource['message'],
|
||||
];
|
||||
}
|
||||
}
|
||||
31
app/Domains/Event/Resources/EventDateResource.php
Normal file
31
app/Domains/Event/Resources/EventDateResource.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Resources;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin EventDate */
|
||||
class EventDateResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'validity_time_id' => $this->validity_time_id,
|
||||
'validity_time' => ValidityTimeResource::make($this->whenLoaded('validityTime')),
|
||||
'date' => $this->date->format('Y-m-d'),
|
||||
'start_time' => substr($this->time_start, 0, 5),
|
||||
'end_time' => substr($this->time_end, 0, 5),
|
||||
'status' => $this->status->value,
|
||||
'rescheduled_to_event_date_id' => $this->rescheduled_to_event_date_id,
|
||||
'suspended_at' => $this->suspended_at?->toISOString(),
|
||||
'rescheduled_dates' => EventDateResource::collection(
|
||||
$this->whenLoaded('adminRescheduledDates')
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
namespace App\Domains\Event\Resources;
|
||||
|
||||
use App\Domains\Event\Services\EventDateGroupingService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -19,14 +19,13 @@ class EventResource extends JsonResource
|
||||
'id' => $this->id,
|
||||
'title' => $this->event_title,
|
||||
'location' => $this->event_location,
|
||||
'dates' => $this->eventDates->map(fn ($eventDate): array => [
|
||||
'id' => $eventDate->id,
|
||||
'validity_time_id' => $eventDate->validity_time_id,
|
||||
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
|
||||
'date' => $eventDate->date->format('Y-m-d'),
|
||||
'start_time' => substr($eventDate->time_start, 0, 5),
|
||||
'end_time' => substr($eventDate->time_end, 0, 5),
|
||||
])->values(),
|
||||
'allow_ticket_refund' => $this->allow_ticket_refund,
|
||||
'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
|
||||
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
|
||||
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
|
||||
'dates' => EventDateResource::collection(
|
||||
app(EventDateGroupingService::class)->group($this->eventDates)
|
||||
),
|
||||
'social_media' => $this->socialMedia->map(fn ($item): array => [
|
||||
'code' => $item->code,
|
||||
'url' => $item->pivot->url,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticket\Services\TicketValidityResolver;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class AffectedEventDatePurchaseResolver
|
||||
{
|
||||
/**
|
||||
* Finds active tickets belonging to paid purchases before an event-date mutation.
|
||||
*
|
||||
* @param Collection<int, int>|list<int> $eventDateIds
|
||||
* @return list<array{purchase_id: int, ticket_ids: list<int>}>
|
||||
*/
|
||||
public function resolve(Tenant $tenant, Collection|array $eventDateIds): array
|
||||
{
|
||||
$eventDateIds = collect($eventDateIds)->map(fn (mixed $id): int => (int) $id)->unique()->values();
|
||||
|
||||
if ($eventDateIds->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
/** @var Collection<int, Ticket> $tickets */
|
||||
$tickets = Ticket::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereHas('sourcePurchaseItem.purchase', fn (Builder $query) => $query
|
||||
->where('status', Purchase::STATUS_PAID))
|
||||
->whereHas('sourceVariant', function (Builder $query) use ($eventDateIds): void {
|
||||
$query->whereIn('event_date_id', $eventDateIds)
|
||||
->orWhereHas('eventDates', fn (Builder $eventDates) => $eventDates
|
||||
->whereIn('event_dates.id', $eventDateIds));
|
||||
})
|
||||
->with([
|
||||
...TicketValidityResolver::RELATIONS,
|
||||
'sourcePurchaseItem.purchase',
|
||||
])
|
||||
->get()
|
||||
->filter(fn (Ticket $ticket): bool => $ticket->is_active())
|
||||
->values();
|
||||
|
||||
return $tickets
|
||||
->groupBy(fn (Ticket $ticket): int => (int) $ticket->sourcePurchaseItem->purchase->getKey())
|
||||
->map(function (Collection $purchaseTickets): array {
|
||||
return [
|
||||
'purchase_id' => (int) $purchaseTickets->first()->sourcePurchaseItem->purchase->getKey(),
|
||||
'ticket_ids' => $purchaseTickets->modelKeys(),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
45
app/Domains/Event/Services/EffectiveEventDateResolver.php
Normal file
45
app/Domains/Event/Services/EffectiveEventDateResolver.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
|
||||
class EffectiveEventDateResolver
|
||||
{
|
||||
public function resolve(EventDate $eventDate): ?EventDate
|
||||
{
|
||||
$date = $this->resolveLatest($eventDate);
|
||||
|
||||
return $date !== null && $date->suspended_at === null ? $date : null;
|
||||
}
|
||||
|
||||
/** Sigue las reprogramaciones para presentación, incluso si el destino está suspendido. */
|
||||
public function resolveLatest(EventDate $eventDate): ?EventDate
|
||||
{
|
||||
$current = $eventDate;
|
||||
$visited = [];
|
||||
|
||||
while (true) {
|
||||
$identity = $current->getKey() === null
|
||||
? 'object:'.spl_object_id($current)
|
||||
: 'key:'.$current->getKey();
|
||||
|
||||
if (isset($visited[$identity])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$visited[$identity] = true;
|
||||
|
||||
if ($current->rescheduled_to_event_date_id === null) {
|
||||
return $current;
|
||||
}
|
||||
|
||||
$current->loadMissing('rescheduledTo');
|
||||
$current = $current->rescheduledTo;
|
||||
|
||||
if ($current === null) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
101
app/Domains/Event/Services/EventDateGroupingService.php
Normal file
101
app/Domains/Event/Services/EventDateGroupingService.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventDateGroupingService
|
||||
{
|
||||
/**
|
||||
* Groups every historical date under its final active destination.
|
||||
*
|
||||
* @param Collection<int, EventDate> $dates
|
||||
* @return Collection<int, EventDate>
|
||||
*/
|
||||
public function group(Collection $dates): Collection
|
||||
{
|
||||
$byId = $dates->keyBy(fn (EventDate $date): int => (int) $date->getKey());
|
||||
$groups = collect();
|
||||
|
||||
foreach ($dates as $date) {
|
||||
$destination = $this->finalDestination($date, $byId);
|
||||
$key = (int) $destination->getKey();
|
||||
|
||||
if (! $groups->has($key)) {
|
||||
$groups->put($key, [
|
||||
'destination' => $destination,
|
||||
'rescheduled' => collect(),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $date->is($destination)) {
|
||||
$group = $groups->get($key);
|
||||
$historicalDate = clone $date;
|
||||
$historicalDate->setAttribute(
|
||||
'rescheduled_to_event_date_id',
|
||||
$destination->getKey(),
|
||||
);
|
||||
$group['rescheduled']->push($historicalDate);
|
||||
$groups->put($key, $group);
|
||||
}
|
||||
}
|
||||
|
||||
return $groups
|
||||
->map(function (array $group): EventDate {
|
||||
/** @var EventDate $destination */
|
||||
$destination = clone $group['destination'];
|
||||
/** @var Collection<int, EventDate> $rescheduled */
|
||||
$rescheduled = $group['rescheduled'];
|
||||
$destination->setRelation(
|
||||
'adminRescheduledDates',
|
||||
new EloquentCollection($rescheduled->sort($this->dateSorter())->values()->all()),
|
||||
);
|
||||
|
||||
return $destination;
|
||||
})
|
||||
->sort($this->dateSorter())
|
||||
->values();
|
||||
}
|
||||
|
||||
/** @param Collection<int, EventDate> $byId */
|
||||
private function finalDestination(EventDate $date, Collection $byId): EventDate
|
||||
{
|
||||
$current = $date;
|
||||
$visited = collect();
|
||||
|
||||
while ($current->rescheduled_to_event_date_id !== null) {
|
||||
$currentId = (int) $current->getKey();
|
||||
|
||||
if ($visited->contains($currentId)) {
|
||||
break;
|
||||
}
|
||||
|
||||
$visited->push($currentId);
|
||||
$destination = $byId->get((int) $current->rescheduled_to_event_date_id);
|
||||
|
||||
if (! $destination instanceof EventDate) {
|
||||
break;
|
||||
}
|
||||
|
||||
$current = $destination;
|
||||
}
|
||||
|
||||
return $current;
|
||||
}
|
||||
|
||||
/** @return callable(EventDate, EventDate): int */
|
||||
private function dateSorter(): callable
|
||||
{
|
||||
return fn (EventDate $left, EventDate $right): int => [
|
||||
$left->date->format('Y-m-d'),
|
||||
$left->time_start,
|
||||
$left->getKey(),
|
||||
] <=> [
|
||||
$right->date->format('Y-m-d'),
|
||||
$right->time_start,
|
||||
$right->getKey(),
|
||||
];
|
||||
}
|
||||
}
|
||||
76
app/Domains/Event/Services/EventDateInfoFormatter.php
Normal file
76
app/Domains/Event/Services/EventDateInfoFormatter.php
Normal file
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventDateInfoFormatter
|
||||
{
|
||||
/** @param Collection<int, EventDateChange> $changes */
|
||||
public function format(EventDate $eventDate, Collection $changes): ?string
|
||||
{
|
||||
$messages = collect();
|
||||
|
||||
if ($eventDate->suspended_at !== null) {
|
||||
$messages->push('Esta fecha fue cancelada.');
|
||||
}
|
||||
|
||||
$sourceDates = $this->reschedulesEndingAt($eventDate, $changes)
|
||||
->pluck('previous_date')
|
||||
->filter()
|
||||
->map(fn ($date): string => $date->format('d/m/Y'))
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($sourceDates->isNotEmpty()) {
|
||||
$verb = $sourceDates->count() === 1 ? 'se reprogramó' : 'se reprogramaron';
|
||||
$messages->push("{$sourceDates->join(', ', ' y ')} {$verb} para este día.");
|
||||
}
|
||||
|
||||
return $messages->isEmpty() ? null : $messages->join(' ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Includes direct and intermediate reschedules that ultimately end at the
|
||||
* displayed event date, while preserving the original change order.
|
||||
*
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return Collection<int, EventDateChange>
|
||||
*/
|
||||
private function reschedulesEndingAt(EventDate $eventDate, Collection $changes): Collection
|
||||
{
|
||||
$eventDateId = $eventDate->getKey();
|
||||
$reschedules = $changes->where('change_type', EventDateChangeType::Rescheduled);
|
||||
|
||||
if ($eventDateId === null) {
|
||||
return $reschedules->where('destination_event_date_id', null);
|
||||
}
|
||||
|
||||
$destinationIds = [(int) $eventDateId => true];
|
||||
|
||||
do {
|
||||
$foundAncestor = false;
|
||||
|
||||
foreach ($reschedules as $change) {
|
||||
$destinationId = $change->destination_event_date_id;
|
||||
$sourceId = $change->source_event_date_id;
|
||||
|
||||
if ($destinationId === null || $sourceId === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($destinationIds[(int) $destinationId]) && ! isset($destinationIds[(int) $sourceId])) {
|
||||
$destinationIds[(int) $sourceId] = true;
|
||||
$foundAncestor = true;
|
||||
}
|
||||
}
|
||||
} while ($foundAncestor);
|
||||
|
||||
return $reschedules
|
||||
->filter(fn (EventDateChange $change): bool => $change->destination_event_date_id !== null
|
||||
&& isset($destinationIds[(int) $change->destination_event_date_id]));
|
||||
}
|
||||
}
|
||||
126
app/Domains/Event/Services/EventDateNoticeFormatter.php
Normal file
126
app/Domains/Event/Services/EventDateNoticeFormatter.php
Normal file
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class EventDateNoticeFormatter
|
||||
{
|
||||
public function __construct(private readonly EventDateTextFormatter $dateTextFormatter) {}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return list<array{
|
||||
* type: string,
|
||||
* change_ids: list<int>,
|
||||
* title: string,
|
||||
* message: list<array{text: string, bold: bool}>
|
||||
* }>
|
||||
*/
|
||||
public function format(Collection $changes): array
|
||||
{
|
||||
return collect([
|
||||
$this->suspensionNotice(
|
||||
$changes->where('change_type', EventDateChangeType::Suspended)
|
||||
),
|
||||
$this->rescheduleNotice(
|
||||
$changes->where('change_type', EventDateChangeType::Rescheduled)
|
||||
),
|
||||
])->filter()->values()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
|
||||
*/
|
||||
private function suspensionNotice(Collection $changes): ?array
|
||||
{
|
||||
$dates = $this->formatDates($changes, 'previous_date');
|
||||
|
||||
if ($dates === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plural = $changes->count() > 1;
|
||||
|
||||
return [
|
||||
'type' => EventDateChangeType::Suspended->value,
|
||||
'change_ids' => $this->changeIds($changes),
|
||||
'title' => $plural ? 'FECHAS CANCELADAS!' : 'FECHA CANCELADA!',
|
||||
'message' => [
|
||||
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
|
||||
['text' => $dates, 'bold' => true],
|
||||
['text' => $plural ? ' han sido canceladas.' : ' ha sido cancelada.', 'bold' => false],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return array{type: string, change_ids: list<int>, title: string, message: list<array{text: string, bold: bool}>}|null
|
||||
*/
|
||||
private function rescheduleNotice(Collection $changes): ?array
|
||||
{
|
||||
$changes = $changes->whereNotNull('new_date');
|
||||
$sourceDates = $this->formatDates($changes, 'previous_date');
|
||||
$destinationDates = $this->formatDates($changes, 'new_date');
|
||||
|
||||
if ($sourceDates === null || $destinationDates === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$plural = $changes->count() > 1;
|
||||
$message = [
|
||||
['text' => $plural ? 'Las fechas del ' : 'La fecha del ', 'bold' => false],
|
||||
['text' => $sourceDates, 'bold' => true],
|
||||
[
|
||||
'text' => $plural ? ' han sido reprogramadas para el ' : ' ha sido reprogramada para el ',
|
||||
'bold' => false,
|
||||
],
|
||||
['text' => $destinationDates, 'bold' => true],
|
||||
];
|
||||
|
||||
if ($plural) {
|
||||
$message[] = ['text' => ', ', 'bold' => false];
|
||||
$message[] = ['text' => 'respectivamente', 'bold' => true];
|
||||
}
|
||||
|
||||
$message[] = ['text' => '.', 'bold' => false];
|
||||
|
||||
return [
|
||||
'type' => EventDateChangeType::Rescheduled->value,
|
||||
'change_ids' => $this->changeIds($changes),
|
||||
'title' => $plural ? 'FECHAS REPROGRAMADAS!' : 'FECHA REPROGRAMADA!',
|
||||
'message' => $message,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
*/
|
||||
private function formatDates(Collection $changes, string $attribute): ?string
|
||||
{
|
||||
return $this->dateTextFormatter->formatForSentence(
|
||||
$changes
|
||||
->pluck($attribute)
|
||||
->filter()
|
||||
->map(fn ($date): string => $date->format('Y-m-d'))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, EventDateChange> $changes
|
||||
* @return list<int>
|
||||
*/
|
||||
private function changeIds(Collection $changes): array
|
||||
{
|
||||
return $changes
|
||||
->pluck('id')
|
||||
->filter(fn ($id): bool => $id !== null)
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
60
app/Domains/Event/Services/EventDateNoticeService.php
Normal file
60
app/Domains/Event/Services/EventDateNoticeService.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use App\Domains\Event\Models\EventDateChangeView;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EventDateNoticeService
|
||||
{
|
||||
public const MAX_DISPLAYS = 3;
|
||||
|
||||
public function __construct(private readonly EventDateNoticeFormatter $formatter) {}
|
||||
|
||||
/**
|
||||
* Claims one display of every pending change and returns them grouped by type.
|
||||
*
|
||||
* @return list<array{
|
||||
* type: string,
|
||||
* change_ids: list<int>,
|
||||
* title: string,
|
||||
* message: list<array{text: string, bold: bool}>
|
||||
* }>
|
||||
*/
|
||||
public function claimFor(User $user, Tenant $tenant): array
|
||||
{
|
||||
return DB::transaction(function () use ($user, $tenant): array {
|
||||
$lockedUser = User::query()->whereKey($user->getKey())->lockForUpdate()->firstOrFail();
|
||||
|
||||
$changes = EventDateChange::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereDoesntHave('views', fn ($query) => $query
|
||||
->where('user_id', $lockedUser->getKey())
|
||||
->where('display_count', '>=', self::MAX_DISPLAYS))
|
||||
->orderBy('created_at')
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$notices = $this->formatter->format($changes);
|
||||
$claimedChangeIds = collect($notices)->pluck('change_ids')->flatten()->unique();
|
||||
|
||||
foreach ($claimedChangeIds as $changeId) {
|
||||
$view = EventDateChangeView::query()->firstOrNew([
|
||||
'user_id' => $lockedUser->getKey(),
|
||||
'event_date_change_id' => $changeId,
|
||||
]);
|
||||
$view->display_count = min(
|
||||
self::MAX_DISPLAYS,
|
||||
((int) $view->display_count) + 1,
|
||||
);
|
||||
$view->last_displayed_at = now();
|
||||
$view->save();
|
||||
}
|
||||
|
||||
return $notices;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@ class EventDateTextFormatter
|
||||
/** @param iterable<string> $dates */
|
||||
public function format(iterable $dates): ?string
|
||||
{
|
||||
return $this->formatWithOptions($dates, false, false);
|
||||
}
|
||||
|
||||
/** @param iterable<string> $dates */
|
||||
public function formatForSentence(iterable $dates): ?string
|
||||
{
|
||||
return $this->formatWithOptions($dates, true, true);
|
||||
}
|
||||
|
||||
/** @param iterable<string> $dates */
|
||||
private function formatWithOptions(
|
||||
iterable $dates,
|
||||
bool $padDays,
|
||||
bool $includeYearPreposition,
|
||||
): ?string {
|
||||
$normalizedDates = collect($dates)
|
||||
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
|
||||
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
|
||||
@@ -37,12 +52,14 @@ class EventDateTextFormatter
|
||||
|
||||
$years = $normalizedDates
|
||||
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
|
||||
->map(function ($yearDates, string $year): string {
|
||||
->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string {
|
||||
$months = $yearDates
|
||||
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
|
||||
->map(function ($monthDates, string $month): string {
|
||||
->map(function ($monthDates, string $month) use ($padDays): string {
|
||||
$days = $monthDates
|
||||
->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j')))
|
||||
->map(fn (DateTimeImmutable $date): string => $padDays
|
||||
? $date->format('d')
|
||||
: (string) ((int) $date->format('j')))
|
||||
->values()
|
||||
->all();
|
||||
|
||||
@@ -51,7 +68,7 @@ class EventDateTextFormatter
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $this->join($months).' '.$year;
|
||||
return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year;
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
|
||||
@@ -2,7 +2,19 @@
|
||||
|
||||
namespace App\Domains\Event\Services;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Cart\Services\InvalidateEventDateCartsService;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Catalog\Services\VariantReplacementService;
|
||||
use App\Domains\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Event\Events\EventDateRescheduled;
|
||||
use App\Domains\Event\Events\EventDateSuspended;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
@@ -14,6 +26,13 @@ class EventService
|
||||
'facebook_url' => 'facebook',
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
private readonly EffectiveEventDateResolver $effectiveEventDateResolver,
|
||||
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
|
||||
private readonly VariantReplacementService $variantReplacementService,
|
||||
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
|
||||
) {}
|
||||
|
||||
public function forTenant(Tenant $tenant): Tenant
|
||||
{
|
||||
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
|
||||
@@ -27,9 +46,14 @@ class EventService
|
||||
$tenant->update([
|
||||
'event_title' => $data['title'],
|
||||
'event_location' => $data['location'],
|
||||
...array_intersect_key($data, array_flip([
|
||||
'allow_ticket_refund',
|
||||
'allow_ticket_total_refund',
|
||||
'allow_ticket_partial_refund',
|
||||
'ticket_partial_refund_percentage',
|
||||
])),
|
||||
]);
|
||||
|
||||
$this->syncDates($tenant, $data['dates']);
|
||||
if (array_key_exists('social_media', $data)) {
|
||||
$this->syncSocialMedia($tenant, $data['social_media']);
|
||||
} else {
|
||||
@@ -40,41 +64,252 @@ class EventService
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
|
||||
private function syncDates(Tenant $tenant, array $dates): void
|
||||
/** @param array{date: string, start_time: string, end_time: string} $data */
|
||||
public function createDateForTenant(Tenant $tenant, array $data): EventDate
|
||||
{
|
||||
$existingDates = $tenant->eventDates()->get()->values();
|
||||
return DB::transaction(function () use ($tenant, $data): EventDate {
|
||||
$attributes = $this->dateAttributes($data);
|
||||
|
||||
foreach (array_values($dates) as $index => $date) {
|
||||
$attributes = [
|
||||
'date' => $date['date'],
|
||||
'time_start' => $date['start_time'],
|
||||
'time_end' => $date['end_time'],
|
||||
];
|
||||
if ($tenant->eventDates()->where($attributes)->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'date' => ['La fecha y el horario ya existen.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$existingDate = $existingDates->get($index);
|
||||
return $tenant->eventDates()->create($attributes)->load('validityTime');
|
||||
});
|
||||
}
|
||||
|
||||
if ($existingDate) {
|
||||
$existingDate->update($attributes);
|
||||
} else {
|
||||
$tenant->eventDates()->create($attributes);
|
||||
/** @param array{date: string} $data */
|
||||
public function rescheduleDateForTenant(
|
||||
Tenant $tenant,
|
||||
EventDate $eventDate,
|
||||
array $data,
|
||||
?User $createdBy = null,
|
||||
): EventDate {
|
||||
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
|
||||
$source = $this->lockedDateForTenant($tenant, $eventDate);
|
||||
|
||||
if ($source->suspended_at !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'event_date' => ['No se puede reprogramar una fecha suspendida.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($source->rescheduled_to_event_date_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'event_date' => ['La fecha ya fue reprogramada.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$destination = $tenant->eventDates()
|
||||
->whereDate('date', $data['date'])
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($destination === null) {
|
||||
$destination = $tenant->eventDates()->create([
|
||||
'date' => $data['date'],
|
||||
'time_start' => $source->time_start,
|
||||
'time_end' => $source->time_end,
|
||||
]);
|
||||
}
|
||||
|
||||
if ($destination->is($source) || $this->chainContains($destination, $source)) {
|
||||
throw ValidationException::withMessages([
|
||||
'date' => ['La reprogramación generaría una referencia circular.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$effectiveDestination = $this->effectiveEventDateResolver->resolve($destination);
|
||||
if ($effectiveDestination === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'date' => ['La fecha de destino no es utilizable.'],
|
||||
]);
|
||||
}
|
||||
|
||||
$affectedDateIds = $this->affectedDateIds($tenant, $source);
|
||||
$this->invalidateEventDateCarts->invalidate($tenant, $affectedDateIds);
|
||||
$purchaseTickets = $this->affectedPurchaseResolver->resolve(
|
||||
$tenant,
|
||||
$affectedDateIds,
|
||||
);
|
||||
$source->update(['rescheduled_to_event_date_id' => $destination->getKey()]);
|
||||
$this->variantReplacementService->replaceEventDate($source, $effectiveDestination);
|
||||
|
||||
EventDateChange::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'change_type' => EventDateChangeType::Rescheduled,
|
||||
'source_event_date_id' => $source->getKey(),
|
||||
'destination_event_date_id' => $destination->getKey(),
|
||||
'created_by_user_id' => $createdBy?->getKey(),
|
||||
'previous_date' => $source->date->format('Y-m-d'),
|
||||
'new_date' => $destination->date->format('Y-m-d'),
|
||||
]);
|
||||
|
||||
EventDateRescheduled::dispatch(
|
||||
$tenant->codigo,
|
||||
$source->getKey(),
|
||||
$destination->getKey(),
|
||||
$source->date->format('d/m/Y'),
|
||||
$destination->date->format('d/m/Y'),
|
||||
$purchaseTickets,
|
||||
);
|
||||
|
||||
return $source->fresh(['validityTime', 'rescheduledTo.validityTime']);
|
||||
});
|
||||
}
|
||||
|
||||
public function suspendDateForTenant(
|
||||
Tenant $tenant,
|
||||
EventDate $eventDate,
|
||||
?User $createdBy = null,
|
||||
): EventDate {
|
||||
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
|
||||
$date = $this->lockedDateForTenant($tenant, $eventDate);
|
||||
|
||||
if ($date->rescheduled_to_event_date_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'event_date' => ['No se puede suspender una fecha que ya fue reprogramada.'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($date->suspended_at !== null) {
|
||||
return $date->load('validityTime');
|
||||
}
|
||||
|
||||
$affectedDateIds = $this->affectedDateIds($tenant, $date);
|
||||
$this->invalidateEventDateCarts->invalidate(
|
||||
$tenant,
|
||||
$affectedDateIds,
|
||||
StockReservationService::REASON_EVENT_DATE_SUSPENDED,
|
||||
);
|
||||
$purchaseTickets = $this->affectedPurchaseResolver->resolve($tenant, $affectedDateIds);
|
||||
$date->update(['suspended_at' => now()]);
|
||||
$this->variantReplacementService->disableForSuspension($date);
|
||||
$this->disableTicketsWithoutUsableDates($tenant, $date);
|
||||
|
||||
EventDateChange::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'change_type' => EventDateChangeType::Suspended,
|
||||
'source_event_date_id' => $date->getKey(),
|
||||
'destination_event_date_id' => null,
|
||||
'created_by_user_id' => $createdBy?->getKey(),
|
||||
'previous_date' => $date->date->format('Y-m-d'),
|
||||
'new_date' => null,
|
||||
]);
|
||||
|
||||
EventDateSuspended::dispatch(
|
||||
$tenant->codigo,
|
||||
$date->getKey(),
|
||||
$date->date->format('d/m/Y'),
|
||||
$purchaseTickets,
|
||||
);
|
||||
|
||||
return $date->fresh('validityTime');
|
||||
});
|
||||
}
|
||||
|
||||
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
|
||||
{
|
||||
return $tenant->eventDates()
|
||||
->whereKey($eventDate->getKey())
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
private function chainContains(EventDate $start, EventDate $expected): bool
|
||||
{
|
||||
$current = $start;
|
||||
$visited = [];
|
||||
|
||||
while ($current->rescheduled_to_event_date_id !== null) {
|
||||
if ($current->is($expected)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($visited[$current->getKey()])) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$visited[$current->getKey()] = true;
|
||||
$current = $current->rescheduledTo()->lockForUpdate()->first();
|
||||
|
||||
if ($current === null) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$datesToDelete = $existingDates->slice(count($dates));
|
||||
return $current->is($expected);
|
||||
}
|
||||
|
||||
if ($datesToDelete->contains(fn ($eventDate): bool => $eventDate
|
||||
->selectedByVariants()
|
||||
->whereHas('sourceTickets')
|
||||
->exists()
|
||||
|| $eventDate->variants()->whereHas('sourceTickets')->exists())) {
|
||||
throw ValidationException::withMessages([
|
||||
'dates' => ['No se puede eliminar una fecha utilizada por tickets generados.'],
|
||||
]);
|
||||
/** @return Collection<int, int> */
|
||||
private function affectedDateIds(Tenant $tenant, EventDate $eventDate): Collection
|
||||
{
|
||||
$affectedDateIds = collect([$eventDate->getKey()]);
|
||||
$frontier = $affectedDateIds;
|
||||
|
||||
while ($frontier->isNotEmpty()) {
|
||||
$predecessors = $tenant->eventDates()
|
||||
->whereIn('rescheduled_to_event_date_id', $frontier)
|
||||
->pluck('id')
|
||||
->diff($affectedDateIds)
|
||||
->values();
|
||||
$affectedDateIds = $affectedDateIds->merge($predecessors)->unique()->values();
|
||||
$frontier = $predecessors;
|
||||
}
|
||||
|
||||
$datesToDelete->each->delete();
|
||||
$tenant->unsetRelation('eventDates');
|
||||
return $affectedDateIds;
|
||||
}
|
||||
|
||||
private function disableTicketsWithoutUsableDates(Tenant $tenant, EventDate $suspendedDate): void
|
||||
{
|
||||
$affectedDateIds = $this->affectedDateIds($tenant, $suspendedDate);
|
||||
|
||||
$variants = Variant::withTrashed()
|
||||
->where(function ($query) use ($affectedDateIds): void {
|
||||
$query->whereIn('event_date_id', $affectedDateIds)
|
||||
->orWhereHas('eventDates', fn ($eventDates) => $eventDates
|
||||
->whereIn('event_dates.id', $affectedDateIds));
|
||||
})
|
||||
->with(['eventDates', 'eventDate'])
|
||||
->get();
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$hasUsableDate = $variant->selectedEventDates()->contains(
|
||||
fn (EventDate $candidate): bool => $this->effectiveEventDateResolver->resolve($candidate) !== null
|
||||
);
|
||||
|
||||
if ($hasUsableDate) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Ticket::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('source_variant_id', $variant->getKey())
|
||||
->whereNull('disabled_at')
|
||||
->whereNull('cancelled_at')
|
||||
->whereNull('refunded_at')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->each(function (Ticket $ticket): void {
|
||||
$ticket->markAsDisabled();
|
||||
$ticket->save();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{date: string, start_time: string, end_time: string} $data
|
||||
* @return array{date: string, time_start: string, time_end: string}
|
||||
*/
|
||||
private function dateAttributes(array $data): array
|
||||
{
|
||||
return [
|
||||
'date' => $data['date'],
|
||||
'time_start' => $data['start_time'].':00',
|
||||
'time_end' => $data['end_time'].':00',
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, string|null> $contact */
|
||||
|
||||
@@ -8,6 +8,7 @@ Administra la configuración temporal de un tenant orientado a eventos y sus fec
|
||||
|
||||
- `Models/EventDate.php`: fecha del evento con inicio, fin, tenant y variantes asociadas.
|
||||
- `Services/EventService.php`: obtiene y actualiza la configuración de evento del tenant.
|
||||
- `Services/EventDateNoticeService.php`: reclama y agrupa los cambios pendientes de cada usuario.
|
||||
- `Controllers/AdminApp/EventController.php`: consulta y modificación desde AdminApp.
|
||||
- `UpdateEventRequest`: valida datos y reglas cruzadas de fechas.
|
||||
- `EventResource`: serializa la configuración de salida.
|
||||
@@ -19,10 +20,18 @@ Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tena
|
||||
- `GET`: obtiene la configuración.
|
||||
- `PUT`: actualiza la configuración.
|
||||
|
||||
Para el storefront autenticado:
|
||||
|
||||
- `POST /tenants/{tenant}/event-date-notices/claim`: devuelve hasta un aviso de suspensiones y otro de
|
||||
reprogramaciones. Cada cambio se muestra como máximo tres veces por usuario.
|
||||
|
||||
## Dependencias
|
||||
|
||||
Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets.
|
||||
|
||||
## Consideraciones
|
||||
|
||||
El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`.
|
||||
Los avisos se construyen dinámicamente después de excluir los cambios que el usuario ya vio
|
||||
tres veces. Al reclamar los avisos se incrementa una vez cada cambio incluido, aunque varios
|
||||
cambios aparezcan agrupados en el mismo mensaje. El reclamo bloquea al usuario durante la
|
||||
transacción para impedir que pestañas concurrentes superen el máximo.
|
||||
|
||||
@@ -8,4 +8,7 @@ Route::prefix('v1/adminapp/tenant')
|
||||
->group(function (): void {
|
||||
Route::get('event', [EventController::class, 'show']);
|
||||
Route::put('event', [EventController::class, 'update']);
|
||||
Route::post('event-dates', [EventController::class, 'storeDate']);
|
||||
Route::post('event-dates/{eventDate}/reschedule', [EventController::class, 'rescheduleDate']);
|
||||
Route::post('event-dates/{eventDate}/suspend', [EventController::class, 'suspendDate']);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Event\Controllers\EventDateNoticeController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
require __DIR__.'/adminapp.php';
|
||||
|
||||
Route::middleware('auth:sanctum')->post(
|
||||
'tenants/{tenant:codigo}/event-date-notices/claim',
|
||||
[EventDateNoticeController::class, 'claim'],
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\FiestaFutbolInfantil\Controllers;
|
||||
|
||||
use App\Domains\FiestaFutbolInfantil\Requests\UpdateHistoricalFoodStockRequest;
|
||||
use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest;
|
||||
use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
|
||||
use App\Domains\FiestaFutbolInfantil\Services\FoodService;
|
||||
@@ -32,6 +33,16 @@ class FoodController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function updateHistoricalStock(UpdateHistoricalFoodStockRequest $request): FoodResource
|
||||
{
|
||||
return FoodResource::make(
|
||||
$this->foodService->updateHistoricalStock(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated('variants'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $food): Response
|
||||
{
|
||||
$this->foodService->delete(
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\FiestaFutbolInfantil\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateHistoricalFoodStockRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||
'variants.*' => ['required', 'array:id,stock'],
|
||||
'variants.*.id' => ['required', 'integer', 'distinct'],
|
||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace App\Domains\FiestaFutbolInfantil\Resources;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/** @mixin CatalogItem */
|
||||
class EntryResource extends JsonResource
|
||||
@@ -12,7 +13,20 @@ class EntryResource extends JsonResource
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$variant = $this->variants->sole();
|
||||
$variants = $this->variants->whereNull('replaced_by_variant_id');
|
||||
|
||||
if ($variants->count() !== 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'entries' => [sprintf(
|
||||
'La entrada %s tiene %d variantes sin reemplazar (IDs: %s). Se esperaba una.',
|
||||
$this->id,
|
||||
$variants->count(),
|
||||
$variants->pluck('id')->implode(', '),
|
||||
)],
|
||||
]);
|
||||
}
|
||||
|
||||
$variant = $variants->first();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
|
||||
@@ -3,8 +3,14 @@
|
||||
namespace App\Domains\FiestaFutbolInfantil\Resources;
|
||||
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Event\Enums\EventDateStatus;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Event\Models\EventDateChange;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/** @mixin CatalogItem */
|
||||
class FoodResource extends JsonResource
|
||||
@@ -17,27 +23,134 @@ class FoodResource extends JsonResource
|
||||
'id' => null,
|
||||
'name' => 'Comida',
|
||||
'variants' => [],
|
||||
'history' => [],
|
||||
];
|
||||
}
|
||||
|
||||
$currentVariants = $this->variants
|
||||
->filter(fn (Variant $variant): bool => $this->isCurrent($variant));
|
||||
$historicalVariants = $this->variants
|
||||
->filter(fn (Variant $variant): bool => $this->isHistorical($variant));
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->nombre,
|
||||
'variants' => $this->variants->map(function ($variant): array {
|
||||
$values = $variant->selectionValues();
|
||||
$eventDate = $variant->selectedEventDates()->first();
|
||||
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'event_date_id' => $eventDate?->id,
|
||||
'event_date' => $eventDate?->date?->format('Y-m-d'),
|
||||
'schedule' => $values->get('horario'),
|
||||
'service' => $values->get('servicio'),
|
||||
'description' => $variant->descripcion,
|
||||
'stock' => $variant->inventory->real_stock,
|
||||
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
];
|
||||
})->values(),
|
||||
'variants' => $currentVariants->map($this->variantData(...))->values(),
|
||||
'history' => $this->historyData($historicalVariants),
|
||||
];
|
||||
}
|
||||
|
||||
private function isCurrent(Variant $variant): bool
|
||||
{
|
||||
if ($variant->sales_disabled_at !== null || $variant->replaced_by_variant_id !== null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$status = $variant->selectedEventDates()->first()?->status;
|
||||
|
||||
return $status === null || in_array(
|
||||
$status,
|
||||
[EventDateStatus::Scheduled, EventDateStatus::InProgress],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private function isHistorical(Variant $variant): bool
|
||||
{
|
||||
return in_array(
|
||||
$variant->selectedEventDates()->first()?->status,
|
||||
[
|
||||
EventDateStatus::Rescheduled,
|
||||
EventDateStatus::Suspended,
|
||||
EventDateStatus::Completed,
|
||||
],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function variantData(Variant $variant): array
|
||||
{
|
||||
$values = $variant->selectionValues();
|
||||
$eventDate = $variant->selectedEventDates()->first();
|
||||
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'event_date_id' => $eventDate?->id,
|
||||
'event_date' => $eventDate?->date?->format('Y-m-d'),
|
||||
'schedule' => $values->get('horario'),
|
||||
'service' => $values->get('servicio'),
|
||||
'description' => $variant->descripcion,
|
||||
'stock' => $variant->inventory->real_stock,
|
||||
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Variant> $variants
|
||||
* @return Collection<int, array<string, mixed>>
|
||||
*/
|
||||
private function historyData(Collection $variants): Collection
|
||||
{
|
||||
return $variants
|
||||
->filter(fn (Variant $variant): bool => $variant->selectedEventDates()->first() !== null)
|
||||
->groupBy(fn (Variant $variant): int => (int) $variant->selectedEventDates()->first()->id)
|
||||
->map(function (Collection $dateVariants): array {
|
||||
/** @var EventDate $eventDate */
|
||||
$eventDate = $dateVariants->first()->selectedEventDates()->first();
|
||||
$status = $this->historicalStatus($eventDate);
|
||||
$change = $this->changeForStatus($eventDate, $status);
|
||||
|
||||
return [
|
||||
'id' => $eventDate->id,
|
||||
'change_id' => $change?->id,
|
||||
'status' => $status->value,
|
||||
'status_text' => match ($status) {
|
||||
EventDateStatus::Rescheduled => 'REPROGRAMADA',
|
||||
EventDateStatus::Suspended => 'CANCELADA',
|
||||
EventDateStatus::Completed => 'FINALIZADA',
|
||||
default => '',
|
||||
},
|
||||
'event_date_id' => $eventDate->id,
|
||||
'event_date' => $eventDate->date->format('Y-m-d'),
|
||||
'replacement_event_date_id' => $status === EventDateStatus::Rescheduled
|
||||
? ($change?->destination_event_date_id
|
||||
?? $eventDate->rescheduled_to_event_date_id)
|
||||
: null,
|
||||
'replacement_event_date' => $status === EventDateStatus::Rescheduled
|
||||
? ($change?->new_date?->format('Y-m-d')
|
||||
?? $eventDate->rescheduledTo?->date?->format('Y-m-d'))
|
||||
: null,
|
||||
'occurred_at' => ($change?->created_at ?? $eventDate->endsAt())->toISOString(),
|
||||
'variants' => $dateVariants->map($this->variantData(...))->values(),
|
||||
];
|
||||
})
|
||||
->sortByDesc('occurred_at')
|
||||
->values();
|
||||
}
|
||||
|
||||
private function historicalStatus(EventDate $eventDate): EventDateStatus
|
||||
{
|
||||
return match ($eventDate->status) {
|
||||
EventDateStatus::Rescheduled => EventDateStatus::Rescheduled,
|
||||
EventDateStatus::Suspended => EventDateStatus::Suspended,
|
||||
default => EventDateStatus::Completed,
|
||||
};
|
||||
}
|
||||
|
||||
private function changeForStatus(
|
||||
EventDate $eventDate,
|
||||
EventDateStatus $status,
|
||||
): ?EventDateChange {
|
||||
$changeType = match ($status) {
|
||||
EventDateStatus::Rescheduled => EventDateChangeType::Rescheduled,
|
||||
EventDateStatus::Suspended => EventDateChangeType::Suspended,
|
||||
default => null,
|
||||
};
|
||||
|
||||
return $changeType === null
|
||||
? null
|
||||
: $eventDate->changeHistory
|
||||
->first(fn (EventDateChange $change): bool => $change->change_type === $changeType);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,13 @@ class EntryService
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
|
||||
->whereHas('variants', fn ($query) => $query
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->whereNull('sales_disabled_at'))
|
||||
->with([
|
||||
'variants' => fn ($query) => $query
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->whereNull('sales_disabled_at'),
|
||||
'variants.inventory',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
@@ -97,6 +103,7 @@ class EntryService
|
||||
|
||||
$variants = Variant::query()
|
||||
->where('catalog_item_id', $catalogItem->id)
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Event\Enums\EventDateStatus;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -36,8 +38,10 @@ class FoodService
|
||||
->with([
|
||||
'variants.catalogItem',
|
||||
'variants.inventory',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.eventDate.rescheduledTo',
|
||||
'variants.eventDate.changeHistory.destinationEventDate',
|
||||
'variants.eventDates.rescheduledTo',
|
||||
'variants.eventDates.changeHistory.destinationEventDate',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
])
|
||||
->first();
|
||||
@@ -55,11 +59,16 @@ class FoodService
|
||||
|
||||
$food->variants()->whereNull('precio')->update(['precio' => $food->precio]);
|
||||
$existingVariants = $food->variants()
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute'])
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
->get()
|
||||
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
|
||||
->values();
|
||||
$resolvedVariants = $this->resolveVariants($variants, $attributes);
|
||||
|
||||
$this->validateCurrentEventDates($tenant, $resolvedVariants);
|
||||
$this->validateCombinations($resolvedVariants, $existingVariants);
|
||||
|
||||
foreach ($resolvedVariants as $index => $data) {
|
||||
@@ -80,7 +89,13 @@ class FoodService
|
||||
}
|
||||
}
|
||||
|
||||
$minimumPrice = $food->variants()->min('precio');
|
||||
$minimumPrice = $food->variants()
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->with(['eventDate', 'eventDates'])
|
||||
->get()
|
||||
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
|
||||
->min('precio');
|
||||
if ($minimumPrice !== null) {
|
||||
$food->update(['precio' => $minimumPrice]);
|
||||
}
|
||||
@@ -88,22 +103,103 @@ class FoodService
|
||||
return $food->fresh()->load([
|
||||
'variants.catalogItem',
|
||||
'variants.inventory',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.eventDate.rescheduledTo',
|
||||
'variants.eventDate.changeHistory.destinationEventDate',
|
||||
'variants.eventDates.rescheduledTo',
|
||||
'variants.eventDates.changeHistory.destinationEventDate',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{id: int, stock: int}> $variants
|
||||
*/
|
||||
public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
|
||||
$food = CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('slug', 'comida')
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
$variantIds = collect($variants)->pluck('id')->map(fn ($id): int => (int) $id);
|
||||
$historicalVariants = $food->variants()
|
||||
->whereIn('id', $variantIds)
|
||||
->with(['inventory', 'eventDate', 'eventDates'])
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->filter(fn (Variant $variant): bool => $this->hasHistoricalDate($variant))
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($variants as $index => $data) {
|
||||
$variant = $historicalVariants->get((int) $data['id']);
|
||||
if ($variant === null) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.id" => [
|
||||
'La variante no pertenece al historial de Comida.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$stock = (int) $data['stock'];
|
||||
$inventory = $this->inventoryForHistoricalStockUpdate($variant);
|
||||
if ($stock < $inventory->reserved_stock) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.stock" => [
|
||||
'El stock no puede ser menor que la cantidad actualmente reservada.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory->update(['real_stock' => $stock]);
|
||||
}
|
||||
|
||||
return $this->current($tenant) ?? $food;
|
||||
});
|
||||
}
|
||||
|
||||
private function inventoryForHistoricalStockUpdate(Variant $variant): Inventory
|
||||
{
|
||||
$inventory = Inventory::query()
|
||||
->whereKey($variant->inventory_id)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
$variantsSharingInventory = Variant::query()
|
||||
->where('inventory_id', $inventory->getKey())
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get(['id']);
|
||||
|
||||
if ($variantsSharingInventory->count() === 1) {
|
||||
return $inventory;
|
||||
}
|
||||
|
||||
$historicalInventory = Inventory::query()->create([
|
||||
'sold_units' => $inventory->sold_units,
|
||||
'refunded_units' => $inventory->refunded_units,
|
||||
'reserved_stock' => 0,
|
||||
'real_stock' => $inventory->real_stock,
|
||||
]);
|
||||
$variant->update(['inventory_id' => $historicalInventory->getKey()]);
|
||||
|
||||
return $historicalInventory;
|
||||
}
|
||||
|
||||
public function delete(Tenant $tenant, int $foodId): void
|
||||
{
|
||||
$variant = Variant::query()
|
||||
->whereKey($foodId)
|
||||
->whereNull('sales_disabled_at')
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->with(['eventDate', 'eventDates'])
|
||||
->whereHas('catalogItem', fn ($query) => $query
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('slug', 'comida'))
|
||||
->firstOrFail();
|
||||
|
||||
abort_unless($this->hasCurrentDate($variant), 404);
|
||||
|
||||
$this->catalogService->deleteVariant($variant);
|
||||
}
|
||||
|
||||
@@ -225,6 +321,30 @@ class FoodService
|
||||
return $option;
|
||||
}
|
||||
|
||||
/** @param array<int, array<string, mixed>> $variants */
|
||||
private function validateCurrentEventDates(Tenant $tenant, array $variants): void
|
||||
{
|
||||
$eventDates = EventDate::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereIn('id', collect($variants)->pluck('event_date_id')->unique())
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
foreach ($variants as $index => $variant) {
|
||||
$eventDate = $eventDates->get($variant['event_date_id']);
|
||||
|
||||
if ($eventDate !== null && $this->isCurrentStatus($eventDate->status)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.event_date_id" => [
|
||||
'La fecha seleccionada ya no está disponible.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $incoming
|
||||
* @param Collection<int, Variant> $existing
|
||||
@@ -329,4 +449,25 @@ class FoodService
|
||||
mb_strtolower(trim($service)),
|
||||
]);
|
||||
}
|
||||
|
||||
private function hasCurrentDate(Variant $variant): bool
|
||||
{
|
||||
$status = $variant->selectedEventDates()->first()?->status;
|
||||
|
||||
return $status === null || $this->isCurrentStatus($status);
|
||||
}
|
||||
|
||||
private function hasHistoricalDate(Variant $variant): bool
|
||||
{
|
||||
return in_array(
|
||||
$variant->selectedEventDates()->first()?->status,
|
||||
[EventDateStatus::Rescheduled, EventDateStatus::Suspended, EventDateStatus::Completed],
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private function isCurrentStatus(EventDateStatus $status): bool
|
||||
{
|
||||
return in_array($status, [EventDateStatus::Scheduled, EventDateStatus::InProgress], true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,6 +39,9 @@ Route::prefix('v1/adminapp/tenant')
|
||||
Route::post('foods', [FoodController::class, 'store'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||
->name('adminapp.fiesta-futbol-infantil.foods.store');
|
||||
Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
|
||||
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Forms\Resources\EntryFormResource;
|
||||
use App\Domains\Forms\Services\EntryFormService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class EntryFormController extends Controller
|
||||
{
|
||||
public function __construct(protected EntryFormService $entryFormService) {}
|
||||
|
||||
public function __invoke(Request $request): EntryFormResource
|
||||
{
|
||||
return EntryFormResource::make(
|
||||
$this->entryFormService->get(
|
||||
$request->user('sanctum')->tenant()->firstOrFail()
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
26
app/Domains/Forms/Resources/EntryFormResource.php
Normal file
26
app/Domains/Forms/Resources/EntryFormResource.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Forms\Resources;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class EntryFormResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'event_dates' => $this->resource['event_dates']->map(
|
||||
fn (EventDate $eventDate): array => [
|
||||
'id' => $eventDate->id,
|
||||
'validity_time_id' => $eventDate->validity_time_id,
|
||||
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
|
||||
'date' => $eventDate->date->format('Y-m-d'),
|
||||
]
|
||||
)->values(),
|
||||
];
|
||||
}
|
||||
}
|
||||
22
app/Domains/Forms/Services/EntryFormService.php
Normal file
22
app/Domains/Forms/Services/EntryFormService.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Forms\Services;
|
||||
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
|
||||
class EntryFormService
|
||||
{
|
||||
/** @return array{event_dates: Collection<int, EventDate>} */
|
||||
public function get(Tenant $tenant): array
|
||||
{
|
||||
return [
|
||||
'event_dates' => $tenant->eventDates()
|
||||
->whereNull('rescheduled_to_event_date_id')
|
||||
->whereNull('suspended_at')
|
||||
->with('validityTime')
|
||||
->get(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ namespace App\Domains\Forms\Services;
|
||||
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\AttributeOption;
|
||||
use App\Domains\Event\Enums\EventDateStatus;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
@@ -27,7 +28,17 @@ class FoodFormService
|
||||
->keyBy('codigo');
|
||||
|
||||
return [
|
||||
'event_dates' => $tenant->eventDates()->with('validityTime')->get(),
|
||||
'event_dates' => $tenant->eventDates()
|
||||
->whereNull('rescheduled_to_event_date_id')
|
||||
->whereNull('suspended_at')
|
||||
->with('validityTime')
|
||||
->get()
|
||||
->filter(fn (EventDate $eventDate): bool => in_array(
|
||||
$eventDate->status,
|
||||
[EventDateStatus::Scheduled, EventDateStatus::InProgress],
|
||||
true,
|
||||
))
|
||||
->values(),
|
||||
'schedules' => $attributes->get('horario')?->options ?? new Collection,
|
||||
'services' => $attributes->get('servicio')?->options ?? new Collection,
|
||||
];
|
||||
|
||||
@@ -124,11 +124,10 @@ class TicketFilterFormService
|
||||
'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'],
|
||||
],
|
||||
'options' => array_values(array_filter(
|
||||
Ticket::statusOptions(),
|
||||
fn (array $option): bool => $option['value'] !== Ticket::STATUS_CANCELLED,
|
||||
)),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -236,11 +236,7 @@ class TicketFormService
|
||||
?: $left['label'] <=> $right['label']);
|
||||
|
||||
return [
|
||||
'statuses' => [
|
||||
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
||||
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
||||
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
||||
],
|
||||
'statuses' => Ticket::statusOptions(),
|
||||
'categories' => array_values(array_map(
|
||||
fn (array $category): array => [
|
||||
'value' => $category['value'],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Forms\Controllers\AdminApp\EntryFormController;
|
||||
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
|
||||
use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
|
||||
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
||||
@@ -22,6 +23,10 @@ Route::prefix('v1/adminapp/forms')
|
||||
'fiesta-futbol-infantil/ticket',
|
||||
TicketFormController::class
|
||||
);
|
||||
Route::get(
|
||||
'fiesta-futbol-infantil/entry',
|
||||
EntryFormController::class
|
||||
);
|
||||
Route::get(
|
||||
'fiesta-futbol-infantil/merchandise',
|
||||
MerchandiseFormController::class
|
||||
|
||||
@@ -4,8 +4,9 @@ namespace App\Domains\Integration\Controllers;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
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\IntegrationAssociationService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
@@ -35,7 +36,7 @@ class ClientIntegrationController extends Controller
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreClientIntegrationRequest $request,
|
||||
ConfigureIntegrationRequest $request,
|
||||
Client $client,
|
||||
string $integrationCode,
|
||||
): JsonResponse {
|
||||
@@ -61,4 +62,11 @@ class ClientIntegrationController extends Controller
|
||||
], 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)
|
||||
{
|
||||
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
|
||||
$integration->delete();
|
||||
|
||||
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;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class ClientIntegration extends Model
|
||||
{
|
||||
protected $hidden = ['integration_data'];
|
||||
|
||||
protected $fillable = [
|
||||
'client_id',
|
||||
'integration_code',
|
||||
'integration_data',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'integration_data' => EncryptedIntegrationData::class,
|
||||
'integration_instance_id',
|
||||
];
|
||||
|
||||
/** @return BelongsTo<Client, $this> */
|
||||
@@ -32,4 +25,10 @@ class ClientIntegration extends Model
|
||||
{
|
||||
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;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Integration extends Model
|
||||
{
|
||||
@@ -13,16 +14,29 @@ class Integration extends Model
|
||||
'name',
|
||||
'url',
|
||||
'integration_data_schema',
|
||||
'requires_client_configuration',
|
||||
'requires_configuration',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'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 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\Validation\ValidationException;
|
||||
|
||||
class StoreClientIntegrationRequest extends FormRequest
|
||||
class ConfigureIntegrationRequest extends FormRequest
|
||||
{
|
||||
protected ?Integration $integrationModel = null;
|
||||
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
return $this->user()?->can('manage', Integration::class) ?? false;
|
||||
}
|
||||
|
||||
protected function prepareForValidation(): void
|
||||
{
|
||||
$integrationCode = $this->route('integration_code');
|
||||
$this->integrationModel = Integration::query()
|
||||
->where('integration_code', $integrationCode)
|
||||
->where('integration_code', $this->route('integration_code'))
|
||||
->first();
|
||||
|
||||
if (! $this->integrationModel) {
|
||||
@@ -31,7 +30,7 @@ class StoreClientIntegrationRequest extends FormRequest
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = [];
|
||||
$rules = ['integration_data' => ['present', 'array']];
|
||||
|
||||
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
||||
$rules['integration_data.'.$field] = $rule;
|
||||
@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
||||
'requires_configuration' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Integration\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateIntegrationRequest extends FormRequest
|
||||
{
|
||||
@@ -19,9 +20,8 @@ class UpdateIntegrationRequest extends FormRequest
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'url' => ['nullable', 'url', 'max:255'],
|
||||
'integration_data_schema' => ['nullable', 'array'],
|
||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
||||
// the code shouldn't ideally be updatable, but if it is:
|
||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
|
||||
'requires_configuration' => ['sometimes', 'boolean'],
|
||||
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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\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\Tenant;
|
||||
use Exception;
|
||||
use Illuminate\Http\Client\PendingRequest;
|
||||
@@ -32,9 +34,9 @@ abstract class BaseIntegrationService
|
||||
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.
|
||||
@@ -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
|
||||
*/
|
||||
@@ -95,6 +97,7 @@ abstract class BaseIntegrationService
|
||||
throw new Exception('Integration code is not set.');
|
||||
}
|
||||
|
||||
$this->integrationInstance = null;
|
||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||
if (! $this->integration) {
|
||||
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.');
|
||||
}
|
||||
|
||||
$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)
|
||||
->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.");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
{
|
||||
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
|
||||
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
|
||||
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
|
||||
{
|
||||
return $client->integrations()
|
||||
->with(['integration', 'integrationInstance'])
|
||||
->where('integration_code', $integrationCode)
|
||||
->first();
|
||||
}
|
||||
@@ -20,7 +21,7 @@ class ClientIntegrationService
|
||||
/** @return Collection<int, ClientIntegration> */
|
||||
public function getAllForClient(Client $client): Collection
|
||||
{
|
||||
return $client->integrations()->with('integration')->get();
|
||||
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
|
||||
}
|
||||
|
||||
public function updateOrCreateIntegration(
|
||||
@@ -29,13 +30,7 @@ class ClientIntegrationService
|
||||
array $data,
|
||||
): ClientIntegration {
|
||||
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
||||
$clientIntegration = ClientIntegration::query()->updateOrCreate(
|
||||
[
|
||||
'client_id' => $client->id,
|
||||
'integration_code' => $integration->integration_code,
|
||||
],
|
||||
['integration_data' => $data],
|
||||
);
|
||||
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
|
||||
|
||||
$service = $this->resolveService($integration->integration_code);
|
||||
$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 bool $usesClientMailer = false;
|
||||
private bool $usesInstanceMailer = false;
|
||||
|
||||
public function __construct(?MailFactory $mailFactory = null)
|
||||
{
|
||||
@@ -40,12 +40,12 @@ class MailService extends BaseIntegrationService
|
||||
{
|
||||
parent::forTenant($tenantCode);
|
||||
|
||||
if ($this->clientIntegration) {
|
||||
if ($this->integrationInstance) {
|
||||
$this->mailer = $this->resolveMailer();
|
||||
$this->usesClientMailer = true;
|
||||
$this->usesInstanceMailer = true;
|
||||
} else {
|
||||
$this->mailer = $this->mailFactory->mailer();
|
||||
$this->usesClientMailer = false;
|
||||
$this->usesInstanceMailer = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -56,12 +56,12 @@ class MailService extends BaseIntegrationService
|
||||
parent::forClient($client);
|
||||
$this->tenant = $this->clientContext?->tenants()->first();
|
||||
|
||||
if ($this->clientIntegration) {
|
||||
if ($this->integrationInstance) {
|
||||
$this->mailer = $this->resolveMailer();
|
||||
$this->usesClientMailer = true;
|
||||
$this->usesInstanceMailer = true;
|
||||
} else {
|
||||
$this->mailer = $this->mailFactory->mailer();
|
||||
$this->usesClientMailer = false;
|
||||
$this->usesInstanceMailer = false;
|
||||
}
|
||||
|
||||
return $this;
|
||||
@@ -122,8 +122,8 @@ class MailService extends BaseIntegrationService
|
||||
|
||||
public function mailerName(): string
|
||||
{
|
||||
return $this->usesClientMailer
|
||||
? 'client-smtp'
|
||||
return $this->usesInstanceMailer
|
||||
? 'integration-smtp'
|
||||
: (string) config('mail.default');
|
||||
}
|
||||
|
||||
@@ -187,7 +187,7 @@ class MailService extends BaseIntegrationService
|
||||
|
||||
private function resolveMailer(): Mailer
|
||||
{
|
||||
$data = $this->clientIntegration?->integration_data;
|
||||
$data = $this->integrationInstance?->integration_data;
|
||||
|
||||
if (! is_array($data)) {
|
||||
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([
|
||||
'name' => 'client-smtp-'.$this->clientContext?->id,
|
||||
'name' => 'integration-smtp-'.$this->integrationInstance?->id,
|
||||
'transport' => 'smtp',
|
||||
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
||||
'host' => $data['MAIL_HOST'],
|
||||
|
||||
@@ -45,11 +45,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
*/
|
||||
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.');
|
||||
}
|
||||
|
||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
||||
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||
|
||||
$token = Cache::get($cacheKey);
|
||||
|
||||
@@ -94,7 +94,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
// Calculate TTL and subtract a buffer of 60 seconds
|
||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||
|
||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
||||
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||
|
||||
return $token;
|
||||
@@ -220,11 +220,11 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
||||
*/
|
||||
public function clearToken(): void
|
||||
{
|
||||
if (! $this->clientContext) {
|
||||
if (! $this->integrationInstance) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cacheKey = "integration_token:{$this->clientContext->id}:{$this->integrationCode}";
|
||||
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||
Cache::forget($cacheKey);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
# 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.
|
||||
- `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.
|
||||
## Resolución
|
||||
|
||||
## 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.
|
||||
- `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.
|
||||
`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`.
|
||||
|
||||
## 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`.
|
||||
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
||||
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
|
||||
## Administración
|
||||
|
||||
## 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
|
||||
|
||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||
use App\Domains\Integration\Controllers\IntegrationController;
|
||||
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||
use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController;
|
||||
use App\Domains\Integration\Models\Integration;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::group(['prefix' => 'integrations'], function () {
|
||||
Route::get('/', [IntegrationController::class, 'index']);
|
||||
Route::post('/', [IntegrationController::class, 'store']);
|
||||
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
||||
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
||||
});
|
||||
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
|
||||
Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
|
||||
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
|
||||
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
|
||||
Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']);
|
||||
Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
|
||||
});
|
||||
});
|
||||
|
||||
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user