Compare commits

..

41 Commits

Author SHA1 Message Date
152d44cd0c Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations 2026-08-26 16:23:20 -03:00
cd99a6489b Merge branch 'fix/simplify_stock_reservations' of https://gitea.quo.ar/tbianchini/shopit-back into fix/simplify_stock_reservations 2026-08-26 10:12:44 -03:00
ebcbbb1774 fix(cart): reconcile expired cart mutations 2026-08-26 10:10:35 -03:00
3dd378ed06 docs(stock): describe associated entity expiration 2026-08-26 10:10:35 -03:00
4f705175bd test(stock): cover propagated reservation expiration 2026-08-26 10:10:35 -03:00
82570fe941 refactor(stock): propagate reservation expiration 2026-08-26 10:10:35 -03:00
d2a2b323e0 docs(stock): document terminal reservation recovery 2026-08-26 10:10:22 -03:00
ec67623b7b test(stock): cover terminal reservation lifecycle 2026-08-26 10:10:22 -03:00
f6f138e180 refactor(stock): make expired reservations terminal 2026-08-26 10:10:22 -03:00
d612b7a118 docs(stock): describe unified expiration ownership 2026-08-26 10:10:08 -03:00
49f42d4507 test(stock): cover authoritative reservation expiration 2026-08-26 10:10:00 -03:00
1881cc1d4b refactor(stock): make reservation expiration authoritative 2026-08-26 10:10:00 -03:00
00ec37d8a0 docs(stock): document centralized reservation flow 2026-08-26 10:09:34 -03:00
d9f374c718 test(stock): cover aggregate reservation lifecycle 2026-08-26 10:09:14 -03:00
bbfdf8f342 refactor(stock): centralize reservation aggregate 2026-08-26 10:08:28 -03:00
8a0f29bdae fix(cart): reconcile expired cart mutations 2026-08-26 09:45:39 -03:00
198ed400a9 docs(stock): describe associated entity expiration 2026-08-26 09:45:39 -03:00
23d83166e6 test(stock): cover propagated reservation expiration 2026-08-26 09:45:39 -03:00
cb090402d0 refactor(stock): propagate reservation expiration 2026-08-26 09:45:39 -03:00
c120b6f7c0 docs(stock): document terminal reservation recovery 2026-08-26 09:45:39 -03:00
1106fa28b4 test(stock): cover terminal reservation lifecycle 2026-08-26 09:45:39 -03:00
896fb81bcf refactor(stock): make expired reservations terminal 2026-08-26 09:45:39 -03:00
8545fbc645 docs(stock): describe unified expiration ownership 2026-08-26 09:45:39 -03:00
dd6dcb708a test(stock): cover authoritative reservation expiration 2026-08-26 09:45:38 -03:00
624b246ff5 refactor(stock): make reservation expiration authoritative 2026-08-26 09:45:38 -03:00
007b95b904 docs(stock): document centralized reservation flow 2026-08-26 09:45:38 -03:00
0b3c8c93be test(stock): cover aggregate reservation lifecycle 2026-08-26 09:45:38 -03:00
ede718e448 refactor(stock): centralize reservation aggregate 2026-08-26 09:45:38 -03:00
e63a03601a fix(cart): reconcile expired cart mutations 2026-08-25 16:33:39 -03:00
389373ad8c docs(stock): describe associated entity expiration 2026-08-25 16:10:15 -03:00
ace02a3133 test(stock): cover propagated reservation expiration 2026-08-25 16:10:14 -03:00
d69a6210f6 refactor(stock): propagate reservation expiration 2026-08-25 16:09:52 -03:00
3c0b43fea3 docs(stock): document terminal reservation recovery 2026-08-25 15:58:47 -03:00
9b0008626b test(stock): cover terminal reservation lifecycle 2026-08-25 15:58:47 -03:00
61861e331a refactor(stock): make expired reservations terminal 2026-08-25 15:58:41 -03:00
2cc9d7dd97 docs(stock): describe unified expiration ownership 2026-08-25 15:28:30 -03:00
f982bcead1 test(stock): cover authoritative reservation expiration 2026-08-25 15:28:23 -03:00
7a85e1731d refactor(stock): make reservation expiration authoritative 2026-08-25 15:28:17 -03:00
6db99e775a docs(stock): document centralized reservation flow 2026-08-25 15:05:34 -03:00
e5f7ba3615 test(stock): cover aggregate reservation lifecycle 2026-08-25 15:05:29 -03:00
24bfef431b refactor(stock): centralize reservation aggregate 2026-08-25 15:05:23 -03:00
309 changed files with 1261 additions and 18421 deletions

View File

@@ -8,7 +8,6 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
STOCK_RESERVATION_EXPIRATION_MINUTES=30
FRONTEND_URLS=http://localhost:4200
@@ -45,7 +44,6 @@ 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

View File

@@ -1,11 +1,5 @@
# 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,63 +0,0 @@
# 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.

View File

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

View File

@@ -5,7 +5,6 @@ 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;
@@ -27,7 +26,6 @@ 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'),

View File

@@ -5,7 +5,6 @@ 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;
@@ -26,7 +25,6 @@ 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) {

View File

@@ -17,8 +17,6 @@ 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';

View File

@@ -5,9 +5,7 @@ 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;
@@ -15,17 +13,16 @@ 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', 'active_email', 'active_google_id'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
use HasApiTokens, HasFactory, Notifiable;
protected $attributes = [
'rol_codigo' => RoleCode::User->value,
@@ -48,18 +45,6 @@ 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>
*/

View File

@@ -2,7 +2,6 @@
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;
@@ -14,26 +13,15 @@ class RegisterUserRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
/** @return array<string, mixed> */
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => [
'required',
'string',
'email',
'max:255',
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'),
],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'],

View File

@@ -4,7 +4,6 @@ namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class UpdateProfileRequest extends FormRequest
{
@@ -13,13 +12,6 @@ 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 [
@@ -27,13 +19,11 @@ class UpdateProfileRequest extends FormRequest
'email' => [
'required',
'email',
Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo)
->whereNull('deleted_at')
->ignore($this->user()->id),
Rule::unique('users', 'email')->ignore($this->user()->id),
],
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
'password' => ['nullable', 'string', Password::min(8)->mixedCase()->symbols()],
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
];
}
}

View File

@@ -24,11 +24,6 @@ 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()),
];
}
}

View File

@@ -11,7 +11,7 @@ class AdminCredentialVerifier
public function verify(string $email, string $password): bool
{
$admin = User::query()
->where('active_email', mb_strtolower(trim($email)))
->where('email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::Admin->value)
->first();

View File

@@ -3,7 +3,6 @@
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;
@@ -125,12 +124,12 @@ class GoogleAuthService
]);
}
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first();
$user = User::query()->where('google_id', $googleId)->first();
if ($user) {
return $user;
}
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first();
$user = User::query()->where('email', $email)->first();
if ($user) {
$user->forceFill(['google_id' => $googleId])->save();

View File

@@ -85,7 +85,7 @@ class PasswordLoginService
null,
$ipAddress,
$userAgent,
RoleCode::Scanner,
null,
true,
PermissionCode::ScanTickets->value,
PasswordResetRequested::CHANNEL_SCANNER,
@@ -120,7 +120,7 @@ class PasswordLoginService
$passwordResetChannel,
): array {
$user = User::query()
->where('active_email', $normalizedEmail)
->where('email', $normalizedEmail)
->when(
$requiredRole !== null,
fn ($query) => $query->where('rol_codigo', $requiredRole->value),

View File

@@ -28,8 +28,7 @@ class ResetPasswordAttemptService
try {
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
$user = User::query()
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', RoleCode::User->value)
->where('email', $email)
->lockForUpdate()
->first();
@@ -66,7 +65,7 @@ class ResetPasswordAttemptService
try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query()
->where('active_email', mb_strtolower(trim($email)))
->where('email', $email)
->where('rol_codigo', RoleCode::AdminApp->value)
->whereNotNull('tenant_codigo')
->lockForUpdate()
@@ -114,7 +113,7 @@ class ResetPasswordAttemptService
try {
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
$user = User::query()
->where('active_email', mb_strtolower(trim($email)))
->where('email', $email)
->where('rol_codigo', RoleCode::Scanner->value)
->whereNotNull('tenant_codigo')
->lockForUpdate()
@@ -153,15 +152,14 @@ class ResetPasswordAttemptService
);
}
public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string
public function validateCode(string $email, string $code): string
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string {
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
$user = User::query()
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', $role->value)
->where('email', $email)
->lockForUpdate()
->first();
@@ -209,15 +207,14 @@ class ResetPasswordAttemptService
}
}
public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool
public function resetPassword(string $email, string $code, string $password): bool
{
$emailFingerprint = $this->emailFingerprint($email);
try {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint, $role): bool {
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
$user = User::query()
->where('active_email', mb_strtolower(trim($email)))
->where('rol_codigo', $role->value)
->where('email', $email)
->lockForUpdate()
->first();

View File

@@ -19,16 +19,6 @@ class ScannerContextService
$user->setRelation('tenant', $tenant);
if ($tenant->requiresScannerCategoryValidation()) {
$categories = $user->scanCategories()
->orderBy('nombre')
->get();
if ($categories->isNotEmpty()) {
$user->setRelation('scanCategories', $categories);
}
}
return $user;
}
}

View File

@@ -12,10 +12,8 @@ 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);

View File

@@ -12,10 +12,8 @@ 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);

View File

@@ -17,7 +17,6 @@ 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,

View File

@@ -32,7 +32,6 @@ class TenantBootstrapService
return $this->tenantInformationService->load(
$tenant,
[
'eventDateChanges',
'menues' => fn ($query) => $query->whereHas(
'roles',
fn ($query) => $query->where('codigo', RoleCode::User->value)

View File

@@ -400,17 +400,6 @@ 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;
}
@@ -441,12 +430,6 @@ 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);

View File

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

View File

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

View File

@@ -59,8 +59,6 @@ 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');
}

View File

@@ -179,28 +179,12 @@ class CatalogItem extends Model
{
return $query->where(function (Builder $query): void {
$query
->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',
fn (Builder $variantQuery): Builder => $variantQuery
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->whereHas(
'inventory',
'variants.inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
)
)
->orWhere(function (Builder $directItemQuery): void {
$directItemQuery
->whereDoesntHave('variants')
@@ -221,12 +205,9 @@ class CatalogItem extends Model
public function visibleVariants(?int $includedVariantId = null): Collection
{
return $this->variants
->filter(fn (Variant $variant): bool => $variant->hasOnlyActiveEventDates()
&& (($includedVariantId !== null && $variant->id === $includedVariantId)
|| ($variant->isSellable() && (
$this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0
))))
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|| $this->inventory_policy === InventoryPolicy::Unlimited
|| ($variant->inventory?->availableStock() ?? 0) > 0)
->values();
}

View File

@@ -10,7 +10,6 @@ use Illuminate\Database\Eloquent\Relations\HasOne;
#[Fillable([
'sold_units',
'refunded_units',
'reserved_stock',
'real_stock',
])]
@@ -24,7 +23,6 @@ class Inventory extends Model
protected $attributes = [
'sold_units' => 0,
'refunded_units' => 0,
'reserved_stock' => 0,
'real_stock' => 0,
];
@@ -33,7 +31,6 @@ class Inventory extends Model
{
return [
'sold_units' => 'integer',
'refunded_units' => 'integer',
'reserved_stock' => 'integer',
'real_stock' => 'integer',
];
@@ -45,10 +42,10 @@ class Inventory extends Model
return $this->hasOne(CatalogItem::class);
}
/** @return HasMany<Variant, $this> */
public function variants(): HasMany
/** @return HasOne<Variant, $this> */
public function variant(): HasOne
{
return $this->hasMany(Variant::class);
return $this->hasOne(Variant::class);
}
/** @return HasMany<StockReservationLine, $this> */

View File

@@ -20,8 +20,6 @@ use Illuminate\Support\Str;
'catalog_item_id',
'event_date_id',
'inventory_id',
'replaced_by_variant_id',
'sales_disabled_at',
'descripcion',
'precio',
])]
@@ -39,8 +37,6 @@ 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',
];
}
@@ -80,39 +76,6 @@ 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
{

View File

@@ -63,12 +63,7 @@ class CatalogInventoryService
$selection->loadMissing('variants.inventory');
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(
return $selection->variants->sum(
fn (Variant $variant): int => $variant->inventory->availableStock(),
);
}

View File

@@ -207,8 +207,7 @@ class CatalogService
$visibleVariants = $catalogItem->visibleVariants();
if ($catalogItem->type === CatalogItemType::Standard
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
&& (($catalogItem->variants->isNotEmpty() && $visibleVariants->isEmpty())
|| ! $catalogItem->isAvailable())) {
&& ! $catalogItem->isAvailable()) {
throw new NotFoundHttpException('Catalog item is out of stock.');
}
@@ -325,13 +324,9 @@ class CatalogService
->findOrFail($variant->catalog_item_id);
$variant->delete();
$sellableVariants = $catalogItem->variants()
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id');
if (! (clone $sellableVariants)->exists()) {
if (! $catalogItem->variants()->exists()) {
$this->delete($catalogItem);
} elseif (($minimumPrice = (clone $sellableVariants)->min('precio')) !== null) {
} elseif (($minimumPrice = $catalogItem->variants()->min('precio')) !== null) {
$catalogItem->update(['precio' => $minimumPrice]);
}
@@ -404,11 +399,7 @@ class CatalogService
]);
}
if ($variantId !== null && ! $componentItem->variants()
->whereKey($variantId)
->whereNull('sales_disabled_at')
->whereNull('replaced_by_variant_id')
->exists()) {
if ($variantId !== null && ! $componentItem->variants()->whereKey($variantId)->exists()) {
throw ValidationException::withMessages([
"components.{$index}.variant_id" => [
__('api.catalog.component_variant_invalid'),

View File

@@ -19,10 +19,6 @@ 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';
@@ -347,9 +343,9 @@ class StockReservationService
});
}
public function clearExpirationForReview(Purchase $purchase): void
public function refreshForPurchase(Purchase $purchase, ?Carbon $expiresAt): void
{
DB::transaction(function () use ($purchase): void {
DB::transaction(function () use ($purchase, $expiresAt): void {
/** @var Purchase $purchase */
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
if ($purchase->stock_reservation_id === null) {
@@ -367,7 +363,7 @@ class StockReservationService
throw new StockReservationExpiredException;
}
$reservation->update(['expires_at' => null]);
$reservation->update(['expires_at' => $expiresAt]);
});
}

View File

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

View File

@@ -5,7 +5,6 @@ namespace App\Domains\Desfile\Services;
use DateTimeInterface;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
use RuntimeException;
@@ -23,7 +22,7 @@ class InvitationPurchaseProvisioner
private const ALLOCATIONS = [
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 17, 'type' => 'VIP + LUNCH'],
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'VIP + LUNCH'],
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
];
@@ -59,7 +58,7 @@ class InvitationPurchaseProvisioner
$allocation['type'],
);
$purchaseItemId = $this->createPurchaseItem(
$this->createPurchaseItem(
$purchaseId,
$catalogItem,
$variant,
@@ -71,7 +70,6 @@ class InvitationPurchaseProvisioner
);
$this->createTicketAndCommitStock(
$purchaseId,
$purchaseItemId,
$userId,
(int) $catalogItem->id,
$variant,
@@ -104,11 +102,7 @@ class InvitationPurchaseProvisioner
private function userId(DateTimeInterface $now): int
{
$user = DB::table('users')
->where('active_email', self::USER_EMAIL)
->where('rol_codigo', 'user')
->whereNull('deleted_at')
->first();
$user = DB::table('users')->where('email', self::USER_EMAIL)->first();
if ($user !== null) {
if ($user->tenant_codigo !== self::TENANT_CODE) {
@@ -332,13 +326,12 @@ class InvitationPurchaseProvisioner
int $seat,
string $type,
DateTimeInterface $now,
): int {
$existingId = DB::table('compra_items')
): void {
if (DB::table('compra_items')
->where('compra_id', $purchaseId)
->where('source_variant_id', $variant->id)
->value('id');
if ($existingId !== null) {
return (int) $existingId;
->exists()) {
return;
}
$attributes = [
@@ -348,7 +341,7 @@ class InvitationPurchaseProvisioner
['name' => 'Asiento', 'value' => (string) $seat],
];
return DB::table('compra_items')->insertGetId([
DB::table('compra_items')->insert([
'compra_id' => $purchaseId,
'source_catalog_item_id' => $catalogItem->id,
'source_variant_id' => $variant->id,
@@ -374,18 +367,13 @@ class InvitationPurchaseProvisioner
private function createTicketAndCommitStock(
int $purchaseId,
int $purchaseItemId,
int $userId,
int $catalogItemId,
object $variant,
DateTimeInterface $now,
): void {
$purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id')
? ['source_purchase_item_id' => $purchaseItemId]
: ['source_purchase_id' => $purchaseId];
if (DB::table('tickets')
->where($purchaseReference)
->where('source_purchase_id', $purchaseId)
->where('source_variant_id', $variant->id)
->exists()) {
return;
@@ -432,7 +420,7 @@ class InvitationPurchaseProvisioner
'ticket' => (string) Str::uuid(),
'name' => null,
'description' => null,
...$purchaseReference,
'source_purchase_id' => $purchaseId,
'source_catalog_item_id' => $catalogItemId,
'source_variant_id' => $variant->id,
'used_at' => null,

View File

@@ -2,11 +2,7 @@
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;
@@ -32,39 +28,4 @@ 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(),
)
);
}
}

View File

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

View File

@@ -1,9 +0,0 @@
<?php
namespace App\Domains\Event\Enums;
enum EventDateChangeType: string
{
case Rescheduled = 'rescheduled';
case Suspended = 'suspended';
}

View File

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

View File

@@ -1,22 +0,0 @@
<?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,
) {}
}

View File

@@ -1,20 +0,0 @@
<?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,
) {}
}

View File

@@ -3,8 +3,6 @@
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;
@@ -23,8 +21,6 @@ use Illuminate\Support\Carbon;
'date',
'time_start',
'time_end',
'rescheduled_to_event_date_id',
'suspended_at',
])]
class EventDate extends Model
{
@@ -32,8 +28,6 @@ class EventDate extends Model
public $timestamps = false;
protected $appends = ['status'];
protected static function booted(): void
{
static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
@@ -58,8 +52,6 @@ class EventDate extends Model
return [
'date' => 'date:Y-m-d',
'validity_time_id' => 'integer',
'rescheduled_to_event_date_id' => 'integer',
'suspended_at' => 'datetime',
];
}
@@ -75,35 +67,6 @@ 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
{
@@ -128,32 +91,7 @@ class EventDate extends Model
public function endsAt(): CarbonInterface
{
$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;
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
}
private function syncTenantDateText(): void
@@ -166,10 +104,7 @@ class EventDate extends Model
$tenant->update([
'event_date_text' => app(EventDateTextFormatter::class)->format(
$tenant->eventDates()
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->pluck('date')
$tenant->eventDates()->pluck('date')
),
]);
}
@@ -179,6 +114,10 @@ class EventDate extends Model
$startsAt = $this->startsAt();
$expiresAt = $this->endsAt();
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
$expiresAt = $expiresAt->addDay();
}
$attributes = [
'type' => ValidityTimeType::FixedWindow,
'start_time' => null,

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,11 @@ 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' => [
@@ -33,16 +38,6 @@ 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',
],
];
}
@@ -59,41 +54,6 @@ 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.'
);
}
},
];
}

View File

@@ -1,24 +0,0 @@
<?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(),
];
}
}

View File

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

View File

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

View File

@@ -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,13 +19,14 @@ class EventResource extends JsonResource
'id' => $this->id,
'title' => $this->event_title,
'location' => $this->event_location,
'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)
),
'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(),
'social_media' => $this->socialMedia->map(fn ($item): array => [
'code' => $item->code,
'url' => $item->pivot->url,

View File

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

View File

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

View File

@@ -1,101 +0,0 @@
<?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(),
];
}
}

View File

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

View File

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

View File

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

View File

@@ -25,21 +25,6 @@ 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'))
@@ -52,14 +37,12 @@ class EventDateTextFormatter
$years = $normalizedDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
->map(function ($yearDates, string $year) use ($padDays, $includeYearPreposition): string {
->map(function ($yearDates, string $year): string {
$months = $yearDates
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
->map(function ($monthDates, string $month) use ($padDays): string {
->map(function ($monthDates, string $month): string {
$days = $monthDates
->map(fn (DateTimeImmutable $date): string => $padDays
? $date->format('d')
: (string) ((int) $date->format('j')))
->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j')))
->values()
->all();
@@ -68,7 +51,7 @@ class EventDateTextFormatter
->values()
->all();
return $this->join($months).($includeYearPreposition ? ' de ' : ' ').$year;
return $this->join($months).' '.$year;
})
->values()
->all();

View File

@@ -2,19 +2,7 @@
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;
@@ -26,13 +14,6 @@ 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']);
@@ -46,14 +27,9 @@ 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 {
@@ -64,252 +40,41 @@ class EventService
});
}
/** @param array{date: string, start_time: string, end_time: string} $data */
public function createDateForTenant(Tenant $tenant, array $data): EventDate
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
private function syncDates(Tenant $tenant, array $dates): void
{
return DB::transaction(function () use ($tenant, $data): EventDate {
$attributes = $this->dateAttributes($data);
$existingDates = $tenant->eventDates()->get()->values();
if ($tenant->eventDates()->where($attributes)->exists()) {
throw ValidationException::withMessages([
'date' => ['La fecha y el horario ya existen.'],
]);
}
return $tenant->eventDates()->create($attributes)->load('validityTime');
});
}
/** @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;
}
}
return $current->is($expected);
}
/** @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;
}
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',
foreach (array_values($dates) as $index => $date) {
$attributes = [
'date' => $date['date'],
'time_start' => $date['start_time'],
'time_end' => $date['end_time'],
];
$existingDate = $existingDates->get($index);
if ($existingDate) {
$existingDate->update($attributes);
} else {
$tenant->eventDates()->create($attributes);
}
}
$datesToDelete = $existingDates->slice(count($dates));
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.'],
]);
}
$datesToDelete->each->delete();
$tenant->unsetRelation('eventDates');
}
/** @param array<string, string|null> $contact */

View File

@@ -8,7 +8,6 @@ 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.
@@ -20,18 +19,10 @@ 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
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.
El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`.

View File

@@ -8,7 +8,4 @@ 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']);
});

View File

@@ -1,11 +1,3 @@
<?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'],
);

View File

@@ -2,7 +2,6 @@
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;
@@ -33,16 +32,6 @@ 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(

View File

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

View File

@@ -5,7 +5,6 @@ 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
@@ -13,20 +12,7 @@ class EntryResource extends JsonResource
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$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();
$variant = $this->variants->sole();
return [
'id' => $this->id,

View File

@@ -3,14 +3,8 @@
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
@@ -23,54 +17,13 @@ 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' => $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
{
'variants' => $this->variants->map(function ($variant): array {
$values = $variant->selectionValues();
$eventDate = $variant->selectedEventDates()->first();
@@ -84,73 +37,7 @@ class FoodResource extends JsonResource
'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(),
})->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);
}
}

View File

@@ -24,13 +24,7 @@ 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',
@@ -103,7 +97,6 @@ class EntryService
$variants = Variant::query()
->where('catalog_item_id', $catalogItem->id)
->whereNull('replaced_by_variant_id')
->lockForUpdate()
->get();

View File

@@ -11,8 +11,6 @@ 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;
@@ -38,10 +36,8 @@ class FoodService
->with([
'variants.catalogItem',
'variants.inventory',
'variants.eventDate.rescheduledTo',
'variants.eventDate.changeHistory.destinationEventDate',
'variants.eventDates.rescheduledTo',
'variants.eventDates.changeHistory.destinationEventDate',
'variants.eventDate',
'variants.eventDates',
'variants.definitions.itemAttribute.attribute',
])
->first();
@@ -59,16 +55,11 @@ 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()
->filter(fn (Variant $variant): bool => $this->hasCurrentDate($variant))
->values();
->get();
$resolvedVariants = $this->resolveVariants($variants, $attributes);
$this->validateCurrentEventDates($tenant, $resolvedVariants);
$this->validateCombinations($resolvedVariants, $existingVariants);
foreach ($resolvedVariants as $index => $data) {
@@ -89,13 +80,7 @@ class FoodService
}
}
$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');
$minimumPrice = $food->variants()->min('precio');
if ($minimumPrice !== null) {
$food->update(['precio' => $minimumPrice]);
}
@@ -103,103 +88,22 @@ class FoodService
return $food->fresh()->load([
'variants.catalogItem',
'variants.inventory',
'variants.eventDate.rescheduledTo',
'variants.eventDate.changeHistory.destinationEventDate',
'variants.eventDates.rescheduledTo',
'variants.eventDates.changeHistory.destinationEventDate',
'variants.eventDate',
'variants.eventDates',
'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);
}
@@ -321,30 +225,6 @@ 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
@@ -449,25 +329,4 @@ 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);
}
}

View File

@@ -39,9 +39,6 @@ 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');

View File

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

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\TicketFilterFormResource;
use App\Domains\Forms\Services\TicketFilterFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class TicketFilterFormController extends Controller
{
public function __construct(private readonly TicketFilterFormService $formService) {}
public function __invoke(Request $request): TicketFilterFormResource
{
$tenant = $request->user('sanctum')->tenant()->firstOrFail();
return TicketFilterFormResource::make($this->formService->get($tenant));
}
}

View File

@@ -1,22 +0,0 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\TicketFormResource;
use App\Domains\Forms\Services\TicketFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
class TicketFormController extends Controller
{
public function __construct(protected TicketFormService $ticketFormService) {}
public function __invoke(Request $request): TicketFormResource
{
return TicketFormResource::make(
$this->ticketFormService->get(
$request->user('sanctum')->tenant()->firstOrFail()
)
);
}
}

View File

@@ -1,26 +0,0 @@
<?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(),
];
}
}

View File

@@ -1,21 +0,0 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketFilterFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'code' => $this->resource['code'],
'action' => $this->resource['action'],
'method' => $this->resource['method'],
'fields' => $this->resource['fields'],
'columns' => $this->resource['columns'],
];
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class TicketFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'statuses' => $this->resource['statuses'],
'categories' => $this->resource['categories'],
];
}
}

View File

@@ -1,22 +0,0 @@
<?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(),
];
}
}

View File

@@ -4,7 +4,6 @@ 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;
@@ -28,17 +27,7 @@ class FoodFormService
->keyBy('codigo');
return [
'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(),
'event_dates' => $tenant->eventDates()->with('validityTime')->get(),
'schedules' => $attributes->get('horario')?->options ?? new Collection,
'services' => $attributes->get('servicio')?->options ?? new Collection,
];

View File

@@ -6,18 +6,27 @@ use App\Domains\Purchase\Models\Purchase;
class SaleFormService
{
/** @return array{statuses: list<array{value: string, label: string, real_statuses: list<string>}>} */
/** @return array{statuses: list<array{code: string, name: string}>} */
public function get(): array
{
$names = [
Purchase::STATUS_CREATED => 'Creada',
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
Purchase::STATUS_IN_REVIEW => 'En revisión',
Purchase::STATUS_PAID => 'Confirmada',
Purchase::STATUS_CANCELLED => 'Cancelada',
Purchase::STATUS_REJECTED => 'Rechazada',
Purchase::STATUS_EXPIRED => 'Vencida',
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
];
return [
'statuses' => array_map(
fn (string $code, array $definition): array => [
'value' => $code,
'label' => $definition['name'],
'real_statuses' => $definition['statuses'],
fn (string $status): array => [
'code' => $status,
'name' => $names[$status],
],
array_keys(Purchase::adminStatuses()),
array_values(Purchase::adminStatuses()),
Purchase::statuses(),
),
];
}

View File

@@ -1,151 +0,0 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
class TicketFilterFormService
{
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
public function __construct(
private readonly TicketFormService $ticketFormService,
private readonly AdminAppTicketColumnService $columnService,
) {}
/** @return array<string, mixed> */
public function get(Tenant $tenant): array
{
$fields = $this->commonFields();
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
$fields = [
...$this->fiestaFutbolInfantilFields($tenant),
...$this->commonFields(includeDate: false),
];
}
return [
'code' => 'tickets_filter',
'action' => '/api/v1/adminapp/tenant/tickets',
'method' => 'GET',
'fields' => $fields,
'columns' => $this->columnService->publicColumns($tenant),
];
}
/** @return list<array<string, mixed>> */
private function fiestaFutbolInfantilFields(Tenant $tenant): array
{
$form = $this->ticketFormService->getForFilters($tenant);
return [
[
'name' => 'category',
'query_param' => 'category',
'label' => 'Categoría',
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => 'Categoría',
'options' => array_map(
fn (array $category): array => [
'value' => $category['value'],
'label' => $category['label'],
'children' => [
[
'field' => 'product',
'disabled' => $category['products'] === [],
'options' => array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'children' => [
[
'field' => 'type',
'disabled' => $product['types'] === [],
'options' => array_map(
fn (array $type): array => [
'value' => $type['value'],
'label' => $type['label'],
'children' => [[
'field' => 'size',
'disabled' => ($type['sizes'] ?? []) === [],
'options' => $type['sizes'] ?? [],
]],
],
$product['types'],
),
],
],
],
$category['products'],
),
],
[
'field' => 'date',
'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true),
'options' => [],
],
],
],
$form['categories'],
),
],
$this->dependentSelect('product', 'Producto', 'category'),
$this->dependentSelect('type', 'Tipo', 'product'),
$this->dependentSelect('size', 'Talle', 'type'),
[
...$this->dependentSelect('date', 'Fecha', 'category'),
'type' => 'date',
],
];
}
/** @return list<array<string, mixed>> */
private function commonFields(bool $includeDate = true): array
{
return [
...($includeDate ? [[
'name' => 'date',
'query_param' => 'date',
'label' => 'Fecha',
'type' => 'date',
'required' => false,
'default' => null,
]] : []),
[
'name' => 'status',
'query_param' => 'status',
'label' => 'Estado',
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => 'Estado',
'options' => array_values(array_filter(
Ticket::statusOptions(),
fn (array $option): bool => $option['value'] !== Ticket::STATUS_CANCELLED,
)),
],
];
}
/** @return array<string, mixed> */
private function dependentSelect(string $name, string $label, string $dependency): array
{
return [
'name' => $name,
'query_param' => $name,
'label' => $label,
'type' => 'select',
'required' => false,
'default' => null,
'placeholder' => $label,
'depends_on' => $dependency,
'disabled' => true,
'options' => [],
];
}
}

View File

@@ -1,396 +0,0 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Models\Ticket;
use Illuminate\Database\Eloquent\Collection;
class TicketFormService
{
private const PRODUCT = 'product';
/**
* @var array<string, array{label: string|null, product: string, type: string|null, order: int}>
*/
private const CATEGORY_PRESENTATIONS = [
'entradas' => [
'label' => null,
'product' => self::PRODUCT,
'type' => null,
'order' => 1,
],
'alojamientos' => [
'label' => 'Camping',
'product' => 'tipo_alojamiento',
'type' => null,
'order' => 2,
],
'camping' => [
'label' => null,
'product' => 'tipo_alojamiento',
'type' => null,
'order' => 2,
],
'comidas' => [
'label' => 'Comida',
'product' => 'event_date',
'type' => 'horario',
'order' => 3,
],
'comida' => [
'label' => null,
'product' => 'event_date',
'type' => 'horario',
'order' => 3,
],
'merchandising' => [
'label' => null,
'product' => self::PRODUCT,
'type' => 'color',
'order' => 4,
],
];
/**
* @var array<string, array{label: string|null, product: string, type: string|null, size: string|null, order: int}>
*/
private const FILTER_CATEGORY_PRESENTATIONS = [
'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1],
'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4],
];
/**
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
public function get(Tenant $tenant): array
{
$items = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('has_tickets', true)
->whereHas('category')
->with($this->relations())
->orderBy('group_order')
->orderBy('nombre')
->get();
return $this->build($items, self::CATEGORY_PRESENTATIONS);
}
/**
* Return active catalog options plus soft-deleted sources still referenced by
* tickets, so historical tickets never become impossible to filter.
*
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
public function getForFilters(Tenant $tenant): array
{
$historicalVariantIds = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereNotNull('source_variant_id')
->distinct()
->pluck('source_variant_id')
->map(fn ($id): int => (int) $id)
->all();
$historicalCatalogItemIds = Ticket::query()
->where('tenant_code', $tenant->codigo)
->whereNotNull('source_catalog_item_id')
->distinct()
->pluck('source_catalog_item_id')
->map(fn ($id): int => (int) $id)
->merge(
Variant::withTrashed()
->whereKey($historicalVariantIds)
->pluck('catalog_item_id')
->map(fn ($id): int => (int) $id),
)
->unique()
->values()
->all();
$items = CatalogItem::withTrashed()
->where('tenant_code', $tenant->codigo)
->whereHas('category')
->where(function ($query) use ($historicalCatalogItemIds): void {
$query
->where(function ($activeQuery): void {
$activeQuery
->whereNull('catalog_items.deleted_at')
->where('has_tickets', true);
})
->orWhereIn('catalog_items.id', $historicalCatalogItemIds);
})
->with([
'category',
'itemAttributes.attribute.options',
'variants' => fn ($query) => $query
->withTrashed()
->where(function ($variantQuery) use ($historicalVariantIds): void {
$variantQuery
->whereNull('variantes.deleted_at')
->orWhereIn('variantes.id', $historicalVariantIds);
}),
'variants.definitions.itemAttribute.attribute.options',
'variants.eventDates',
'variants.eventDate',
])
->orderBy('group_order')
->orderBy('nombre')
->get();
return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true);
}
/**
* @param Collection<int, CatalogItem> $items
* @return array{
* statuses: list<array{value: string, label: string}>,
* categories: list<array{
* value: string,
* label: string,
* products: list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>
* }>
* }>
* }
*/
private function build(Collection $items, array $presentations, bool $includeSizes = false): array
{
$categories = [];
foreach ($items as $item) {
$sourceCategory = trim((string) $item->category?->nombre);
$categoryValue = mb_strtolower($sourceCategory);
$presentation = $presentations[$categoryValue] ?? [
'label' => null,
'product' => self::PRODUCT,
'type' => null,
'size' => null,
'order' => PHP_INT_MAX,
];
$categories[$categoryValue] ??= [
'value' => $categoryValue,
'label' => $presentation['label'] ?? $sourceCategory,
'order' => $presentation['order'],
'products' => [],
];
foreach ($this->products(
$item,
$presentation['product'],
$presentation['type'],
$presentation['size'] ?? null,
) as $product) {
$productValue = $product['value'];
$existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [
'value' => $productValue,
'label' => $product['label'],
'types' => [],
'sizes' => [],
];
foreach ($product['types'] as $type) {
$existingProduct['types'][$type['value']] = $type;
}
foreach ($product['sizes'] as $size) {
$existingProduct['sizes'][$size['value']] = $size;
}
$categories[$categoryValue]['products'][$productValue] = $existingProduct;
}
}
uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order']
?: $left['label'] <=> $right['label']);
return [
'statuses' => Ticket::statusOptions(),
'categories' => array_values(array_map(
fn (array $category): array => [
'value' => $category['value'],
'label' => $category['label'],
'products' => array_values(array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'types' => array_values($product['types']),
...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []),
],
$category['products'],
)),
],
$categories,
)),
];
}
/** @return list<string> */
private function relations(): array
{
return [
'category',
'itemAttributes.attribute.options',
'variants.definitions.itemAttribute.attribute.options',
'variants.eventDates',
'variants.eventDate',
];
}
/**
* @return list<array{
* value: string,
* label: string,
* types: list<array{value: string, label: string}>,
* sizes: list<array{value: string, label: string}>
* }>
*/
private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array
{
if ($productCode === self::PRODUCT) {
return [[
'value' => $item->slug,
'label' => $item->nombre,
'types' => $this->types($item, $typeCode, $sizeCode),
'sizes' => $this->types($item, $sizeCode),
]];
}
$products = [];
foreach ($item->variants as $variant) {
foreach ($this->variantOptions($variant, $productCode) as $productOption) {
$productValue = $productOption['value'];
$products[$productValue] ??= [
'value' => $productValue,
'label' => $this->optionLabel($productOption['label'], $productCode),
'types' => [],
'sizes' => [],
];
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
$this->mergeTypeOption(
$products[$productValue]['types'],
$typeOption,
$variant,
$sizeCode,
);
}
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
$products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption;
}
}
}
return array_values(array_map(
fn (array $product): array => [
'value' => $product['value'],
'label' => $product['label'],
'types' => array_values($product['types']),
'sizes' => array_values($product['sizes']),
],
$products,
));
}
/** @return list<array<string, mixed>> */
private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array
{
$types = [];
foreach ($item->variants as $variant) {
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
$this->mergeTypeOption($types, $typeOption, $variant, $sizeCode);
}
}
return array_values(array_map(function (array $type) use ($sizeCode): array {
if ($sizeCode !== null) {
$type['sizes'] = array_values($type['sizes']);
}
return $type;
}, $types));
}
/**
* @param array<string, array<string, mixed>> $types
* @param array{value: string, label: string} $typeOption
*/
private function mergeTypeOption(
array &$types,
array $typeOption,
Variant $variant,
?string $sizeCode,
): void {
$typeValue = $typeOption['value'];
$types[$typeValue] ??= [
...$typeOption,
...($sizeCode !== null ? ['sizes' => []] : []),
];
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
$types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption;
}
}
/** @return list<array{value: string, label: string}> */
private function variantOptions(Variant $variant, ?string $attributeCode): array
{
if ($attributeCode === null) {
return [];
}
$selection = $variant->selectionOptions($variant->catalogItem->itemAttributes)
->get($attributeCode);
if ($selection === null) {
return [];
}
return array_is_list($selection) ? $selection : [$selection];
}
private function optionLabel(string $label, string $attributeCode): string
{
if ($attributeCode !== 'event_date') {
return $label;
}
[$day, $month] = array_pad(explode('/', $label), 2, null);
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
}
}

View File

@@ -9,7 +9,6 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil.
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
@@ -20,7 +19,6 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`:
- `GET /event`.
- `GET /sale`.
- `GET /staff`.
- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets.
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
## Dependencias

View File

@@ -1,13 +1,10 @@
<?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;
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
use App\Domains\Forms\Controllers\AdminApp\TicketFilterFormController;
use App\Domains\Forms\Controllers\AdminApp\TicketFormController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms')
@@ -16,17 +13,6 @@ Route::prefix('v1/adminapp/forms')
Route::get('event', EventFormController::class);
Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class);
Route::get('tickets-filter', TicketFilterFormController::class)
->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.forms.tickets-filter');
Route::get(
'fiesta-futbol-infantil/ticket',
TicketFormController::class
);
Route::get(
'fiesta-futbol-infantil/entry',
EntryFormController::class
);
Route::get(
'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class

View File

@@ -4,9 +4,8 @@ namespace App\Domains\Integration\Controllers;
use App\Domains\Client\Models\Client;
use App\Domains\Integration\Models\Integration;
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
use App\Domains\Integration\Requests\StoreClientIntegrationRequest;
use App\Domains\Integration\Services\ClientIntegrationService;
use App\Domains\Integration\Services\IntegrationAssociationService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -36,7 +35,7 @@ class ClientIntegrationController extends Controller
}
public function store(
ConfigureIntegrationRequest $request,
StoreClientIntegrationRequest $request,
Client $client,
string $integrationCode,
): JsonResponse {
@@ -62,11 +61,4 @@ class ClientIntegrationController extends Controller
], 400);
}
}
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
{
$service->detach($client, $integrationCode);
return response()->noContent();
}
}

View File

@@ -35,7 +35,6 @@ 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();

View File

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

View File

@@ -3,15 +3,22 @@
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_instance_id',
'integration_data',
];
protected $casts = [
'integration_data' => EncryptedIntegrationData::class,
];
/** @return BelongsTo<Client, $this> */
@@ -25,10 +32,4 @@ 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);
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Integration\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class Integration extends Model
{
@@ -14,29 +13,16 @@ class Integration extends Model
'name',
'url',
'integration_data_schema',
'requires_configuration',
'requires_client_configuration',
];
protected $casts = [
'integration_data_schema' => 'array',
'requires_configuration' => 'boolean',
'requires_client_configuration' => 'boolean',
];
/** @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
public function clientIntegrations()
{
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');
}
}

View File

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

View File

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

View File

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

View File

@@ -6,19 +6,20 @@ use App\Domains\Integration\Models\Integration;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\ValidationException;
class ConfigureIntegrationRequest extends FormRequest
class StoreClientIntegrationRequest extends FormRequest
{
protected ?Integration $integrationModel = null;
public function authorize(): bool
{
return $this->user()?->can('manage', Integration::class) ?? false;
return true;
}
protected function prepareForValidation(): void
{
$integrationCode = $this->route('integration_code');
$this->integrationModel = Integration::query()
->where('integration_code', $this->route('integration_code'))
->where('integration_code', $integrationCode)
->first();
if (! $this->integrationModel) {
@@ -30,7 +31,7 @@ class ConfigureIntegrationRequest extends FormRequest
public function rules(): array
{
$rules = ['integration_data' => ['present', 'array']];
$rules = [];
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
$rules['integration_data.'.$field] = $rule;

View File

@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
'name' => ['required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_configuration' => ['sometimes', 'boolean'],
'requires_client_configuration' => ['sometimes', 'boolean'],
];
}
}

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Integration\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateIntegrationRequest extends FormRequest
{
@@ -20,8 +19,9 @@ class UpdateIntegrationRequest extends FormRequest
'name' => ['sometimes', 'required', 'string', 'max:255'],
'url' => ['nullable', 'url', 'max:255'],
'integration_data_schema' => ['nullable', 'array'],
'requires_configuration' => ['sometimes', 'boolean'],
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
'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 ?? '')],
];
}
}

View File

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

View File

@@ -1,20 +0,0 @@
<?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,
];
}
}

View File

@@ -5,8 +5,6 @@ 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;
@@ -34,9 +32,9 @@ abstract class BaseIntegrationService
protected ?Integration $integration = null;
/**
* The effective integration instance.
* The client-owned integration configuration.
*/
protected ?IntegrationInstance $integrationInstance = null;
protected ?ClientIntegration $clientIntegration = null;
/**
* Set the integration code.
@@ -87,7 +85,7 @@ abstract class BaseIntegrationService
}
/**
* Load the integration definition and its effective instance configuration.
* Load the integration definition and its client-owned configuration.
*
* @throws Exception
*/
@@ -97,7 +95,6 @@ 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.");
@@ -107,18 +104,11 @@ abstract class BaseIntegrationService
throw new Exception('Client context is not set.');
}
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
$this->clientIntegration = ClientIntegration::where('client_id', $this->clientContext->id)
->where('integration_code', $this->integrationCode)
->first()?->integrationInstance;
->first();
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) {
if (! $this->clientIntegration && $this->integration->requires_client_configuration) {
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
}
}
@@ -141,15 +131,15 @@ abstract class BaseIntegrationService
}
/**
* Get an integration setting from the effective instance configuration.
* Get an integration setting from the client-owned configuration.
*/
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
{
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
if (! $this->clientIntegration || ! $this->clientIntegration->integration_data) {
return $default;
}
return $this->integrationInstance->integration_data[$key] ?? $default;
return $this->clientIntegration->integration_data[$key] ?? $default;
}
/**

View File

@@ -13,7 +13,6 @@ class ClientIntegrationService
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
{
return $client->integrations()
->with(['integration', 'integrationInstance'])
->where('integration_code', $integrationCode)
->first();
}
@@ -21,7 +20,7 @@ class ClientIntegrationService
/** @return Collection<int, ClientIntegration> */
public function getAllForClient(Client $client): Collection
{
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
return $client->integrations()->with('integration')->get();
}
public function updateOrCreateIntegration(
@@ -30,7 +29,13 @@ class ClientIntegrationService
array $data,
): ClientIntegration {
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
$clientIntegration = ClientIntegration::query()->updateOrCreate(
[
'client_id' => $client->id,
'integration_code' => $integration->integration_code,
],
['integration_data' => $data],
);
$service = $this->resolveService($integration->integration_code);
$service?->forClient($client)->onSetup();

Some files were not shown because too many files have changed in this diff Show More