Compare commits
44 Commits
ace3f136e4
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| e0f0fb1a72 | |||
| 02cf3f3773 | |||
| b294e5c46e | |||
| d02b0c5551 | |||
| ee911171be | |||
| e17d1fa15e | |||
| 2a15dc92be | |||
| 3a039a6055 | |||
| 84e45bb964 | |||
| 8aa3a26ee7 | |||
| 1de08c1ca4 | |||
| 3f9bcd84c4 | |||
| 96f26e2e9d | |||
| c4b317d5fc | |||
| 3b2977a330 | |||
| 3b36abd46d | |||
| 12697c2268 | |||
| f4638bc984 | |||
| c3713c62a6 | |||
| 4a51f3afde | |||
| f1df6a5918 | |||
| dbeaf19513 | |||
| dd76d5d951 | |||
| f603a82b5e | |||
| b478c23f3b | |||
| 6858b387c3 | |||
| 893bea1492 | |||
| ec1d80dff2 | |||
| 3968a10f6a | |||
| cf2beb2ff5 | |||
| 484fa204b3 | |||
| 6dd057874a | |||
| 40cbac17c6 | |||
| 45e56c43a6 | |||
| cbb7b1c3a1 | |||
| 6b3a12b624 | |||
| 40a7dece11 | |||
| 5ec65ce3d3 | |||
| b7e1332b8c | |||
| a087a511e0 | |||
| a20b1713d3 | |||
| d56d387933 | |||
| cba32e90e7 | |||
| a674bcfabc |
29
app/Domains/Attachable/documentacion/README.md
Normal file
29
app/Domains/Attachable/documentacion/README.md
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
# Dominio Attachable
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Centraliza el almacenamiento y la metadata de archivos adjuntos. Acepta archivos subidos o contenido Base64, los persiste en S3 y registra su tipo, MIME, extensión, tamaño, nombre original y clave única.
|
||||||
|
|
||||||
|
## Componentes principales
|
||||||
|
|
||||||
|
- `Models/Attachment.php`: representa un adjunto y genera URL temporales de acceso.
|
||||||
|
- `Services/AttachmentService.php`: almacena, copia y elimina archivos, compensando en S3 si falla la escritura en base de datos.
|
||||||
|
- `Enums/AttachmentType.php`: clasifica imágenes, videos, PDF, audio, documentos y otros archivos.
|
||||||
|
- `Exceptions/AttachmentStorageException.php`: expresa fallos propios del almacenamiento.
|
||||||
|
|
||||||
|
## Flujo principal
|
||||||
|
|
||||||
|
1. El consumidor entrega un `UploadedFile` o una cadena Base64 y un directorio.
|
||||||
|
2. El servicio valida el contenido, detecta MIME/extensión y genera una clave UUID.
|
||||||
|
3. El archivo se guarda en el disco `s3`.
|
||||||
|
4. Se crea el registro `Attachment`; ante error se elimina el objeto que había sido subido.
|
||||||
|
|
||||||
|
## API y dependencias
|
||||||
|
|
||||||
|
No expone rutas HTTP propias. Lo consumen otros dominios, especialmente `Catalog` y `Tenant`. Depende de Laravel Storage, Symfony Mime y del modelo `Attachment`.
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
- El directorio no puede quedar vacío después de normalizarlo.
|
||||||
|
- La eliminación se considera fallida si S3 no confirma el borrado.
|
||||||
|
- Las URL generadas son temporales; el vencimiento predeterminado es de 10 minutos.
|
||||||
42
app/Domains/Auth/Controllers/AdminAppLoginController.php
Normal file
42
app/Domains/Auth/Controllers/AdminAppLoginController.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Requests\AdminAppLoginRequest;
|
||||||
|
use App\Domains\Auth\Resources\UserResource;
|
||||||
|
use App\Domains\Auth\Services\PasswordLoginService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class AdminAppLoginController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly PasswordLoginService $passwordLoginService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(AdminAppLoginRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$credentials = $request->validated();
|
||||||
|
$user = $this->passwordLoginService->authenticateAdminApp(
|
||||||
|
$credentials['email'],
|
||||||
|
$credentials['password'],
|
||||||
|
$request->ip(),
|
||||||
|
$request->userAgent(),
|
||||||
|
);
|
||||||
|
|
||||||
|
$expirationMinutes = (int) config('sanctum.expiration');
|
||||||
|
$token = $user->createToken(
|
||||||
|
'adminapp-token',
|
||||||
|
['adminapp'],
|
||||||
|
now()->addMinutes($expirationMinutes),
|
||||||
|
)->plainTextToken;
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'auth.login_success',
|
||||||
|
'message' => __('api.auth.login_success'),
|
||||||
|
'token' => $token,
|
||||||
|
'token_type' => 'Bearer',
|
||||||
|
'user' => UserResource::make($user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Auth/Controllers/AdminAppMeController.php
Normal file
26
app/Domains/Auth/Controllers/AdminAppMeController.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Auth\Resources\AdminAppMeResource;
|
||||||
|
use App\Domains\Auth\Services\AdminAppContextService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class AdminAppMeController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly AdminAppContextService $adminAppContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): AdminAppMeResource
|
||||||
|
{
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
return AdminAppMeResource::make(
|
||||||
|
$this->adminAppContextService->load($user)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'codigo', 'status'])]
|
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
||||||
#[Hidden(['codigo'])]
|
#[Hidden(['codigo'])]
|
||||||
class ResetPasswordAttempt extends Model
|
class ResetPasswordAttempt extends Model
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,22 +2,32 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Models;
|
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\Tenant\Models\Tenant;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
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\Relations\HasMany;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id'])]
|
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable;
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
];
|
||||||
|
|
||||||
protected static function newFactory(): UserFactory
|
protected static function newFactory(): UserFactory
|
||||||
{
|
{
|
||||||
return UserFactory::new();
|
return UserFactory::new();
|
||||||
@@ -35,6 +45,33 @@ class User extends Authenticatable
|
|||||||
return $this->hasMany(LoginAttempt::class);
|
return $this->hasMany(LoginAttempt::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Role, $this>
|
||||||
|
*/
|
||||||
|
public function role(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Tenant, $this>
|
||||||
|
*/
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<Category, $this> */
|
||||||
|
public function scanCategories(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Category::class,
|
||||||
|
'category_scanners',
|
||||||
|
'user_id',
|
||||||
|
'categoria_id',
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
36
app/Domains/Auth/Requests/AdminAppLoginRequest.php
Normal file
36
app/Domains/Auth/Requests/AdminAppLoginRequest.php
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
|
class AdminAppLoginRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$email = $this->input('email');
|
||||||
|
|
||||||
|
if (is_string($email)) {
|
||||||
|
$this->merge([
|
||||||
|
'email' => Str::lower(trim($email)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'email' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'password' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Domains/Auth/Resources/AdminAppMeResource.php
Normal file
25
app/Domains/Auth/Resources/AdminAppMeResource.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @mixin User
|
||||||
|
*/
|
||||||
|
class AdminAppMeResource extends JsonResource
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'user' => UserResource::make($this->resource),
|
||||||
|
'tenant' => TenantResource::make($this->tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Resources;
|
namespace App\Domains\Auth\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @mixin \App\Domains\Auth\Models\User
|
* @mixin User
|
||||||
*/
|
*/
|
||||||
class UserResource extends JsonResource
|
class UserResource extends JsonResource
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,8 @@ class UserResource extends JsonResource
|
|||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
app/Domains/Auth/Services/AdminAppContextService.php
Normal file
26
app/Domains/Auth/Services/AdminAppContextService.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
|
||||||
|
class AdminAppContextService
|
||||||
|
{
|
||||||
|
public function load(User $user): User
|
||||||
|
{
|
||||||
|
$tenant = $user->tenant()
|
||||||
|
->with([
|
||||||
|
'menues' => fn ($query) => $query
|
||||||
|
->whereHas(
|
||||||
|
'roles',
|
||||||
|
fn ($query) => $query->where('codigo', RoleCode::AdminApp->value)
|
||||||
|
),
|
||||||
|
])
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$user->setRelation('tenant', $tenant);
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,13 +5,19 @@ namespace App\Domains\Auth\Services;
|
|||||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||||
use App\Domains\Auth\Models\LoginAttempt;
|
use App\Domains\Auth\Models\LoginAttempt;
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
class PasswordLoginService
|
class PasswordLoginService
|
||||||
{
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @throws AccountLockedException
|
* @throws AccountLockedException
|
||||||
* @throws ValidationException
|
* @throws ValidationException
|
||||||
@@ -22,6 +28,48 @@ class PasswordLoginService
|
|||||||
string $tenantCode,
|
string $tenantCode,
|
||||||
?string $ipAddress,
|
?string $ipAddress,
|
||||||
?string $userAgent,
|
?string $userAgent,
|
||||||
|
): User {
|
||||||
|
return $this->authenticateUser(
|
||||||
|
$email,
|
||||||
|
$password,
|
||||||
|
$tenantCode,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authenticate a tenant-bound AdminApp user without requiring the caller
|
||||||
|
* to know their tenant code beforehand.
|
||||||
|
*
|
||||||
|
* @throws AccountLockedException
|
||||||
|
* @throws ValidationException
|
||||||
|
*/
|
||||||
|
public function authenticateAdminApp(
|
||||||
|
string $email,
|
||||||
|
string $password,
|
||||||
|
?string $ipAddress,
|
||||||
|
?string $userAgent,
|
||||||
|
): User {
|
||||||
|
return $this->authenticateUser(
|
||||||
|
$email,
|
||||||
|
$password,
|
||||||
|
null,
|
||||||
|
$ipAddress,
|
||||||
|
$userAgent,
|
||||||
|
RoleCode::AdminApp,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function authenticateUser(
|
||||||
|
string $email,
|
||||||
|
string $password,
|
||||||
|
?string $tenantCode,
|
||||||
|
?string $ipAddress,
|
||||||
|
?string $userAgent,
|
||||||
|
RoleCode $requiredRole = RoleCode::User,
|
||||||
|
bool $requiresTenant = false,
|
||||||
): User {
|
): User {
|
||||||
$normalizedEmail = mb_strtolower(trim($email));
|
$normalizedEmail = mb_strtolower(trim($email));
|
||||||
$now = CarbonImmutable::now();
|
$now = CarbonImmutable::now();
|
||||||
@@ -34,17 +82,25 @@ class PasswordLoginService
|
|||||||
$ipAddress,
|
$ipAddress,
|
||||||
$userAgent,
|
$userAgent,
|
||||||
$now,
|
$now,
|
||||||
|
$requiredRole,
|
||||||
|
$requiresTenant,
|
||||||
): array {
|
): array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $normalizedEmail)
|
->where('email', $normalizedEmail)
|
||||||
|
->where('rol_codigo', $requiredRole->value)
|
||||||
|
->when(
|
||||||
|
$requiresTenant,
|
||||||
|
fn ($query) => $query->whereNotNull('tenant_codigo'),
|
||||||
|
)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
$attemptTenantCode = $tenantCode ?? $user?->tenant_codigo;
|
||||||
|
|
||||||
if ($user?->locked_until?->isFuture()) {
|
if ($user?->locked_until?->isFuture()) {
|
||||||
$this->recordAttempt(
|
$this->recordAttempt(
|
||||||
$user,
|
$user,
|
||||||
$normalizedEmail,
|
$normalizedEmail,
|
||||||
$tenantCode,
|
$attemptTenantCode,
|
||||||
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
LoginAttempt::OUTCOME_ACCOUNT_LOCKED,
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$userAgent,
|
$userAgent,
|
||||||
@@ -67,7 +123,7 @@ class PasswordLoginService
|
|||||||
|
|
||||||
if ($user === null || ! Hash::check($password, $user->password)) {
|
if ($user === null || ! Hash::check($password, $user->password)) {
|
||||||
if ($user !== null) {
|
if ($user !== null) {
|
||||||
$this->registerFailure($user, $now);
|
$this->registerFailure($user, $now, $tenantCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
$outcome = $user?->locked_until?->isFuture()
|
$outcome = $user?->locked_until?->isFuture()
|
||||||
@@ -76,7 +132,7 @@ class PasswordLoginService
|
|||||||
$this->recordAttempt(
|
$this->recordAttempt(
|
||||||
$user,
|
$user,
|
||||||
$normalizedEmail,
|
$normalizedEmail,
|
||||||
$tenantCode,
|
$attemptTenantCode,
|
||||||
$outcome,
|
$outcome,
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$userAgent,
|
$userAgent,
|
||||||
@@ -100,7 +156,7 @@ class PasswordLoginService
|
|||||||
$this->recordAttempt(
|
$this->recordAttempt(
|
||||||
$user,
|
$user,
|
||||||
$normalizedEmail,
|
$normalizedEmail,
|
||||||
$tenantCode,
|
$attemptTenantCode,
|
||||||
LoginAttempt::OUTCOME_SUCCESS,
|
LoginAttempt::OUTCOME_SUCCESS,
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$userAgent,
|
$userAgent,
|
||||||
@@ -126,7 +182,7 @@ class PasswordLoginService
|
|||||||
return $result['user'];
|
return $result['user'];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function registerFailure(User $user, CarbonImmutable $now): void
|
private function registerFailure(User $user, CarbonImmutable $now, string $tenantCode): void
|
||||||
{
|
{
|
||||||
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
$windowMinutes = max(1, (int) config('login-security.attempt_window_minutes'));
|
||||||
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
$maxAttempts = max(1, (int) config('login-security.max_attempts'));
|
||||||
@@ -138,6 +194,8 @@ class PasswordLoginService
|
|||||||
? $user->failed_login_attempts + 1
|
? $user->failed_login_attempts + 1
|
||||||
: 1;
|
: 1;
|
||||||
|
|
||||||
|
$previousAttempts = $user->failed_login_attempts;
|
||||||
|
|
||||||
$user->forceFill([
|
$user->forceFill([
|
||||||
'failed_login_attempts' => $attempts,
|
'failed_login_attempts' => $attempts,
|
||||||
'last_failed_login_at' => $now,
|
'last_failed_login_at' => $now,
|
||||||
@@ -145,12 +203,23 @@ class PasswordLoginService
|
|||||||
? $now->addMinutes($lockMinutes)
|
? $now->addMinutes($lockMinutes)
|
||||||
: null,
|
: null,
|
||||||
])->save();
|
])->save();
|
||||||
|
|
||||||
|
if ($attempts >= $maxAttempts && $previousAttempts < $maxAttempts) {
|
||||||
|
try {
|
||||||
|
$this->resetPasswordAttemptService->createForEmail($user->email, $tenantCode, 'account_locked');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Log::error('Failed to trigger reset password on account lock', [
|
||||||
|
'user_id' => $user->id,
|
||||||
|
'exception' => $e
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordAttempt(
|
private function recordAttempt(
|
||||||
?User $user,
|
?User $user,
|
||||||
string $normalizedEmail,
|
string $normalizedEmail,
|
||||||
string $tenantCode,
|
?string $tenantCode,
|
||||||
string $outcome,
|
string $outcome,
|
||||||
?string $ipAddress,
|
?string $ipAddress,
|
||||||
?string $userAgent,
|
?string $userAgent,
|
||||||
|
|||||||
@@ -11,12 +11,12 @@ use Throwable;
|
|||||||
|
|
||||||
class ResetPasswordAttemptService
|
class ResetPasswordAttemptService
|
||||||
{
|
{
|
||||||
public function createForEmail(string $email, string $tenantCode): void
|
public function createForEmail(string $email, string $tenantCode, string $reason = 'manual'): void
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint): ?int {
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('email', $email)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -39,6 +39,7 @@ class ResetPasswordAttemptService
|
|||||||
|
|
||||||
$attempt = $user->resetPasswordAttempts()->create([
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
'codigo' => $this->generateCode(),
|
'codigo' => $this->generateCode(),
|
||||||
|
'reason' => $reason,
|
||||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
36
app/Domains/Auth/documentacion/README.md
Normal file
36
app/Domains/Auth/documentacion/README.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Dominio Auth
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Gestiona identidad y acceso de usuarios de la tienda y del panel administrativo: registro, inicio y cierre de sesión, perfil, autenticación con Google y recuperación de contraseña.
|
||||||
|
|
||||||
|
## Modelo y servicios
|
||||||
|
|
||||||
|
- `User`: usuario autenticable, asociado a tenant, rol, intentos de acceso y categorías habilitadas para escaneo.
|
||||||
|
- `LoginAttempt` y `ResetPasswordAttempt`: trazabilidad de accesos y recuperación de contraseña.
|
||||||
|
- `PasswordLoginService`: autentica tienda y AdminApp, incluyendo bloqueo por intentos.
|
||||||
|
- `RegisterUserService` y `ProfileService`: alta y edición del usuario.
|
||||||
|
- `ResetPasswordAttemptService`: crea, valida y consume códigos de recuperación.
|
||||||
|
- `GoogleAuthService`: redirección, callback e intercambio de código para Google OAuth.
|
||||||
|
- `AdminAppContextService`: carga el contexto requerido por un usuario administrativo.
|
||||||
|
|
||||||
|
## Endpoints públicos
|
||||||
|
|
||||||
|
- `POST /register`, `POST /login` y `POST /logout`.
|
||||||
|
- `GET /me` y `PUT /me`, protegidos por `auth:sanctum`.
|
||||||
|
- Creación, validación y aplicación de intentos de recuperación bajo `/password`.
|
||||||
|
- `POST /auth/google/exchange` para canjear el código de autenticación.
|
||||||
|
- `POST /v1/adminapp/login` y consulta del usuario administrativo dentro del grupo autenticado de AdminApp.
|
||||||
|
|
||||||
|
## Validación y respuestas
|
||||||
|
|
||||||
|
Los `FormRequest` validan cada operación. `UserResource` y `AdminAppMeResource` definen las representaciones de salida. Los endpoints sensibles aplican `auth:sanctum` y límites de frecuencia.
|
||||||
|
|
||||||
|
## Dependencias y eventos
|
||||||
|
|
||||||
|
Se relaciona con `Tenant` y `Authorization`; el registro y la recuperación disparan flujos atendidos por `Notification`. El carrito invitado puede integrarse al usuario autenticado mediante el dominio `Cart`.
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
- La resolución del tenant forma parte de la autenticación y no debe omitirse.
|
||||||
|
- Los cambios en reglas de login deben conservar los límites de intentos y el manejo de `AccountLockedException`.
|
||||||
11
app/Domains/Auth/routes/adminapp.php
Normal file
11
app/Domains/Auth/routes/adminapp.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Controllers\AdminAppLoginController;
|
||||||
|
use App\Domains\Auth\Controllers\AdminAppMeController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp')->group(function (): void {
|
||||||
|
Route::post('login', AdminAppLoginController::class)->middleware('throttle:login');
|
||||||
|
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->get('me', AdminAppMeController::class);
|
||||||
|
});
|
||||||
@@ -23,3 +23,5 @@ Route::post('/auth/google/exchange', GoogleTokenExchangeController::class);
|
|||||||
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
|
||||||
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
Route::middleware('auth:sanctum')->get('/me', MeController::class);
|
||||||
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
|
|||||||
11
app/Domains/Authorization/Enums/RoleCode.php
Normal file
11
app/Domains/Authorization/Enums/RoleCode.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Authorization\Enums;
|
||||||
|
|
||||||
|
enum RoleCode: string
|
||||||
|
{
|
||||||
|
case Admin = 'admin';
|
||||||
|
case AdminApp = 'adminapp';
|
||||||
|
case Scanner = 'scanner';
|
||||||
|
case User = 'user';
|
||||||
|
}
|
||||||
32
app/Domains/Authorization/Models/Permission.php
Normal file
32
app/Domains/Authorization/Models/Permission.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Authorization\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
|
||||||
|
class Permission extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'permisos';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'codigo',
|
||||||
|
'nombre',
|
||||||
|
'descripcion',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Role, $this>
|
||||||
|
*/
|
||||||
|
public function roles(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Role::class,
|
||||||
|
'roles_permisos',
|
||||||
|
'codigo_permiso',
|
||||||
|
'rol_codigo',
|
||||||
|
'codigo',
|
||||||
|
'codigo'
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
|
}
|
||||||
59
app/Domains/Authorization/Models/Role.php
Normal file
59
app/Domains/Authorization/Models/Role.php
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Authorization\Models;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use App\Domains\Menu\Models\MenuRole;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class Role extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'roles';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'codigo',
|
||||||
|
'nombre',
|
||||||
|
'descripcion',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Permission, $this>
|
||||||
|
*/
|
||||||
|
public function permissions(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Permission::class,
|
||||||
|
'roles_permisos',
|
||||||
|
'rol_codigo',
|
||||||
|
'codigo_permiso',
|
||||||
|
'codigo',
|
||||||
|
'codigo'
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<User, $this>
|
||||||
|
*/
|
||||||
|
public function users(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(User::class, 'rol_codigo', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsToMany<Menu, $this>
|
||||||
|
*/
|
||||||
|
public function menus(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Menu::class,
|
||||||
|
'roles_menues',
|
||||||
|
'rol_codigo',
|
||||||
|
'menu_codigo',
|
||||||
|
'codigo',
|
||||||
|
'code'
|
||||||
|
)->using(MenuRole::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Domains/Authorization/Models/RolePermission.php
Normal file
32
app/Domains/Authorization/Models/RolePermission.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Authorization\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class RolePermission extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'roles_permisos';
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'codigo_permiso',
|
||||||
|
'rol_codigo',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Permission, $this>
|
||||||
|
*/
|
||||||
|
public function permission(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Permission::class, 'codigo_permiso', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return BelongsTo<Role, $this>
|
||||||
|
*/
|
||||||
|
public function role(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Role::class, 'rol_codigo', 'codigo');
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Authorization/documentacion/README.md
Normal file
26
app/Domains/Authorization/documentacion/README.md
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Dominio Authorization
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Define el esquema de roles y permisos usado para autorizar funcionalidades de la aplicación.
|
||||||
|
|
||||||
|
## Componentes principales
|
||||||
|
|
||||||
|
- `Enums/RoleCode.php`: códigos de roles conocidos por el sistema.
|
||||||
|
- `Models/Role.php`: rol con relaciones hacia permisos, usuarios y menús.
|
||||||
|
- `Models/Permission.php`: permiso asignable a uno o más roles.
|
||||||
|
- `Models/RolePermission.php`: entidad de asociación entre rol y permiso.
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
No expone controladores ni rutas propias. Su información se consume desde autenticación, menús, políticas y middleware de autorización.
|
||||||
|
|
||||||
|
## Relaciones relevantes
|
||||||
|
|
||||||
|
- `Role` tiene muchos usuarios del dominio `Auth`.
|
||||||
|
- Roles y permisos mantienen una relación muchos-a-muchos.
|
||||||
|
- Los roles determinan los menús disponibles mediante el dominio `Menu`.
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
Los códigos definidos en `RoleCode` funcionan como contrato entre datos persistidos y lógica de aplicación. Al agregar un rol o permiso se deben revisar seeds, asociaciones y consumidores.
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Bootstrap\Requests\AdminAppBootstrapRequest;
|
||||||
|
use App\Domains\Bootstrap\Resources\AdminAppBootstrapResource;
|
||||||
|
use App\Domains\Bootstrap\Services\AdminAppBootstrapService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class AdminAppBootstrapController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected AdminAppBootstrapService $bootstrapService) {}
|
||||||
|
|
||||||
|
public function __invoke(AdminAppBootstrapRequest $request): AdminAppBootstrapResource
|
||||||
|
{
|
||||||
|
return AdminAppBootstrapResource::make(
|
||||||
|
$this->bootstrapService->get((string) $request->validated('dominio'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Bootstrap\Requests\TenantBootstrapRequest;
|
||||||
|
use App\Domains\Bootstrap\Services\TenantBootstrapService;
|
||||||
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class TenantBootstrapController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected TenantBootstrapService $bootstrapService) {}
|
||||||
|
|
||||||
|
public function __invoke(TenantBootstrapRequest $request): TenantResource
|
||||||
|
{
|
||||||
|
return TenantResource::make(
|
||||||
|
$this->bootstrapService->get((string) $request->validated('dominio'))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Requests;
|
||||||
|
|
||||||
|
class AdminAppBootstrapRequest extends TenantBootstrapRequest {}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
namespace App\Domains\Bootstrap\Requests;
|
||||||
|
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class BootstrapTenantRequest extends FormRequest
|
class TenantBootstrapRequest extends FormRequest
|
||||||
{
|
{
|
||||||
protected bool $hasInvalidDomain = false;
|
protected bool $hasInvalidDomain = false;
|
||||||
|
|
||||||
@@ -23,14 +23,10 @@ class BootstrapTenantRequest extends FormRequest
|
|||||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
||||||
&& $normalizedDomain === null;
|
&& $normalizedDomain === null;
|
||||||
|
|
||||||
$this->merge([
|
$this->merge(['dominio' => $normalizedDomain]);
|
||||||
'dominio' => $normalizedDomain,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return array<string, mixed> */
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin array{website_type: WebsiteType} */
|
||||||
|
class AdminAppBootstrapResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
/** @var WebsiteType $websiteType */
|
||||||
|
$websiteType = $this->resource['website_type'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'website_type_code' => $websiteType->codigo,
|
||||||
|
'primary_color' => $websiteType->primary_color,
|
||||||
|
'secondary_color' => $websiteType->secondary_color,
|
||||||
|
'danger_color' => $websiteType->danger_color,
|
||||||
|
'success_color' => $websiteType->success_color,
|
||||||
|
'warning_color' => $websiteType->warning_color,
|
||||||
|
'body_color' => $websiteType->body_color,
|
||||||
|
'darker_body_color' => $websiteType->darker_body_color,
|
||||||
|
'surface_color' => $websiteType->surface_color,
|
||||||
|
'background_color' => $websiteType->background_color,
|
||||||
|
'border_color' => $websiteType->border_color,
|
||||||
|
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||||
|
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||||
|
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Domains/Bootstrap/Services/AdminAppBootstrapService.php
Normal file
19
app/Domains/Bootstrap/Services/AdminAppBootstrapService.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
|
||||||
|
class AdminAppBootstrapService
|
||||||
|
{
|
||||||
|
/** @return array{website_type: WebsiteType} */
|
||||||
|
public function get(string $domain): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'website_type' => WebsiteType::query()
|
||||||
|
->with(['siteLogo', 'footerLogo'])
|
||||||
|
->where('dominio', $domain)
|
||||||
|
->firstOrFail(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Bootstrap/Services/TenantBootstrapService.php
Normal file
26
app/Domains/Bootstrap/Services/TenantBootstrapService.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Bootstrap\Services;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Services\TenantInformationService;
|
||||||
|
|
||||||
|
class TenantBootstrapService
|
||||||
|
{
|
||||||
|
public function __construct(protected TenantInformationService $tenantInformationService) {}
|
||||||
|
|
||||||
|
public function get(string $domain): Tenant
|
||||||
|
{
|
||||||
|
return $this->tenantInformationService->load(
|
||||||
|
Tenant::query()->where('dominio', $domain)->firstOrFail(),
|
||||||
|
[
|
||||||
|
'menues' => fn ($query) => $query->whereHas(
|
||||||
|
'roles',
|
||||||
|
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
||||||
|
),
|
||||||
|
'categories' => fn ($query) => $query->orderBy('nombre'),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Domains/Bootstrap/documentacion/README.md
Normal file
28
app/Domains/Bootstrap/documentacion/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Dominio Bootstrap
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Entrega la configuración inicial que necesitan la tienda y el panel administrativo antes de renderizar su interfaz.
|
||||||
|
|
||||||
|
## Flujos
|
||||||
|
|
||||||
|
- `TenantBootstrapService` resuelve un tenant desde el dominio solicitado y carga su información pública.
|
||||||
|
- `AdminAppBootstrapService` prepara el contexto inicial del panel administrativo para el tenant autenticado.
|
||||||
|
- Los controladores invocables transforman el resultado mediante `TenantResource` o `AdminAppBootstrapResource`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- `GET /tenants/bootstrap/{dominio}`: bootstrap público de la tienda.
|
||||||
|
- Endpoint de bootstrap bajo `/v1/adminapp`, protegido por `auth:sanctum` y `adminapp.tenant`.
|
||||||
|
|
||||||
|
## Validación
|
||||||
|
|
||||||
|
`TenantBootstrapRequest` valida el dominio recibido. `AdminAppBootstrapRequest` reutiliza ese contrato para el panel.
|
||||||
|
|
||||||
|
## Dependencias
|
||||||
|
|
||||||
|
Depende principalmente de `Tenant` para resolver y cargar la tienda, y de los dominios que aportan datos al contexto administrativo.
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
Este dominio es un agregador de lectura. Debe mantenerse liviano y delegar la obtención de cada dato al dominio propietario.
|
||||||
9
app/Domains/Bootstrap/routes/adminapp.php
Normal file
9
app/Domains/Bootstrap/routes/adminapp.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Bootstrap\Controllers\AdminAppBootstrapController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get(
|
||||||
|
'v1/adminapp/bootstrap/{dominio}',
|
||||||
|
AdminAppBootstrapController::class
|
||||||
|
);
|
||||||
9
app/Domains/Bootstrap/routes/api.php
Normal file
9
app/Domains/Bootstrap/routes/api.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Bootstrap\Controllers\TenantBootstrapController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::get('tenants/bootstrap/{dominio}', TenantBootstrapController::class)
|
||||||
|
->where('dominio', '.*');
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
@@ -54,16 +54,26 @@ class CartController extends Controller
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
CartItem $cartItem,
|
CartItem $cartItem,
|
||||||
): CartResource {
|
): CartResource {
|
||||||
|
$updatesVariant = $request->exists('variant_id');
|
||||||
|
|
||||||
return CartResource::make(
|
return CartResource::make(
|
||||||
$this->cartService->updateItemQuantity(
|
$this->cartService->updateItem(
|
||||||
$tenant,
|
$tenant,
|
||||||
$request,
|
$request,
|
||||||
$cartItem->getKey(),
|
$cartItem->getKey(),
|
||||||
(int) $request->validated('cantidad'),
|
(int) $request->validated('cantidad'),
|
||||||
|
$updatesVariant
|
||||||
|
? ($request->validated('variant_id') !== null
|
||||||
|
? (int) $request->validated('variant_id')
|
||||||
|
: null)
|
||||||
|
: $cartItem->variant_id,
|
||||||
|
$updatesVariant,
|
||||||
)
|
)
|
||||||
)->additional([
|
)->additional([
|
||||||
'code' => 'cart.quantity_updated',
|
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
|
||||||
'message' => __('api.cart.quantity_updated'),
|
'message' => $updatesVariant
|
||||||
|
? __('api.cart.item_updated')
|
||||||
|
: __('api.cart.quantity_updated'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Domains\Catalog\Models\CatalogItem;
|
|||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
@@ -86,6 +87,10 @@ class Cart extends Model
|
|||||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||||
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
|
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
|
||||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||||
|
$cartQuantity = (int) $this->items()
|
||||||
|
->where('catalog_item_id', $catalogItemId)
|
||||||
|
->sum('cantidad');
|
||||||
|
$this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity);
|
||||||
$inventoryService = app(CatalogInventoryService::class);
|
$inventoryService = app(CatalogInventoryService::class);
|
||||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||||
|
|
||||||
@@ -119,29 +124,99 @@ class Cart extends Model
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateItem(int $cartItemId, int $quantity): CartItem
|
public function updateItem(
|
||||||
{
|
int $cartItemId,
|
||||||
|
int $quantity,
|
||||||
|
?int $variantId = null,
|
||||||
|
bool $updateVariant = false,
|
||||||
|
): CartItem {
|
||||||
if ($quantity <= 0) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cantidad' => __('api.cart.positive_quantity'),
|
'cantidad' => __('api.cart.positive_quantity'),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
|
return DB::transaction(function () use (
|
||||||
|
$cartItemId,
|
||||||
|
$quantity,
|
||||||
|
$variantId,
|
||||||
|
$updateVariant,
|
||||||
|
): CartItem {
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
->where('id', $cartItemId)
|
->where('id', $cartItemId)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$selectedItem = $this->resolveScopedItem(
|
$currentSelection = $this->resolveScopedItem(
|
||||||
$item->catalog_item_id,
|
$item->catalog_item_id,
|
||||||
$item->variant_id,
|
$item->variant_id,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
$inventoryService = app(CatalogInventoryService::class);
|
$inventoryService = app(CatalogInventoryService::class);
|
||||||
|
|
||||||
|
if ($updateVariant && $variantId !== $item->variant_id) {
|
||||||
|
$nextSelection = $this->resolveScopedItem(
|
||||||
|
$item->catalog_item_id,
|
||||||
|
$variantId,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
$otherVariantsQuantity = (int) $this->items()
|
||||||
|
->where('catalog_item_id', $item->catalog_item_id)
|
||||||
|
->whereKeyNot($item->getKey())
|
||||||
|
->sum('cantidad');
|
||||||
|
$this->assertUserPurchaseLimit(
|
||||||
|
$nextSelection,
|
||||||
|
$otherVariantsQuantity + $quantity,
|
||||||
|
);
|
||||||
|
|
||||||
|
$inventoryService->release($currentSelection, $item->cantidad);
|
||||||
|
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||||
|
|
||||||
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$targetItem = $this->items()
|
||||||
|
->where('catalog_item_id', $item->catalog_item_id)
|
||||||
|
->where('variant_id', $variantId)
|
||||||
|
->whereKeyNot($item->getKey())
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$inventoryService->reserve($nextSelection, $quantity);
|
||||||
|
|
||||||
|
if ($targetItem !== null) {
|
||||||
|
$targetItem->cantidad += $quantity;
|
||||||
|
$targetItem->save();
|
||||||
|
$item->delete();
|
||||||
|
|
||||||
|
return $targetItem->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->variant_id = $variantId;
|
||||||
|
$item->cantidad = $quantity;
|
||||||
|
$item->save();
|
||||||
|
|
||||||
|
return $item->fresh();
|
||||||
|
}
|
||||||
|
|
||||||
$delta = $quantity - $item->cantidad;
|
$delta = $quantity - $item->cantidad;
|
||||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
|
||||||
|
if ($delta > 0) {
|
||||||
|
$otherVariantsQuantity = (int) $this->items()
|
||||||
|
->where('catalog_item_id', $item->catalog_item_id)
|
||||||
|
->whereKeyNot($item->getKey())
|
||||||
|
->sum('cantidad');
|
||||||
|
$this->assertUserPurchaseLimit(
|
||||||
|
$currentSelection,
|
||||||
|
$otherVariantsQuantity + $quantity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
|
||||||
|
|
||||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||||
@@ -154,11 +229,11 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
|
|
||||||
if ($delta > 0) {
|
if ($delta > 0) {
|
||||||
$inventoryService->reserve($selectedItem, $delta);
|
$inventoryService->reserve($currentSelection, $delta);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delta < 0) {
|
if ($delta < 0) {
|
||||||
$inventoryService->release($selectedItem, abs($delta));
|
$inventoryService->release($currentSelection, abs($delta));
|
||||||
}
|
}
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
@@ -256,6 +331,26 @@ class Cart extends Model
|
|||||||
return $variant;
|
return $variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function assertUserPurchaseLimit(
|
||||||
|
CatalogItem|Variant $selectedItem,
|
||||||
|
int $cartQuantity,
|
||||||
|
): void {
|
||||||
|
if ($this->user_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogItem = $selectedItem instanceof Variant
|
||||||
|
? $selectedItem->catalogItem
|
||||||
|
: $selectedItem;
|
||||||
|
|
||||||
|
app(UserPurchaseLimitService::class)->assertCanPurchase(
|
||||||
|
$catalogItem,
|
||||||
|
$this->user_id,
|
||||||
|
$cartQuantity,
|
||||||
|
field: 'cantidad',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
|
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
|
||||||
{
|
{
|
||||||
$query = Inventory::query()->whereKey($inventoryId);
|
$query = Inventory::query()->whereKey($inventoryId);
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ class UpdateCartItemQuantityRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'cantidad' => ['required', 'integer', 'min:1'],
|
'cantidad' => ['required', 'integer', 'min:1'],
|
||||||
'catalog_item_id' => ['prohibited'],
|
'catalog_item_id' => ['prohibited'],
|
||||||
'variant_id' => ['prohibited'],
|
'variant_id' => ['sometimes', 'nullable', 'integer'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Domains\Cart\Resources;
|
namespace App\Domains\Cart\Resources;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -36,6 +38,16 @@ class CartItemResource extends JsonResource
|
|||||||
'product' => $selectedItem === null ? null : [
|
'product' => $selectedItem === null ? null : [
|
||||||
'nombre' => $selectedItem->getName(),
|
'nombre' => $selectedItem->getName(),
|
||||||
'imagen' => $imageUrl,
|
'imagen' => $imageUrl,
|
||||||
|
'variants' => $this->catalogItem->visibleVariants($this->variant_id)
|
||||||
|
->map(fn (Variant $variant): array => [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'precio' => $this->formatMoney($variant->getPrice()),
|
||||||
|
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
|
? null
|
||||||
|
: $variant->inventory->availableStock(),
|
||||||
|
'values' => $variant->selectionOptions($this->catalogItem->itemAttributes),
|
||||||
|
])
|
||||||
|
->values(),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,11 +51,17 @@ class CartService
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
|
public function updateItem(
|
||||||
{
|
Tenant $tenant,
|
||||||
|
Request $request,
|
||||||
|
int $cartItemId,
|
||||||
|
int $quantity,
|
||||||
|
?int $variantId,
|
||||||
|
bool $updateVariant,
|
||||||
|
): Cart {
|
||||||
$identity = $this->requireIdentity($request);
|
$identity = $this->requireIdentity($request);
|
||||||
$cart = $this->findCartOrFail($tenant, $identity);
|
$cart = $this->findCartOrFail($tenant, $identity);
|
||||||
$cart->updateItem($cartItemId, $quantity);
|
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
|
||||||
|
|
||||||
return $this->loadCart($cart);
|
return $this->loadCart($cart);
|
||||||
}
|
}
|
||||||
@@ -101,9 +107,16 @@ class CartService
|
|||||||
return $cart->fresh()->load([
|
return $cart->fresh()->load([
|
||||||
'items.catalogItem.attachments',
|
'items.catalogItem.attachments',
|
||||||
'items.catalogItem.inventory',
|
'items.catalogItem.inventory',
|
||||||
|
'items.catalogItem.itemAttributes.attribute',
|
||||||
|
'items.catalogItem.variants.inventory',
|
||||||
|
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
|
||||||
|
'items.catalogItem.variants.eventDates',
|
||||||
|
'items.catalogItem.variants.eventDate',
|
||||||
'items.variant.attachments',
|
'items.variant.attachments',
|
||||||
'items.variant.inventory',
|
'items.variant.inventory',
|
||||||
'items.variant.definitions.itemAttribute.attribute',
|
'items.variant.definitions.itemAttribute.attribute.options',
|
||||||
|
'items.variant.eventDates',
|
||||||
|
'items.variant.eventDate',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
32
app/Domains/Cart/documentacion/README.md
Normal file
32
app/Domains/Cart/documentacion/README.md
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Dominio Cart
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios autenticados.
|
||||||
|
|
||||||
|
## Modelo
|
||||||
|
|
||||||
|
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
|
||||||
|
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; expone la selección efectiva.
|
||||||
|
|
||||||
|
## Servicios
|
||||||
|
|
||||||
|
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
||||||
|
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Bajo `/tenants/{tenant:codigo}`:
|
||||||
|
|
||||||
|
- `GET /cart`.
|
||||||
|
- `POST /cart/items`.
|
||||||
|
- `PATCH /cart/items/{cartItem}`.
|
||||||
|
- `DELETE /cart/items/{cartItem}`.
|
||||||
|
|
||||||
|
## Contratos
|
||||||
|
|
||||||
|
`AddCartItemRequest` y `UpdateCartItemQuantityRequest` validan selección y cantidad. `CartResource` y `CartItemResource` estabilizan la respuesta pública.
|
||||||
|
|
||||||
|
## Dependencias y reglas
|
||||||
|
|
||||||
|
Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y de `Auth` cuando existe usuario. Toda operación debe comprobar que carrito e ítem pertenecen al tenant actual.
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
|
||||||
|
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
|
||||||
|
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OnTicketFeaturedGroupService $featuredGroupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return OnTicketFeaturedGroupResource::collection(
|
||||||
|
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$featuredGroup = $this->featuredGroupService->create(
|
||||||
|
$this->onTicketTenant($request),
|
||||||
|
$request->validated(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return OnTicketFeaturedGroupResource::make($featuredGroup)
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
UpsertOnTicketFeaturedGroupRequest $request,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
): OnTicketFeaturedGroupResource {
|
||||||
|
$tenant = $this->onTicketTenant($request);
|
||||||
|
|
||||||
|
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
|
return OnTicketFeaturedGroupResource::make(
|
||||||
|
$this->featuredGroupService->update(
|
||||||
|
$tenant,
|
||||||
|
$featuredGroup,
|
||||||
|
$request->validated(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onTicketTenant(Request $request): Tenant
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
abort_unless($tenant->website_type_code === 'onticket', 404);
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,34 +2,28 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers;
|
namespace App\Domains\Catalog\Controllers;
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Category;
|
use App\Domains\Catalog\Models\Category;
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\FeaturedItem;
|
|
||||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||||
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||||
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||||
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
use App\Domains\Catalog\Requests\StoreCatalogItemRequest;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
|
||||||
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
||||||
use App\Domains\Catalog\Services\CatalogService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
|
use App\Domains\Catalog\Services\FeaturedGroupService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
|
|
||||||
class CatalogController extends Controller
|
class CatalogController extends Controller
|
||||||
{
|
{
|
||||||
private const ITEMS_PER_PAGE = 12;
|
public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
|
||||||
|
|
||||||
public function index(Tenant $tenant): JsonResponse
|
|
||||||
{
|
{
|
||||||
$featuredGroups = FeaturedGroup::query()
|
$featuredGroups = FeaturedGroup::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
@@ -39,7 +33,7 @@ class CatalogController extends Controller
|
|||||||
return response()->json($featuredGroups->map(
|
return response()->json($featuredGroups->map(
|
||||||
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
||||||
$featuredGroup,
|
$featuredGroup,
|
||||||
$this->featuredItemsResponse($featuredGroup, 1),
|
$featuredGroupService->itemsResponse($featuredGroup, 1),
|
||||||
))->resolve()
|
))->resolve()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -89,12 +83,13 @@ class CatalogController extends Controller
|
|||||||
FeaturedGroupPageRequest $request,
|
FeaturedGroupPageRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
FeaturedGroup $featuredGroup,
|
FeaturedGroup $featuredGroup,
|
||||||
|
FeaturedGroupService $featuredGroupService,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
$page = (int) $request->validated('page', 1);
|
$page = (int) $request->validated('page', 1);
|
||||||
|
|
||||||
return response()->json($this->featuredItemsResponse($featuredGroup, $page));
|
return response()->json($featuredGroupService->itemsResponse($featuredGroup, $page));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(
|
public function show(
|
||||||
@@ -129,58 +124,4 @@ class CatalogController extends Controller
|
|||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<array-key, mixed> */
|
|
||||||
private function featuredItemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
|
||||||
{
|
|
||||||
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
|
|
||||||
$featuredItems = $this->featuredItemsQuery($featuredGroup)->get();
|
|
||||||
|
|
||||||
$featuredItems->each(
|
|
||||||
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
|
|
||||||
);
|
|
||||||
|
|
||||||
return CatalogFeaturedItemResource::collection($featuredItems)->resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
$paginator = $this->paginateFeaturedItems($featuredGroup, $page);
|
|
||||||
|
|
||||||
$paginator->getCollection()->each(
|
|
||||||
fn ($featuredItem) => $featuredItem->setRelation('featuredGroup', $featuredGroup)
|
|
||||||
);
|
|
||||||
|
|
||||||
return CatalogFeaturedItemResource::collection($paginator)
|
|
||||||
->response()
|
|
||||||
->getData(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function paginateFeaturedItems(
|
|
||||||
FeaturedGroup $featuredGroup,
|
|
||||||
int $page,
|
|
||||||
): LengthAwarePaginator {
|
|
||||||
$paginator = $this->featuredItemsQuery($featuredGroup)->paginate(
|
|
||||||
perPage: self::ITEMS_PER_PAGE,
|
|
||||||
pageName: 'page',
|
|
||||||
page: $page,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
|
||||||
'tenant' => $featuredGroup->tenant_code,
|
|
||||||
'featuredGroup' => $featuredGroup->id,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<FeaturedItem, FeaturedGroup> */
|
|
||||||
private function featuredItemsQuery(FeaturedGroup $featuredGroup): HasMany
|
|
||||||
{
|
|
||||||
return $featuredGroup->featuredItems()->with([
|
|
||||||
'catalogItem.inventory',
|
|
||||||
'catalogItem.attachments',
|
|
||||||
'catalogItem.variants.inventory',
|
|
||||||
'catalogItem.variants.attachments',
|
|
||||||
'catalogItem.variants.definitions.itemAttribute.attribute',
|
|
||||||
'catalogItem.bundleComponents.catalogItem',
|
|
||||||
'catalogItem.bundleComponents.variant.catalogItem',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
18
app/Domains/Catalog/Enums/FeaturedGroupSource.php
Normal file
18
app/Domains/Catalog/Enums/FeaturedGroupSource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Enums;
|
||||||
|
|
||||||
|
enum FeaturedGroupSource: string
|
||||||
|
{
|
||||||
|
case Manual = 'manual';
|
||||||
|
case Category = 'category';
|
||||||
|
case All = 'all';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function values(): array
|
||||||
|
{
|
||||||
|
return array_column(self::cases(), 'value');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Event\Models\EventDate;
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
@@ -51,4 +52,14 @@ class Attribute extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
return $this->hasMany(AttributeOption::class, 'attribute_id')->orderBy('sort_order');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return HasMany<EventDate, $this>
|
||||||
|
*/
|
||||||
|
public function eventDates(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
|
||||||
|
->orderBy('date')
|
||||||
|
->orderBy('time_start');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Ticket\Models\ValidityTime;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
@@ -9,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'attribute_id',
|
'attribute_id',
|
||||||
|
'validity_time_id',
|
||||||
'value',
|
'value',
|
||||||
'label',
|
'label',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
@@ -26,6 +28,7 @@ class AttributeOption extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'validity_time_id' => 'integer',
|
||||||
'sort_order' => 'integer',
|
'sort_order' => 'integer',
|
||||||
'metadata' => 'array',
|
'metadata' => 'array',
|
||||||
];
|
];
|
||||||
@@ -38,4 +41,10 @@ class AttributeOption extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Attribute::class, 'attribute_id');
|
return $this->belongsTo(Attribute::class, 'attribute_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<ValidityTime, $this> */
|
||||||
|
public function validityTime(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ValidityTime::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,13 +7,17 @@ use App\Domains\Catalog\Enums\CatalogItemType;
|
|||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Carbon\CarbonInterface;
|
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Models\ValidityTime;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
@@ -26,9 +30,10 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'descripcion',
|
'descripcion',
|
||||||
'precio',
|
'precio',
|
||||||
'inventory_policy',
|
'inventory_policy',
|
||||||
|
'max_units_per_user',
|
||||||
'has_tickets',
|
'has_tickets',
|
||||||
'maximum_use_date',
|
'ticket_generation_policy',
|
||||||
'minimum_use_date',
|
'validity_time_id',
|
||||||
])]
|
])]
|
||||||
class CatalogItem extends Model
|
class CatalogItem extends Model
|
||||||
{
|
{
|
||||||
@@ -42,6 +47,7 @@ class CatalogItem extends Model
|
|||||||
'type' => CatalogItemType::Standard->value,
|
'type' => CatalogItemType::Standard->value,
|
||||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
'has_tickets' => false,
|
'has_tickets' => false,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -53,9 +59,10 @@ class CatalogItem extends Model
|
|||||||
'type' => CatalogItemType::class,
|
'type' => CatalogItemType::class,
|
||||||
'precio' => 'decimal:2',
|
'precio' => 'decimal:2',
|
||||||
'inventory_policy' => InventoryPolicy::class,
|
'inventory_policy' => InventoryPolicy::class,
|
||||||
|
'max_units_per_user' => 'integer',
|
||||||
'has_tickets' => 'boolean',
|
'has_tickets' => 'boolean',
|
||||||
'maximum_use_date' => 'datetime',
|
'ticket_generation_policy' => TicketGenerationPolicy::class,
|
||||||
'minimum_use_date' => 'datetime',
|
'validity_time_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,6 +108,18 @@ class CatalogItem extends Model
|
|||||||
return $this->hasMany(Variant::class);
|
return $this->hasMany(Variant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Ticket, $this> */
|
||||||
|
public function sourceTickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'source_catalog_item_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<ValidityTime, $this> */
|
||||||
|
public function validityTime(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ValidityTime::class);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsToMany<Attribute, $this> */
|
/** @return BelongsToMany<Attribute, $this> */
|
||||||
public function attributes(): BelongsToMany
|
public function attributes(): BelongsToMany
|
||||||
{
|
{
|
||||||
@@ -154,6 +173,31 @@ class CatalogItem extends Model
|
|||||||
return ($this->availableStock() ?? 0) > 0;
|
return ($this->availableStock() ?? 0) > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @param Builder<CatalogItem> $query */
|
||||||
|
public function scopeWhereVariantsAvailable(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->whereDoesntHave('variants')
|
||||||
|
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||||
|
->orWhereHas(
|
||||||
|
'variants.inventory',
|
||||||
|
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||||
|
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, Variant> */
|
||||||
|
public function visibleVariants(?int $includedVariantId = null): Collection
|
||||||
|
{
|
||||||
|
return $this->variants
|
||||||
|
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|
||||||
|
|| $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
|
|| ($variant->inventory?->availableStock() ?? 0) > 0)
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
public function getPrice(): float
|
public function getPrice(): float
|
||||||
{
|
{
|
||||||
return (float) $this->precio;
|
return (float) $this->precio;
|
||||||
@@ -164,14 +208,9 @@ class CatalogItem extends Model
|
|||||||
return $this->nombre;
|
return $this->nombre;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMinimumUseDate(): ?CarbonInterface
|
public function getDescription(): ?string
|
||||||
{
|
{
|
||||||
return $this->minimum_use_date;
|
return $this->descripcion;
|
||||||
}
|
|
||||||
|
|
||||||
public function getMaximumUseDate(): ?CarbonInterface
|
|
||||||
{
|
|
||||||
return $this->maximum_use_date;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function isBundle(): bool
|
public function isBundle(): bool
|
||||||
|
|||||||
@@ -2,11 +2,13 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -64,4 +66,15 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return $this->hasMany(CatalogItem::class);
|
return $this->hasMany(CatalogItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<User, $this> */
|
||||||
|
public function scanners(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
User::class,
|
||||||
|
'category_scanners',
|
||||||
|
'categoria_id',
|
||||||
|
'user_id',
|
||||||
|
)->withTimestamps();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -13,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
|
'source_type',
|
||||||
|
'category_id',
|
||||||
'product_layout',
|
'product_layout',
|
||||||
'group_layout',
|
'group_layout',
|
||||||
'group_name',
|
'group_name',
|
||||||
@@ -26,9 +29,15 @@ class FeaturedGroup extends Model
|
|||||||
|
|
||||||
protected $table = 'featured_groups';
|
protected $table = 'featured_groups';
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'source_type' => FeaturedGroupSource::Manual->value,
|
||||||
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'source_type' => FeaturedGroupSource::class,
|
||||||
|
'category_id' => 'integer',
|
||||||
'product_layout' => ProductLayout::class,
|
'product_layout' => ProductLayout::class,
|
||||||
'group_layout' => GroupLayout::class,
|
'group_layout' => GroupLayout::class,
|
||||||
'group_order' => 'integer',
|
'group_order' => 'integer',
|
||||||
@@ -41,6 +50,12 @@ class FeaturedGroup extends Model
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Category, $this> */
|
||||||
|
public function category(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Category::class);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return HasMany<FeaturedItem, $this> */
|
/** @return HasMany<FeaturedItem, $this> */
|
||||||
public function featuredItems(): HasMany
|
public function featuredItems(): HasMany
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'catalog_item_id',
|
'catalog_item_id',
|
||||||
'attribute_id',
|
'attribute_id',
|
||||||
|
'allow_multi_select',
|
||||||
|
'sort_order',
|
||||||
])]
|
])]
|
||||||
class ItemAttribute extends Model
|
class ItemAttribute extends Model
|
||||||
{
|
{
|
||||||
@@ -18,6 +20,16 @@ class ItemAttribute extends Model
|
|||||||
|
|
||||||
protected $table = 'item_attributes';
|
protected $table = 'item_attributes';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'catalog_item_id' => 'integer',
|
||||||
|
'attribute_id' => 'integer',
|
||||||
|
'allow_multi_select' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<CatalogItem, $this> */
|
/** @return BelongsTo<CatalogItem, $this> */
|
||||||
public function catalogItem(): BelongsTo
|
public function catalogItem(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,19 +3,22 @@
|
|||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use Carbon\CarbonInterface;
|
use App\Domains\Event\Models\EventDate;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'catalog_item_id',
|
'catalog_item_id',
|
||||||
|
'event_date_id',
|
||||||
'inventory_id',
|
'inventory_id',
|
||||||
'minimum_use_date',
|
'descripcion',
|
||||||
'maximum_use_date',
|
'precio',
|
||||||
])]
|
])]
|
||||||
class Variant extends Model
|
class Variant extends Model
|
||||||
{
|
{
|
||||||
@@ -29,9 +32,9 @@ class Variant extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'catalog_item_id' => 'integer',
|
'catalog_item_id' => 'integer',
|
||||||
|
'event_date_id' => 'integer',
|
||||||
'inventory_id' => 'integer',
|
'inventory_id' => 'integer',
|
||||||
'minimum_use_date' => 'datetime',
|
'precio' => 'decimal:2',
|
||||||
'maximum_use_date' => 'datetime',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +44,29 @@ class Variant extends Model
|
|||||||
return $this->belongsTo(CatalogItem::class);
|
return $this->belongsTo(CatalogItem::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<EventDate, $this> */
|
||||||
|
public function eventDate(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(EventDate::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<EventDate, $this> */
|
||||||
|
public function eventDates(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
EventDate::class,
|
||||||
|
'variant_event_dates',
|
||||||
|
'variant_id',
|
||||||
|
'event_date_id',
|
||||||
|
)->orderBy('date')->orderBy('time_start');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Ticket, $this> */
|
||||||
|
public function sourceTickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'source_variant_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Inventory, $this> */
|
/** @return BelongsTo<Inventory, $this> */
|
||||||
public function inventory(): BelongsTo
|
public function inventory(): BelongsTo
|
||||||
{
|
{
|
||||||
@@ -74,36 +100,145 @@ class Variant extends Model
|
|||||||
|
|
||||||
public function getPrice(): float
|
public function getPrice(): float
|
||||||
{
|
{
|
||||||
return $this->catalogItem->getPrice();
|
return (float) ($this->precio ?? $this->catalogItem->precio);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getDescription(): ?string
|
||||||
|
{
|
||||||
|
return $this->descripcion ?? $this->catalogItem->descripcion;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getName(): string
|
public function getName(): string
|
||||||
{
|
{
|
||||||
$name = $this->catalogItem->nombre;
|
return $this->catalogItem->nombre;
|
||||||
$this->loadMissing('definitions.itemAttribute.attribute');
|
}
|
||||||
$definitions = $this->definitions
|
|
||||||
->map(function (VariantDefinition $definition): ?string {
|
|
||||||
$attributeName = $definition->itemAttribute?->attribute?->nombre;
|
|
||||||
|
|
||||||
return $attributeName
|
/** @return Collection<string, string|array<int, string>> */
|
||||||
? "{$attributeName}: {$definition->value}"
|
public function selectionValues(): Collection
|
||||||
: $definition->value;
|
{
|
||||||
})
|
$values = $this->definitions
|
||||||
|
->groupBy('item_attribute_id')
|
||||||
|
->mapWithKeys(function (Collection $definitions): array {
|
||||||
|
$itemAttribute = $definitions->first()?->itemAttribute;
|
||||||
|
$attributeCode = $itemAttribute?->attribute?->codigo;
|
||||||
|
|
||||||
|
if ($attributeCode === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$definitionValues = $definitions->pluck('value')->values();
|
||||||
|
|
||||||
|
return [
|
||||||
|
$attributeCode => $itemAttribute->allow_multi_select
|
||||||
|
? $definitionValues->all()
|
||||||
|
: $definitionValues->first(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$eventDateIds = $this->selectedEventDates()
|
||||||
|
->pluck('id')
|
||||||
|
->map(fn ($id): string => (string) $id)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($eventDateIds->count() === 1) {
|
||||||
|
$values->put('event_date', $eventDateIds->first());
|
||||||
|
} elseif ($eventDateIds->isNotEmpty()) {
|
||||||
|
$values->put('event_date', $eventDateIds->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Collection<string, array{value: string, label: string}|array<int, array{value: string, label: string}>>
|
||||||
|
*/
|
||||||
|
public function selectionOptions(?Collection $itemAttributes = null): Collection
|
||||||
|
{
|
||||||
|
$options = $this->definitions
|
||||||
|
->groupBy('item_attribute_id')
|
||||||
|
->mapWithKeys(function (Collection $definitions): array {
|
||||||
|
$itemAttribute = $definitions->first()?->itemAttribute;
|
||||||
|
$attribute = $itemAttribute?->attribute;
|
||||||
|
$attributeCode = $attribute?->codigo;
|
||||||
|
|
||||||
|
if ($attributeCode === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$values = $definitions
|
||||||
|
->pluck('value')
|
||||||
|
->values()
|
||||||
|
->map(function (string $value) use ($attribute): array {
|
||||||
|
$attributeOption = $attribute->options->firstWhere('value', $value);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'value' => $value,
|
||||||
|
'label' => $attributeOption?->label ?? $value,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return [
|
||||||
|
$attributeCode => $itemAttribute->allow_multi_select
|
||||||
|
? $values->all()
|
||||||
|
: $values->first(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$eventDateOptions = $this->selectedEventDates()
|
||||||
|
->map(fn (EventDate $eventDate): array => [
|
||||||
|
'value' => (string) $eventDate->id,
|
||||||
|
'label' => $eventDate->date->format('d/m/Y'),
|
||||||
|
])
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($eventDateOptions->count() === 1) {
|
||||||
|
$options->put('event_date', $eventDateOptions->first());
|
||||||
|
} elseif ($eventDateOptions->isNotEmpty()) {
|
||||||
|
$options->put('event_date', $eventDateOptions->all());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($itemAttributes === null) {
|
||||||
|
$itemAttributes = $this->definitions
|
||||||
|
->map(fn (VariantDefinition $definition) => $definition->itemAttribute)
|
||||||
->filter()
|
->filter()
|
||||||
->implode(', ');
|
->unique('id')
|
||||||
|
->values();
|
||||||
|
|
||||||
return $definitions === '' ? $name : "{$name} ({$definitions})";
|
if ($this->catalogItem !== null) {
|
||||||
|
$itemAttributes = $itemAttributes
|
||||||
|
->merge($this->catalogItem->itemAttributes)
|
||||||
|
->unique('id')
|
||||||
|
->values();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMinimumUseDate(): ?CarbonInterface
|
$ordering = $itemAttributes->mapWithKeys(function (ItemAttribute $itemAttribute): array {
|
||||||
{
|
$attribute = $itemAttribute->attribute;
|
||||||
return $this->minimum_use_date
|
|
||||||
?? $this->catalogItem->getMinimumUseDate();
|
return $attribute === null
|
||||||
|
? []
|
||||||
|
: [$attribute->codigo => [$itemAttribute->sort_order, mb_strtolower($attribute->nombre)]];
|
||||||
|
});
|
||||||
|
|
||||||
|
return $options->sortKeysUsing(function (string $left, string $right) use ($ordering): int {
|
||||||
|
[$leftOrder, $leftLabel] = $ordering->get($left, [0, mb_strtolower($left)]);
|
||||||
|
[$rightOrder, $rightLabel] = $ordering->get($right, [0, mb_strtolower($right)]);
|
||||||
|
|
||||||
|
return $leftOrder <=> $rightOrder
|
||||||
|
?: $leftLabel <=> $rightLabel
|
||||||
|
?: $left <=> $right;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMaximumUseDate(): ?CarbonInterface
|
/** @return Collection<int, EventDate> */
|
||||||
|
public function selectedEventDates(): Collection
|
||||||
{
|
{
|
||||||
return $this->maximum_use_date
|
$eventDates = $this->eventDates;
|
||||||
?? $this->catalogItem->getMaximumUseDate();
|
|
||||||
|
if ($eventDates->isEmpty() && $this->event_date_id !== null && $this->eventDate !== null) {
|
||||||
|
return collect([$this->eventDate]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $eventDates;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests\AdminApp;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_name' => ['required', 'string', 'max:255'],
|
||||||
|
'is_featured' => ['required', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Requests;
|
|||||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||||
|
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
@@ -53,9 +54,10 @@ class StoreCatalogItemRequest extends FormRequest
|
|||||||
'descripcion' => ['sometimes', 'nullable', 'string'],
|
'descripcion' => ['sometimes', 'nullable', 'string'],
|
||||||
'precio' => ['required', 'numeric', 'min:0'],
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
||||||
|
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
|
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
|
||||||
'minimum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date'],
|
'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)],
|
||||||
'maximum_use_date' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'date', 'after_or_equal:minimum_use_date'],
|
'validity_time_id' => [Rule::prohibitedIf($isBundle), 'sometimes', 'nullable', 'integer', Rule::exists('validity_times', 'id')],
|
||||||
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
|
'real_stock' => [Rule::prohibitedIf($isBundle), 'sometimes', 'integer', 'min:0'],
|
||||||
'inventory_id' => ['prohibited'],
|
'inventory_id' => ['prohibited'],
|
||||||
'reserved_stock' => ['prohibited'],
|
'reserved_stock' => ['prohibited'],
|
||||||
@@ -69,22 +71,44 @@ class StoreCatalogItemRequest extends FormRequest
|
|||||||
fn ($query) => $query->where('tenant_codigo', $tenantCode)
|
fn ($query) => $query->where('tenant_codigo', $tenantCode)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
'multi_select_attribute_codes' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
|
||||||
|
'multi_select_attribute_codes.*' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('attribute', 'codigo')->where(
|
||||||
|
fn ($query) => $query->where('tenant_codigo', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
'images' => ['sometimes', 'array'],
|
'images' => ['sometimes', 'array'],
|
||||||
'images.*' => ['required', new ImageOrBase64Rule],
|
'images.*' => ['required', new ImageOrBase64Rule],
|
||||||
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
|
'variants' => [Rule::prohibitedIf($isBundle), 'sometimes', 'array'],
|
||||||
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
|
'variants.*.real_stock' => ['sometimes', 'integer', 'min:0'],
|
||||||
|
'variants.*.descripcion' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'variants.*.precio' => ['sometimes', 'nullable', 'numeric', 'min:0', 'max:99999999.99'],
|
||||||
|
'variants.*.event_date_id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('event_dates', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'variants.*.event_date_ids' => ['sometimes', 'array', 'min:1'],
|
||||||
|
'variants.*.event_date_ids.*' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('event_dates', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
'variants.*.inventory_id' => ['prohibited'],
|
'variants.*.inventory_id' => ['prohibited'],
|
||||||
'variants.*.reserved_stock' => ['prohibited'],
|
'variants.*.reserved_stock' => ['prohibited'],
|
||||||
'variants.*.sold_units' => ['prohibited'],
|
'variants.*.sold_units' => ['prohibited'],
|
||||||
'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'],
|
|
||||||
'variants.*.maximum_use_date' => [
|
|
||||||
'sometimes',
|
|
||||||
'nullable',
|
|
||||||
'date',
|
|
||||||
'after_or_equal:variants.*.minimum_use_date',
|
|
||||||
],
|
|
||||||
'variants.*.values' => ['sometimes', 'array'],
|
'variants.*.values' => ['sometimes', 'array'],
|
||||||
'variants.*.values.*' => ['nullable', 'string'],
|
'variants.*.values.*' => ['nullable'],
|
||||||
|
'variants.*.values.*.*' => ['required', 'string'],
|
||||||
'variants.*.images' => ['sometimes', 'array'],
|
'variants.*.images' => ['sometimes', 'array'],
|
||||||
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
|
'variants.*.images.*' => ['required', new ImageOrBase64Rule],
|
||||||
'components' => [
|
'components' => [
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin FeaturedGroup */
|
||||||
|
class OnTicketFeaturedGroupResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'category_id' => $this->category_id,
|
||||||
|
'category_name' => $this->category->nombre,
|
||||||
|
'group_name' => $this->group_name,
|
||||||
|
'is_featured' => $this->product_layout === ProductLayout::Row,
|
||||||
|
'type' => $this->source_type->value,
|
||||||
|
'product_layout' => $this->product_layout->value,
|
||||||
|
'group_layout' => $this->group_layout->value,
|
||||||
|
'order' => $this->group_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,20 +5,23 @@ namespace App\Domains\Catalog\Resources;
|
|||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\FeaturedItem;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
/** @mixin FeaturedItem */
|
/** @mixin CatalogItem */
|
||||||
class CatalogFeaturedItemResource extends JsonResource
|
class CatalogFeaturedItemResource extends JsonResource
|
||||||
{
|
{
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$catalogItem = $this->catalogItem;
|
$catalogItem = $this->resource;
|
||||||
|
/** @var FeaturedGroup $featuredGroup */
|
||||||
|
$featuredGroup = $catalogItem->getRelation('featuredGroup');
|
||||||
|
|
||||||
if ($this->featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
|
if ($featuredGroup->product_layout === ProductLayout::ColumnWithImage) {
|
||||||
return $this->columnWithImageData($catalogItem);
|
return $this->columnWithImageData($catalogItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,18 +31,23 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'nombre' => $catalogItem->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'descripcion' => $catalogItem->descripcion,
|
'descripcion' => $catalogItem->descripcion,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
|
'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value,
|
||||||
|
'validity_time_id' => $catalogItem->validity_time_id,
|
||||||
|
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
|
||||||
'stock_tecnico' => $catalogItem->availableStock(),
|
'stock_tecnico' => $catalogItem->availableStock(),
|
||||||
'variants' => $catalogItem->variants
|
'variants' => $catalogItem->visibleVariants()
|
||||||
->map(fn (Variant $variant): array => [
|
->map(fn (Variant $variant): array => [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
|
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
||||||
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
|
'descripcion' => $variant->getDescription(),
|
||||||
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
: $variant->inventory->availableStock(),
|
: $variant->inventory->availableStock(),
|
||||||
'values' => $variant->definitions
|
'values' => $variant->selectionOptions($catalogItem->itemAttributes),
|
||||||
->mapWithKeys(fn ($definition) => [
|
|
||||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
|
||||||
])
|
|
||||||
->filter(fn ($value, $key): bool => $key !== null),
|
|
||||||
])
|
])
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
@@ -58,6 +66,9 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'type' => $catalogItem->type->value,
|
'type' => $catalogItem->type->value,
|
||||||
'nombre' => $catalogItem->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
|
'ticket_generation_policy' => $catalogItem->ticket_generation_policy->value,
|
||||||
|
'validity_time_id' => $catalogItem->validity_time_id,
|
||||||
|
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
|
||||||
'image' => $attachment?->getTemporaryUrl(1440),
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ use App\Domains\Catalog\Enums\InventoryPolicy;
|
|||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\ItemAttribute;
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
|
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -31,12 +33,14 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
'category' => $this->category?->nombre,
|
'category' => $this->category?->nombre,
|
||||||
'brand' => $this->brand?->nombre,
|
'brand' => $this->brand?->nombre,
|
||||||
'inventory_policy' => $this->inventory_policy?->value,
|
'inventory_policy' => $this->inventory_policy?->value,
|
||||||
|
'max_units_per_user' => $this->max_units_per_user,
|
||||||
'has_tickets' => $this->has_tickets,
|
'has_tickets' => $this->has_tickets,
|
||||||
'minimum_use_date' => $this->minimum_use_date,
|
'ticket_generation_policy' => $this->ticket_generation_policy->value,
|
||||||
'maximum_use_date' => $this->maximum_use_date,
|
'validity_time_id' => $this->validity_time_id,
|
||||||
'attributes' => $this->itemAttributes
|
'validity_time' => $this->validityTime === null
|
||||||
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
|
? null
|
||||||
->values(),
|
: ValidityTimeResource::make($this->validityTime),
|
||||||
|
'attributes' => $this->attributesData(),
|
||||||
'stock_tecnico' => $this->when(
|
'stock_tecnico' => $this->when(
|
||||||
$selectedVariant === null,
|
$selectedVariant === null,
|
||||||
fn () => $this->availableStock(),
|
fn () => $this->availableStock(),
|
||||||
@@ -45,7 +49,7 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
$selectedVariant === null,
|
$selectedVariant === null,
|
||||||
fn () => $this->imageUrls($this->attachments),
|
fn () => $this->imageUrls($this->attachments),
|
||||||
),
|
),
|
||||||
'variants' => $this->variants
|
'variants' => $this->visibleVariants()
|
||||||
->map(fn (Variant $variant): array => $this->variantData($variant))
|
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||||
->values(),
|
->values(),
|
||||||
'selected_variant' => $this->when(
|
'selected_variant' => $this->when(
|
||||||
@@ -78,6 +82,44 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
private function attributeData(ItemAttribute $itemAttribute): array
|
private function attributeData(ItemAttribute $itemAttribute): array
|
||||||
{
|
{
|
||||||
$attribute = $itemAttribute->attribute;
|
$attribute = $itemAttribute->attribute;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $attribute->id,
|
||||||
|
'codigo' => $attribute->codigo,
|
||||||
|
'nombre' => $attribute->nombre,
|
||||||
|
'sort_order' => $itemAttribute->sort_order,
|
||||||
|
'is_required' => $attribute->is_required,
|
||||||
|
'allow_multi_select' => $itemAttribute->allow_multi_select,
|
||||||
|
'metadata_schema' => $attribute->metadata_schema,
|
||||||
|
'type' => $attribute->type->value,
|
||||||
|
'options' => $attribute->type === FieldType::EventDate
|
||||||
|
? $this->eventDateOptions($itemAttribute)
|
||||||
|
: $this->catalogAttributeOptions($itemAttribute),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, array<string, mixed>> */
|
||||||
|
private function eventDateOptions(ItemAttribute $itemAttribute): Collection
|
||||||
|
{
|
||||||
|
return $itemAttribute->attribute->eventDates
|
||||||
|
->map(fn ($eventDate, int $index): array => [
|
||||||
|
'id' => $eventDate->id,
|
||||||
|
'value' => (string) $eventDate->id,
|
||||||
|
'label' => $eventDate->date->format('d/m/Y'),
|
||||||
|
'sort_order' => $index,
|
||||||
|
'validity_time_id' => $eventDate->validity_time_id,
|
||||||
|
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
|
||||||
|
'metadata' => [
|
||||||
|
'date' => $eventDate->date->format('Y-m-d'),
|
||||||
|
'time_start' => $eventDate->time_start,
|
||||||
|
'time_end' => $eventDate->time_end,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, array<string, mixed>> */
|
||||||
|
private function catalogAttributeOptions(ItemAttribute $itemAttribute): Collection
|
||||||
|
{
|
||||||
$availableValues = $this->variants
|
$availableValues = $this->variants
|
||||||
->flatMap->definitions
|
->flatMap->definitions
|
||||||
->where('item_attribute_id', $itemAttribute->id)
|
->where('item_attribute_id', $itemAttribute->id)
|
||||||
@@ -85,43 +127,50 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
->filter()
|
->filter()
|
||||||
->unique();
|
->unique();
|
||||||
|
|
||||||
return [
|
return $itemAttribute->attribute->options
|
||||||
'id' => $attribute->id,
|
|
||||||
'codigo' => $attribute->codigo,
|
|
||||||
'nombre' => $attribute->nombre,
|
|
||||||
'is_required' => $attribute->is_required,
|
|
||||||
'metadata_schema' => $attribute->metadata_schema,
|
|
||||||
'type' => $attribute->type->value,
|
|
||||||
'options' => $attribute->options
|
|
||||||
->whereIn('value', $availableValues)
|
->whereIn('value', $availableValues)
|
||||||
->map(fn ($option): array => [
|
->map(fn ($option): array => [
|
||||||
'id' => $option->id,
|
'id' => $option->id,
|
||||||
'value' => $option->value,
|
'value' => $option->value,
|
||||||
'label' => $option->label,
|
'label' => $option->label,
|
||||||
'sort_order' => $option->sort_order,
|
'sort_order' => $option->sort_order,
|
||||||
|
'validity_time_id' => $option->validity_time_id,
|
||||||
|
'validity_time' => $option->validityTime === null
|
||||||
|
? null
|
||||||
|
: ValidityTimeResource::make($option->validityTime),
|
||||||
'metadata' => $option->metadata,
|
'metadata' => $option->metadata,
|
||||||
])
|
])
|
||||||
->values(),
|
->values();
|
||||||
];
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, array<string, mixed>> */
|
||||||
|
private function attributesData(): Collection
|
||||||
|
{
|
||||||
|
return $this->itemAttributes
|
||||||
|
->sort(function (ItemAttribute $left, ItemAttribute $right): int {
|
||||||
|
return $left->sort_order <=> $right->sort_order
|
||||||
|
?: mb_strtolower($left->attribute->nombre) <=> mb_strtolower($right->attribute->nombre);
|
||||||
|
})
|
||||||
|
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
|
||||||
|
->values();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
private function variantData(Variant $variant): array
|
private function variantData(Variant $variant): array
|
||||||
{
|
{
|
||||||
|
$values = $variant->selectionOptions($this->itemAttributes);
|
||||||
|
$eventDates = $variant->selectedEventDates();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
|
'event_date_ids' => $eventDates->pluck('id')->values(),
|
||||||
|
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
|
'descripcion' => $variant->getDescription(),
|
||||||
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'stock_tecnico' => $this->variantStock($variant),
|
'stock_tecnico' => $this->variantStock($variant),
|
||||||
'minimum_use_date' => $variant->minimum_use_date,
|
'values' => $values,
|
||||||
'maximum_use_date' => $variant->maximum_use_date,
|
|
||||||
'effective_minimum_use_date' => $variant->minimum_use_date
|
|
||||||
?? $this->minimum_use_date,
|
|
||||||
'effective_maximum_use_date' => $variant->maximum_use_date
|
|
||||||
?? $this->maximum_use_date,
|
|
||||||
'values' => $variant->definitions
|
|
||||||
->mapWithKeys(fn ($definition) => [
|
|
||||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
|
||||||
])
|
|
||||||
->filter(fn ($value, $key): bool => $key !== null),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Catalog\Resources;
|
namespace App\Domains\Catalog\Resources;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -22,9 +23,14 @@ class CatalogItemResource extends JsonResource
|
|||||||
'descripcion' => $this->descripcion,
|
'descripcion' => $this->descripcion,
|
||||||
'precio' => $this->precio,
|
'precio' => $this->precio,
|
||||||
'inventory_policy' => $this->inventory_policy?->value,
|
'inventory_policy' => $this->inventory_policy?->value,
|
||||||
|
'max_units_per_user' => $this->max_units_per_user,
|
||||||
'has_tickets' => $this->has_tickets,
|
'has_tickets' => $this->has_tickets,
|
||||||
'minimum_use_date' => $this->minimum_use_date,
|
'ticket_generation_policy' => $this->ticket_generation_policy->value,
|
||||||
'maximum_use_date' => $this->maximum_use_date,
|
'validity_time_id' => $this->validity_time_id,
|
||||||
|
'validity_time' => $this->whenLoaded(
|
||||||
|
'validityTime',
|
||||||
|
fn () => ValidityTimeResource::make($this->validityTime),
|
||||||
|
),
|
||||||
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
|
'real_stock' => $this->whenLoaded('inventory', fn () => $this->inventory?->real_stock),
|
||||||
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
|
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
|
||||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||||
@@ -32,18 +38,14 @@ class CatalogItemResource extends JsonResource
|
|||||||
'variants' => $this->whenLoaded('variants', fn () => $this->variants
|
'variants' => $this->whenLoaded('variants', fn () => $this->variants
|
||||||
->map(fn ($variant) => [
|
->map(fn ($variant) => [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
|
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
||||||
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
|
'descripcion' => $variant->getDescription(),
|
||||||
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'real_stock' => $variant->inventory?->real_stock,
|
'real_stock' => $variant->inventory?->real_stock,
|
||||||
'minimum_use_date' => $variant->minimum_use_date,
|
'values' => $variant->selectionOptions($this->itemAttributes),
|
||||||
'maximum_use_date' => $variant->maximum_use_date,
|
|
||||||
'effective_minimum_use_date' => $variant->minimum_use_date
|
|
||||||
?? $this->minimum_use_date,
|
|
||||||
'effective_maximum_use_date' => $variant->maximum_use_date
|
|
||||||
?? $this->maximum_use_date,
|
|
||||||
'values' => $variant->definitions
|
|
||||||
->mapWithKeys(fn ($definition) => [
|
|
||||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
|
||||||
])
|
|
||||||
->filter(fn ($value, $key) => $key !== null),
|
|
||||||
'images' => $variant->attachments
|
'images' => $variant->attachments
|
||||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||||
->values(),
|
->values(),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Resources;
|
|||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -25,19 +26,24 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
'nombre' => $this->nombre,
|
'nombre' => $this->nombre,
|
||||||
'descripcion' => $this->descripcion,
|
'descripcion' => $this->descripcion,
|
||||||
'precio' => $this->precio,
|
'precio' => $this->precio,
|
||||||
|
'ticket_generation_policy' => $this->ticket_generation_policy->value,
|
||||||
|
'validity_time_id' => $this->validity_time_id,
|
||||||
|
'validity_time' => ValidityTimeResource::make($this->validityTime),
|
||||||
'image' => $attachment?->getTemporaryUrl(1440),
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
'stock_tecnico' => $this->availableStock(),
|
'stock_tecnico' => $this->availableStock(),
|
||||||
'variants' => $this->variants
|
'variants' => $this->visibleVariants()
|
||||||
->map(fn (Variant $variant): array => [
|
->map(fn (Variant $variant): array => [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
|
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
||||||
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
|
'descripcion' => $variant->getDescription(),
|
||||||
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
|
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
: $variant->inventory?->availableStock(),
|
: $variant->inventory?->availableStock(),
|
||||||
'values' => $variant->definitions
|
'values' => $variant->selectionOptions($this->itemAttributes),
|
||||||
->mapWithKeys(fn ($definition) => [
|
|
||||||
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
|
||||||
])
|
|
||||||
->filter(fn ($value, $key): bool => $key !== null),
|
|
||||||
])
|
])
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\Category;
|
|||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\ItemAttribute;
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Event\Models\EventDate;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
@@ -35,11 +36,35 @@ class CatalogService
|
|||||||
$variants = $data['variants'] ?? [];
|
$variants = $data['variants'] ?? [];
|
||||||
$images = $data['images'] ?? [];
|
$images = $data['images'] ?? [];
|
||||||
$attributeCodes = $data['attribute_codes'] ?? [];
|
$attributeCodes = $data['attribute_codes'] ?? [];
|
||||||
|
$multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? [];
|
||||||
$components = $data['components'] ?? [];
|
$components = $data['components'] ?? [];
|
||||||
$hasDirectStock = array_key_exists('real_stock', $data);
|
$hasDirectStock = array_key_exists('real_stock', $data);
|
||||||
$realStock = (int) ($data['real_stock'] ?? 0);
|
$realStock = (int) ($data['real_stock'] ?? 0);
|
||||||
|
|
||||||
$hasVariants = $attributeCodes !== [];
|
$hasEventDateVariants = $variants !== [] && collect($variants)->every(
|
||||||
|
fn (array $variant): bool => ! empty($variant['event_date_id'])
|
||||||
|
|| ! empty($variant['event_date_ids'])
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($hasEventDateVariants && ! in_array('event_date', $attributeCodes, true)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'attribute_codes' => [
|
||||||
|
__('api.catalog.event_date_attribute_required'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$hasVariants = $attributeCodes !== [] || $hasEventDateVariants;
|
||||||
|
|
||||||
|
if (array_diff($multiSelectAttributeCodes, $attributeCodes) !== []) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'multi_select_attribute_codes' => [
|
||||||
|
__('api.catalog.multi_select_attribute_not_on_item'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
|
||||||
|
|
||||||
if ($type === CatalogItemType::Bundle) {
|
if ($type === CatalogItemType::Bundle) {
|
||||||
$this->validateBundleData($data, $components);
|
$this->validateBundleData($data, $components);
|
||||||
@@ -62,6 +87,7 @@ class CatalogService
|
|||||||
$data['variants'],
|
$data['variants'],
|
||||||
$data['images'],
|
$data['images'],
|
||||||
$data['attribute_codes'],
|
$data['attribute_codes'],
|
||||||
|
$data['multi_select_attribute_codes'],
|
||||||
$data['components'],
|
$data['components'],
|
||||||
$data['real_stock'],
|
$data['real_stock'],
|
||||||
$data['reserved_stock'],
|
$data['reserved_stock'],
|
||||||
@@ -75,8 +101,6 @@ class CatalogService
|
|||||||
$data['inventory_id'] = null;
|
$data['inventory_id'] = null;
|
||||||
$data['inventory_policy'] = null;
|
$data['inventory_policy'] = null;
|
||||||
$data['has_tickets'] = false;
|
$data['has_tickets'] = false;
|
||||||
$data['minimum_use_date'] = null;
|
|
||||||
$data['maximum_use_date'] = null;
|
|
||||||
} elseif ($hasVariants) {
|
} elseif ($hasVariants) {
|
||||||
$data['inventory_id'] = null;
|
$data['inventory_id'] = null;
|
||||||
} else {
|
} else {
|
||||||
@@ -85,7 +109,7 @@ class CatalogService
|
|||||||
|
|
||||||
$catalogItem = CatalogItem::query()->create($data);
|
$catalogItem = CatalogItem::query()->create($data);
|
||||||
$itemAttributes = $type === CatalogItemType::Standard
|
$itemAttributes = $type === CatalogItemType::Standard
|
||||||
? $this->createItemAttributes($catalogItem, $attributeCodes)
|
? $this->createItemAttributes($catalogItem, $attributeCodes, $multiSelectAttributeCodes)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
if ($type === CatalogItemType::Bundle) {
|
if ($type === CatalogItemType::Bundle) {
|
||||||
@@ -121,10 +145,13 @@ class CatalogService
|
|||||||
'inventory',
|
'inventory',
|
||||||
'category',
|
'category',
|
||||||
'brand',
|
'brand',
|
||||||
|
'validityTime',
|
||||||
'itemAttributes.attribute',
|
'itemAttributes.attribute',
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
'variants.attachments',
|
'variants.attachments',
|
||||||
'variants.definitions.itemAttribute.attribute',
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
'bundleComponents.catalogItem',
|
'bundleComponents.catalogItem',
|
||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
]);
|
]);
|
||||||
@@ -138,20 +165,25 @@ class CatalogService
|
|||||||
'inventory',
|
'inventory',
|
||||||
'category',
|
'category',
|
||||||
'brand',
|
'brand',
|
||||||
'itemAttributes.attribute.options',
|
'validityTime',
|
||||||
|
'itemAttributes.attribute.options.validityTime',
|
||||||
|
'itemAttributes.attribute.eventDates.validityTime',
|
||||||
'variants' => fn ($query) => $query->orderBy('id'),
|
'variants' => fn ($query) => $query->orderBy('id'),
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
'variants.attachments',
|
'variants.attachments',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||||
'variants.definitions.itemAttribute.attribute',
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
'bundleComponents.catalogItem.inventory',
|
'bundleComponents.catalogItem.inventory',
|
||||||
'bundleComponents.variant.inventory',
|
'bundleComponents.variant.inventory',
|
||||||
'bundleComponents.variant.definitions.itemAttribute.attribute',
|
'bundleComponents.variant.definitions.itemAttribute.attribute',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$visibleVariants = $catalogItem->visibleVariants();
|
||||||
$selectedVariant = $variantId === null
|
$selectedVariant = $variantId === null
|
||||||
? $catalogItem->variants->first()
|
? $visibleVariants->first()
|
||||||
: $catalogItem->variants->firstWhere('id', $variantId);
|
: $visibleVariants->firstWhere('id', $variantId);
|
||||||
|
|
||||||
if ($variantId !== null && $selectedVariant === null) {
|
if ($variantId !== null && $selectedVariant === null) {
|
||||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||||
@@ -175,6 +207,7 @@ class CatalogService
|
|||||||
|
|
||||||
$paginator = CatalogItem::query()
|
$paginator = CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereVariantsAvailable()
|
||||||
->where(function (Builder $query) use ($containsPattern): void {
|
->where(function (Builder $query) use ($containsPattern): void {
|
||||||
$query
|
$query
|
||||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
@@ -193,9 +226,13 @@ class CatalogService
|
|||||||
->with([
|
->with([
|
||||||
'attachments',
|
'attachments',
|
||||||
'inventory',
|
'inventory',
|
||||||
|
'validityTime',
|
||||||
|
'itemAttributes.attribute',
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
'variants.attachments',
|
'variants.attachments',
|
||||||
'variants.definitions.itemAttribute.attribute',
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
'bundleComponents.catalogItem',
|
'bundleComponents.catalogItem',
|
||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
])
|
])
|
||||||
@@ -221,12 +258,17 @@ class CatalogService
|
|||||||
return CatalogItem::query()
|
return CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('category_id', $category->id)
|
->where('category_id', $category->id)
|
||||||
|
->whereVariantsAvailable()
|
||||||
->with([
|
->with([
|
||||||
'attachments',
|
'attachments',
|
||||||
'inventory',
|
'inventory',
|
||||||
|
'validityTime',
|
||||||
|
'itemAttributes.attribute',
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
'variants.attachments',
|
'variants.attachments',
|
||||||
'variants.definitions.itemAttribute.attribute',
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
'bundleComponents.catalogItem',
|
'bundleComponents.catalogItem',
|
||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
])
|
])
|
||||||
@@ -266,6 +308,39 @@ class CatalogService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteVariant(Variant $variant): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($variant): void {
|
||||||
|
$variant = Variant::query()
|
||||||
|
->with('attachments')
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($variant->getKey());
|
||||||
|
$catalogItem = CatalogItem::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($variant->catalog_item_id);
|
||||||
|
$attachments = $variant->attachments;
|
||||||
|
$inventoryId = $variant->inventory_id;
|
||||||
|
|
||||||
|
$variant->attachments()->detach();
|
||||||
|
$variant->delete();
|
||||||
|
Inventory::query()->whereKey($inventoryId)->delete();
|
||||||
|
|
||||||
|
$minimumPrice = $catalogItem->variants()->min('precio');
|
||||||
|
|
||||||
|
if ($minimumPrice === null) {
|
||||||
|
$this->delete($catalogItem);
|
||||||
|
} else {
|
||||||
|
$catalogItem->update(['precio' => $minimumPrice]);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
|
||||||
|
$this->attachmentService->delete($attachment);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private function createInventory(int $realStock): Inventory
|
private function createInventory(int $realStock): Inventory
|
||||||
{
|
{
|
||||||
return Inventory::query()->create([
|
return Inventory::query()->create([
|
||||||
@@ -367,8 +442,8 @@ class CatalogService
|
|||||||
'attribute_codes',
|
'attribute_codes',
|
||||||
'variants',
|
'variants',
|
||||||
'has_tickets',
|
'has_tickets',
|
||||||
'minimum_use_date',
|
'ticket_generation_policy',
|
||||||
'maximum_use_date',
|
'validity_time_id',
|
||||||
] as $field) {
|
] as $field) {
|
||||||
if (array_key_exists($field, $data)) {
|
if (array_key_exists($field, $data)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -412,11 +487,13 @@ class CatalogService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, string> $attributeCodes
|
* @param array<int, string> $attributeCodes
|
||||||
|
* @param array<int, string> $multiSelectAttributeCodes
|
||||||
* @return array<string, ItemAttribute>
|
* @return array<string, ItemAttribute>
|
||||||
*/
|
*/
|
||||||
private function createItemAttributes(
|
private function createItemAttributes(
|
||||||
CatalogItem $catalogItem,
|
CatalogItem $catalogItem,
|
||||||
array $attributeCodes,
|
array $attributeCodes,
|
||||||
|
array $multiSelectAttributeCodes = [],
|
||||||
): array {
|
): array {
|
||||||
$itemAttributes = [];
|
$itemAttributes = [];
|
||||||
$attributeCodes = array_values(array_unique($attributeCodes));
|
$attributeCodes = array_values(array_unique($attributeCodes));
|
||||||
@@ -439,6 +516,7 @@ class CatalogService
|
|||||||
|
|
||||||
$itemAttribute = $catalogItem->itemAttributes()->create([
|
$itemAttribute = $catalogItem->itemAttributes()->create([
|
||||||
'attribute_id' => $attribute->id,
|
'attribute_id' => $attribute->id,
|
||||||
|
'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$itemAttributes[$attributeCode] = $itemAttribute;
|
$itemAttributes[$attributeCode] = $itemAttribute;
|
||||||
@@ -472,15 +550,53 @@ class CatalogService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
|
||||||
|
$eventDateIds = collect($data['event_date_ids'] ?? [])
|
||||||
|
->when(
|
||||||
|
isset($data['event_date_id']),
|
||||||
|
fn ($ids) => $ids->push($data['event_date_id']),
|
||||||
|
)
|
||||||
|
->filter(fn ($id): bool => $id !== null)
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$eventDateItemAttribute = $itemAttributes['event_date'] ?? null;
|
||||||
|
if ($eventDateItemAttribute !== null && (
|
||||||
|
$eventDateIds->isEmpty()
|
||||||
|
|| (! $eventDateItemAttribute->allow_multi_select && $eventDateIds->count() !== 1)
|
||||||
|
)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.event_date_ids" => [
|
||||||
|
$eventDateItemAttribute->allow_multi_select
|
||||||
|
? __('api.catalog.event_date_selection_required')
|
||||||
|
: __('api.catalog.single_event_date_required'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validEventDateCount = EventDate::query()
|
||||||
|
->whereKey($eventDateIds)
|
||||||
|
->where('tenant_code', $catalogItem->tenant_code)
|
||||||
|
->count();
|
||||||
|
|
||||||
|
if ($validEventDateCount !== $eventDateIds->count()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.event_date_ids" => [
|
||||||
|
__('api.catalog.event_date_wrong_tenant'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$variant = $catalogItem->variants()->create([
|
$variant = $catalogItem->variants()->create([
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
'minimum_use_date' => $data['minimum_use_date'] ?? null,
|
'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null,
|
||||||
'maximum_use_date' => $data['maximum_use_date'] ?? null,
|
'descripcion' => $data['descripcion'] ?? null,
|
||||||
|
'precio' => $data['precio'] ?? null,
|
||||||
]);
|
]);
|
||||||
|
$variant->eventDates()->sync($eventDateIds->all());
|
||||||
$variant->setRelation('catalogItem', $catalogItem);
|
$variant->setRelation('catalogItem', $catalogItem);
|
||||||
|
|
||||||
$this->validateVariantUseDates($variant, $index);
|
|
||||||
|
|
||||||
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
foreach ($data['values'] ?? [] as $attributeCode => $value) {
|
||||||
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
$itemAttribute = $itemAttributes[$attributeCode] ?? null;
|
||||||
|
|
||||||
@@ -492,31 +608,130 @@ class CatalogService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
foreach ($this->validatedVariantValues(
|
||||||
|
$itemAttribute,
|
||||||
|
$value,
|
||||||
|
"variants.{$index}.values.{$attributeCode}",
|
||||||
|
) as $validatedValue) {
|
||||||
$variant->definitions()->create([
|
$variant->definitions()->create([
|
||||||
'item_attribute_id' => $itemAttribute->id,
|
'item_attribute_id' => $itemAttribute->id,
|
||||||
'value' => $value,
|
'value' => $validatedValue,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $variant;
|
return $variant;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function validateVariantUseDates(Variant $variant, int $index): void
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $variants
|
||||||
|
* @param array<int, string> $attributeCodes
|
||||||
|
*/
|
||||||
|
private function validateUniqueVariantCombinations(array $variants, array $attributeCodes): void
|
||||||
{
|
{
|
||||||
$minimumUseDate = $variant->getMinimumUseDate();
|
$seen = [];
|
||||||
$maximumUseDate = $variant->getMaximumUseDate();
|
$attributeCodes = array_values(array_unique($attributeCodes));
|
||||||
|
sort($attributeCodes);
|
||||||
|
|
||||||
if (
|
foreach (array_values($variants) as $index => $variant) {
|
||||||
$minimumUseDate !== null
|
$eventDateIds = collect($variant['event_date_ids'] ?? [])
|
||||||
&& $maximumUseDate !== null
|
->when(
|
||||||
&& $maximumUseDate->lessThan($minimumUseDate)
|
isset($variant['event_date_id']),
|
||||||
) {
|
fn ($ids) => $ids->push($variant['event_date_id']),
|
||||||
|
)
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->implode(',');
|
||||||
|
$combination = [$eventDateIds];
|
||||||
|
|
||||||
|
foreach ($attributeCodes as $attributeCode) {
|
||||||
|
$values = $variant['values'][$attributeCode] ?? '';
|
||||||
|
$normalizedValues = collect(is_array($values) ? $values : [$values])
|
||||||
|
->map(fn ($value): string => $this->normalizeVariantValue((string) $value))
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values()
|
||||||
|
->implode(',');
|
||||||
|
$combination[] = $normalizedValues;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = implode('|', $combination);
|
||||||
|
if (isset($seen[$key])) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
"variants.{$index}.maximum_use_date" => [
|
"variants.{$index}" => [__('api.catalog.duplicate_variant_combination')],
|
||||||
__('api.catalog.invalid_effective_date_range'),
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, string> */
|
||||||
|
private function validatedVariantValues(
|
||||||
|
ItemAttribute $itemAttribute,
|
||||||
|
mixed $value,
|
||||||
|
string $validationKey,
|
||||||
|
): array {
|
||||||
|
$values = is_array($value) ? array_values($value) : [$value];
|
||||||
|
|
||||||
|
if ($values === [] || (! $itemAttribute->allow_multi_select && count($values) !== 1)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validationKey => [
|
||||||
|
$itemAttribute->allow_multi_select
|
||||||
|
? __('api.catalog.multi_value_required')
|
||||||
|
: __('api.catalog.single_value_required'),
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (collect($values)->contains(fn ($item): bool => ! is_string($item) || trim($item) === '')) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validationKey => [__('api.catalog.selected_values_non_empty')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$normalizedValues = collect($values)
|
||||||
|
->map(fn (string $item): string => $this->normalizeVariantValue($item));
|
||||||
|
|
||||||
|
if ($normalizedValues->unique()->count() !== count($values)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validationKey => [__('api.catalog.selected_values_distinct')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$attribute = $itemAttribute->attribute;
|
||||||
|
if ($attribute->type->supportsOptions() && ! $attribute->type->usesDynamicOptions()) {
|
||||||
|
$optionsByNormalizedValue = $attribute->options
|
||||||
|
->keyBy(fn ($option): string => $this->normalizeVariantValue($option->value));
|
||||||
|
|
||||||
|
$resolvedOptions = $normalizedValues->map(fn (string $normalizedValue) => $optionsByNormalizedValue->get($normalizedValue));
|
||||||
|
if ($resolvedOptions->contains(null)) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validationKey => [__('api.catalog.invalid_attribute_options')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$validityTimeIds = $resolvedOptions
|
||||||
|
->pluck('validity_time_id')
|
||||||
|
->filter()
|
||||||
|
->unique();
|
||||||
|
if ($validityTimeIds->count() > 1) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validationKey => [__('api.catalog.incompatible_validity_windows')],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $resolvedOptions->pluck('value')->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
return collect($values)->map(fn (string $item): string => trim($item))->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function normalizeVariantValue(string $value): string
|
||||||
|
{
|
||||||
|
return Str::ascii(mb_strtolower(trim($value)));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
91
app/Domains/Catalog/Services/FeaturedGroupService.php
Normal file
91
app/Domains/Catalog/Services/FeaturedGroupService.php
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Catalog\Resources\CatalogFeaturedItemResource;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
|
||||||
|
class FeaturedGroupService
|
||||||
|
{
|
||||||
|
private const ITEMS_PER_PAGE = 12;
|
||||||
|
|
||||||
|
/** @return array<array-key, mixed> */
|
||||||
|
public function itemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
||||||
|
{
|
||||||
|
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
|
||||||
|
$items = $this->itemsQuery($featuredGroup)->get();
|
||||||
|
$this->attachGroup($items, $featuredGroup);
|
||||||
|
|
||||||
|
return CatalogFeaturedItemResource::collection($items)->resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
$paginator = $this->paginateItems($featuredGroup, $page);
|
||||||
|
$this->attachGroup($paginator->getCollection(), $featuredGroup);
|
||||||
|
|
||||||
|
return CatalogFeaturedItemResource::collection($paginator)
|
||||||
|
->response()
|
||||||
|
->getData(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<CatalogItem> */
|
||||||
|
private function itemsQuery(FeaturedGroup $featuredGroup): Builder
|
||||||
|
{
|
||||||
|
$query = CatalogItem::query()
|
||||||
|
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||||
|
->whereVariantsAvailable()
|
||||||
|
->with([
|
||||||
|
'inventory',
|
||||||
|
'attachments',
|
||||||
|
'validityTime',
|
||||||
|
'itemAttributes.attribute',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.attachments',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
|
'bundleComponents.catalogItem',
|
||||||
|
'bundleComponents.variant.catalogItem',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return match ($featuredGroup->source_type) {
|
||||||
|
FeaturedGroupSource::Manual => $query
|
||||||
|
->select('catalog_items.*')
|
||||||
|
->join('featured_items', 'featured_items.catalog_item_id', '=', 'catalog_items.id')
|
||||||
|
->where('featured_items.featured_group_id', $featuredGroup->id)
|
||||||
|
->orderBy('featured_items.order')
|
||||||
|
->orderBy('featured_items.id'),
|
||||||
|
FeaturedGroupSource::Category => $query
|
||||||
|
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||||
|
->orderBy('catalog_items.id'),
|
||||||
|
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paginateItems(
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
int $page,
|
||||||
|
): LengthAwarePaginator {
|
||||||
|
$paginator = $this->itemsQuery($featuredGroup)->paginate(
|
||||||
|
perPage: self::ITEMS_PER_PAGE,
|
||||||
|
pageName: 'page',
|
||||||
|
page: $page,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $paginator->withPath(route('catalog.featured-groups.items.index', [
|
||||||
|
'tenant' => $featuredGroup->tenant_code,
|
||||||
|
'featuredGroup' => $featuredGroup->id,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attachGroup(iterable $items, FeaturedGroup $featuredGroup): void
|
||||||
|
{
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$item->setRelation('featuredGroup', $featuredGroup);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupService
|
||||||
|
{
|
||||||
|
/** @return Collection<int, FeaturedGroup> */
|
||||||
|
public function forTenant(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return FeaturedGroup::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('source_type', FeaturedGroupSource::Category)
|
||||||
|
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
|
||||||
|
->with('category')
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{category_name: string, is_featured: bool} $data */
|
||||||
|
public function create(Tenant $tenant, array $data): FeaturedGroup
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
|
||||||
|
$category = $tenant->categories()->create([
|
||||||
|
'nombre' => $data['category_name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$featuredGroup = FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::Category,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'product_layout' => $this->productLayout($data['is_featured']),
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
'group_name' => $data['category_name'],
|
||||||
|
'group_order' => $this->nextOrder($tenant),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $featuredGroup->setRelation('category', $category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{category_name: string, is_featured: bool} $data */
|
||||||
|
public function update(
|
||||||
|
Tenant $tenant,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
array $data,
|
||||||
|
): FeaturedGroup {
|
||||||
|
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
|
||||||
|
$featuredGroup = FeaturedGroup::query()
|
||||||
|
->whereKey($featuredGroup->getKey())
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('source_type', FeaturedGroupSource::Category)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$category = Category::query()
|
||||||
|
->whereKey($featuredGroup->category_id)
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$category->update(['nombre' => $data['category_name']]);
|
||||||
|
$featuredGroup->update([
|
||||||
|
'group_name' => $data['category_name'],
|
||||||
|
'product_layout' => $this->productLayout($data['is_featured']),
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $featuredGroup->setRelation('category', $category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function productLayout(bool $isFeatured): ProductLayout
|
||||||
|
{
|
||||||
|
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextOrder(Tenant $tenant): int
|
||||||
|
{
|
||||||
|
$maximumOrder = FeaturedGroup::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->max('group_order');
|
||||||
|
|
||||||
|
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
33
app/Domains/Catalog/documentacion/README.md
Normal file
33
app/Domains/Catalog/documentacion/README.md
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
# Dominio Catalog
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Modela y publica la oferta comercial del tenant: productos, variantes, categorías, marcas, atributos, inventario, bundles y grupos destacados.
|
||||||
|
|
||||||
|
## Modelo
|
||||||
|
|
||||||
|
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
||||||
|
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
||||||
|
- `Inventory` administra stock disponible, reservado y comprado.
|
||||||
|
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||||
|
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||||
|
- `BundleComponent` representa los componentes de un paquete.
|
||||||
|
|
||||||
|
## Servicios
|
||||||
|
|
||||||
|
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
||||||
|
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
||||||
|
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||||
|
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||||
|
|
||||||
|
## Endpoints de tienda
|
||||||
|
|
||||||
|
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
|
||||||
|
|
||||||
|
## Endpoints administrativos
|
||||||
|
|
||||||
|
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
|
||||||
|
|
||||||
|
## Dependencias y reglas
|
||||||
|
|
||||||
|
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
|
||||||
15
app/Domains/Catalog/routes/adminapp.php
Normal file
15
app/Domains/Catalog/routes/adminapp.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
||||||
|
->name('adminapp.featured-groups.index');
|
||||||
|
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
||||||
|
->name('adminapp.featured-groups.store');
|
||||||
|
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
|
||||||
|
->name('adminapp.featured-groups.update');
|
||||||
|
});
|
||||||
@@ -14,3 +14,5 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
|||||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
|
|||||||
31
app/Domains/Event/Controllers/AdminApp/EventController.php
Normal file
31
app/Domains/Event/Controllers/AdminApp/EventController.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Event\Requests\UpdateEventRequest;
|
||||||
|
use App\Domains\Event\Resources\EventResource;
|
||||||
|
use App\Domains\Event\Services\EventService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EventController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected EventService $eventService) {}
|
||||||
|
|
||||||
|
public function show(Request $request): EventResource
|
||||||
|
{
|
||||||
|
return EventResource::make(
|
||||||
|
$this->eventService->forTenant($request->user()->tenant()->firstOrFail())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateEventRequest $request): EventResource
|
||||||
|
{
|
||||||
|
return EventResource::make(
|
||||||
|
$this->eventService->updateForTenant(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
141
app/Domains/Event/Models/EventDate.php
Normal file
141
app/Domains/Event/Models/EventDate.php
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Models;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Event\Services\EventDateTextFormatter;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Enums\ValidityTimeType;
|
||||||
|
use App\Domains\Ticket\Models\ValidityTime;
|
||||||
|
use Carbon\CarbonInterface;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'tenant_code',
|
||||||
|
'date',
|
||||||
|
'time_start',
|
||||||
|
'time_end',
|
||||||
|
])]
|
||||||
|
class EventDate extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
public $timestamps = false;
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(fn (self $eventDate) => $eventDate->syncValidityTime());
|
||||||
|
static::created(fn (self $eventDate) => $eventDate->syncTenantDateText());
|
||||||
|
static::updated(function (self $eventDate): void {
|
||||||
|
if ($eventDate->wasChanged(['date', 'time_start', 'time_end'])) {
|
||||||
|
$eventDate->syncValidityTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventDate->syncTenantDateText();
|
||||||
|
});
|
||||||
|
static::deleted(function (self $eventDate): void {
|
||||||
|
$eventDate->syncTenantDateText();
|
||||||
|
ValidityTime::query()
|
||||||
|
->whereKey($eventDate->validity_time_id)
|
||||||
|
->whereDoesntHave('ticketValidityGroups')
|
||||||
|
->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'date' => 'date:Y-m-d',
|
||||||
|
'validity_time_id' => 'integer',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Tenant, $this> */
|
||||||
|
public function tenant(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<ValidityTime, $this> */
|
||||||
|
public function validityTime(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(ValidityTime::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Variant, $this> */
|
||||||
|
public function variants(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Variant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<Variant, $this> */
|
||||||
|
public function selectedByVariants(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->belongsToMany(
|
||||||
|
Variant::class,
|
||||||
|
'variant_event_dates',
|
||||||
|
'event_date_id',
|
||||||
|
'variant_id',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function startsAt(): CarbonInterface
|
||||||
|
{
|
||||||
|
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_start);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function endsAt(): CarbonInterface
|
||||||
|
{
|
||||||
|
return Carbon::parse($this->date->format('Y-m-d').' '.$this->time_end);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncTenantDateText(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant()->first();
|
||||||
|
|
||||||
|
if (! $tenant) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->update([
|
||||||
|
'event_date_text' => app(EventDateTextFormatter::class)->format(
|
||||||
|
$tenant->eventDates()->pluck('date')
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function syncValidityTime(): void
|
||||||
|
{
|
||||||
|
$startsAt = $this->startsAt();
|
||||||
|
$expiresAt = $this->endsAt();
|
||||||
|
|
||||||
|
if ($expiresAt->lessThanOrEqualTo($startsAt)) {
|
||||||
|
$expiresAt = $expiresAt->addDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
'type' => ValidityTimeType::FixedWindow,
|
||||||
|
'start_time' => null,
|
||||||
|
'end_time' => null,
|
||||||
|
'fixed_starts_at' => $startsAt,
|
||||||
|
'fixed_expires_at' => $expiresAt,
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($this->validity_time_id === null) {
|
||||||
|
$validityTime = ValidityTime::query()->create($attributes);
|
||||||
|
$this->validity_time_id = $validityTime->getKey();
|
||||||
|
$this->setRelation('validityTime', $validityTime);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->validityTime()->update($attributes);
|
||||||
|
$this->unsetRelation('validityTime');
|
||||||
|
}
|
||||||
|
}
|
||||||
60
app/Domains/Event/Requests/UpdateEventRequest.php
Normal file
60
app/Domains/Event/Requests/UpdateEventRequest.php
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class UpdateEventRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
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' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('social_media', 'code'),
|
||||||
|
],
|
||||||
|
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
||||||
|
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
||||||
|
'contact' => ['sometimes', 'array:whatsapp_url,instagram_url,facebook_url'],
|
||||||
|
'contact.whatsapp_url' => ['nullable', 'url', 'max:2048'],
|
||||||
|
'contact.instagram_url' => ['nullable', 'url', 'max:2048'],
|
||||||
|
'contact.facebook_url' => ['nullable', 'url', 'max:2048'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, callable> */
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
$input = $this->all();
|
||||||
|
|
||||||
|
if (! array_key_exists('social_media', $input) && ! array_key_exists('contact', $input)) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
'social_media',
|
||||||
|
'The social media field is required.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Domains/Event/Resources/EventResource.php
Normal file
42
app/Domains/Event/Resources/EventResource.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin Tenant */
|
||||||
|
class EventResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$socialMedia = $this->socialMedia->keyBy('code');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->event_title,
|
||||||
|
'location' => $this->event_location,
|
||||||
|
'dates' => $this->eventDates->map(fn ($eventDate): array => [
|
||||||
|
'id' => $eventDate->id,
|
||||||
|
'validity_time_id' => $eventDate->validity_time_id,
|
||||||
|
'validity_time' => ValidityTimeResource::make($eventDate->validityTime),
|
||||||
|
'date' => $eventDate->date->format('Y-m-d'),
|
||||||
|
'start_time' => substr($eventDate->time_start, 0, 5),
|
||||||
|
'end_time' => substr($eventDate->time_end, 0, 5),
|
||||||
|
])->values(),
|
||||||
|
'social_media' => $this->socialMedia->map(fn ($item): array => [
|
||||||
|
'code' => $item->code,
|
||||||
|
'url' => $item->pivot->url,
|
||||||
|
'orden' => $item->pivot->orden,
|
||||||
|
])->values(),
|
||||||
|
'contact' => [
|
||||||
|
'whatsapp_url' => $socialMedia->get('whatsapp')?->pivot->url,
|
||||||
|
'instagram_url' => $socialMedia->get('instagram')?->pivot->url,
|
||||||
|
'facebook_url' => $socialMedia->get('facebook')?->pivot->url,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
73
app/Domains/Event/Services/EventDateTextFormatter.php
Normal file
73
app/Domains/Event/Services/EventDateTextFormatter.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Services;
|
||||||
|
|
||||||
|
use DateTimeImmutable;
|
||||||
|
|
||||||
|
class EventDateTextFormatter
|
||||||
|
{
|
||||||
|
/** @var array<int, string> */
|
||||||
|
private const MONTHS = [
|
||||||
|
1 => 'Enero',
|
||||||
|
2 => 'Febrero',
|
||||||
|
3 => 'Marzo',
|
||||||
|
4 => 'Abril',
|
||||||
|
5 => 'Mayo',
|
||||||
|
6 => 'Junio',
|
||||||
|
7 => 'Julio',
|
||||||
|
8 => 'Agosto',
|
||||||
|
9 => 'Septiembre',
|
||||||
|
10 => 'Octubre',
|
||||||
|
11 => 'Noviembre',
|
||||||
|
12 => 'Diciembre',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @param iterable<string> $dates */
|
||||||
|
public function format(iterable $dates): ?string
|
||||||
|
{
|
||||||
|
$normalizedDates = collect($dates)
|
||||||
|
->map(fn (string $date): DateTimeImmutable => new DateTimeImmutable($date))
|
||||||
|
->unique(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
|
||||||
|
->sortBy(fn (DateTimeImmutable $date): string => $date->format('Y-m-d'))
|
||||||
|
->values();
|
||||||
|
|
||||||
|
if ($normalizedDates->isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$years = $normalizedDates
|
||||||
|
->groupBy(fn (DateTimeImmutable $date): string => $date->format('Y'))
|
||||||
|
->map(function ($yearDates, string $year): string {
|
||||||
|
$months = $yearDates
|
||||||
|
->groupBy(fn (DateTimeImmutable $date): string => $date->format('n'))
|
||||||
|
->map(function ($monthDates, string $month): string {
|
||||||
|
$days = $monthDates
|
||||||
|
->map(fn (DateTimeImmutable $date): string => (string) ((int) $date->format('j')))
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->join($days).' de '.self::MONTHS[(int) $month];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->join($months).' '.$year;
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
return $this->join($years);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, string> $parts */
|
||||||
|
private function join(array $parts): string
|
||||||
|
{
|
||||||
|
if (count($parts) <= 1) {
|
||||||
|
return $parts[0] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$last = array_pop($parts);
|
||||||
|
|
||||||
|
return implode(', ', $parts).' y '.$last;
|
||||||
|
}
|
||||||
|
}
|
||||||
102
app/Domains/Event/Services/EventService.php
Normal file
102
app/Domains/Event/Services/EventService.php
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Event\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class EventService
|
||||||
|
{
|
||||||
|
private const CONTACT_CODES = [
|
||||||
|
'whatsapp_url' => 'whatsapp',
|
||||||
|
'instagram_url' => 'instagram',
|
||||||
|
'facebook_url' => 'facebook',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function forTenant(Tenant $tenant): Tenant
|
||||||
|
{
|
||||||
|
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function updateForTenant(Tenant $tenant, array $data): Tenant
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): Tenant {
|
||||||
|
$tenant = Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||||
|
$tenant->update([
|
||||||
|
'event_title' => $data['title'],
|
||||||
|
'event_location' => $data['location'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->syncDates($tenant, $data['dates']);
|
||||||
|
if (array_key_exists('social_media', $data)) {
|
||||||
|
$this->syncSocialMedia($tenant, $data['social_media']);
|
||||||
|
} else {
|
||||||
|
$this->syncLegacyContact($tenant, $data['contact']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tenant->load(['eventDates.validityTime', 'socialMedia']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array{date: string, start_time: string, end_time: string}> $dates */
|
||||||
|
private function syncDates(Tenant $tenant, array $dates): void
|
||||||
|
{
|
||||||
|
$existingDates = $tenant->eventDates()->get()->values();
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$existingDates->slice(count($dates))->each->delete();
|
||||||
|
$tenant->unsetRelation('eventDates');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string|null> $contact */
|
||||||
|
private function syncLegacyContact(Tenant $tenant, array $contact): void
|
||||||
|
{
|
||||||
|
foreach (self::CONTACT_CODES as $field => $code) {
|
||||||
|
$url = $contact[$field] ?? null;
|
||||||
|
|
||||||
|
if ($url === null || $url === '') {
|
||||||
|
$tenant->socialMedia()->detach($code);
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->socialMedia()->syncWithoutDetaching([
|
||||||
|
$code => ['url' => $url],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->unsetRelation('socialMedia');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array{code: string, url: string, orden?: int}> $socialMedia */
|
||||||
|
private function syncSocialMedia(Tenant $tenant, array $socialMedia): void
|
||||||
|
{
|
||||||
|
$associations = [];
|
||||||
|
|
||||||
|
foreach (array_values($socialMedia) as $index => $item) {
|
||||||
|
$associations[$item['code']] = [
|
||||||
|
'url' => $item['url'],
|
||||||
|
'orden' => $item['orden'] ?? $index,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$tenant->socialMedia()->sync($associations);
|
||||||
|
$tenant->unsetRelation('socialMedia');
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Domains/Event/documentacion/README.md
Normal file
28
app/Domains/Event/documentacion/README.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Dominio Event
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Administra la configuración temporal de un tenant orientado a eventos y sus fechas disponibles.
|
||||||
|
|
||||||
|
## Componentes
|
||||||
|
|
||||||
|
- `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.
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Bajo `/v1/adminapp/tenant/event`, protegidos por `auth:sanctum` y `adminapp.tenant`:
|
||||||
|
|
||||||
|
- `GET`: obtiene la configuración.
|
||||||
|
- `PUT`: actualiza la configuración.
|
||||||
|
|
||||||
|
## Dependencias
|
||||||
|
|
||||||
|
Depende de `Tenant`. Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar tickets.
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
El archivo `routes/api.php` no publica operaciones adicionales. Al modificar fechas debe mantenerse la validación de orden y coherencia temporal de `UpdateEventRequest`.
|
||||||
11
app/Domains/Event/routes/adminapp.php
Normal file
11
app/Domains/Event/routes/adminapp.php
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Event\Controllers\AdminApp\EventController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('event', [EventController::class, 'show']);
|
||||||
|
Route::put('event', [EventController::class, 'update']);
|
||||||
|
});
|
||||||
3
app/Domains/Event/routes/api.php
Normal file
3
app/Domains/Event/routes/api.php
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Requests\UpsertAccommodationVariantsRequest;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Resources\AccommodationResource;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Services\AccommodationService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class AccommodationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AccommodationService $accommodationService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AccommodationResource
|
||||||
|
{
|
||||||
|
return AccommodationResource::make(
|
||||||
|
$this->accommodationService->current($request->user()->tenant()->firstOrFail())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UpsertAccommodationVariantsRequest $request): AccommodationResource
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return AccommodationResource::make(
|
||||||
|
$this->accommodationService->upsertMany(
|
||||||
|
$tenant,
|
||||||
|
$request->validated('variants'),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $accommodation): Response
|
||||||
|
{
|
||||||
|
$this->accommodationService->delete(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$accommodation,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Requests\UpsertEntriesRequest;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Resources\EntryResource;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Services\EntryService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class EntryController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly EntryService $entryService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return EntryResource::collection(
|
||||||
|
$this->entryService->all($request->user()->tenant()->firstOrFail())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UpsertEntriesRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
$entries = $this->entryService->upsertMany(
|
||||||
|
$tenant,
|
||||||
|
$request->validated('entries'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return EntryResource::collection($entries)
|
||||||
|
->response()
|
||||||
|
->setStatusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $entry): Response
|
||||||
|
{
|
||||||
|
$this->entryService->delete(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$entry,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Requests\UpsertFoodVariantsRequest;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Services\FoodService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class FoodController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly FoodService $foodService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): FoodResource
|
||||||
|
{
|
||||||
|
return FoodResource::make(
|
||||||
|
$this->foodService->current($request->user()->tenant()->firstOrFail())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UpsertFoodVariantsRequest $request): FoodResource
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return FoodResource::make(
|
||||||
|
$this->foodService->upsertMany(
|
||||||
|
$tenant,
|
||||||
|
$request->validated('variants'),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $food): Response
|
||||||
|
{
|
||||||
|
$this->foodService->delete(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$food,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Requests\UpsertMerchandiseRequest;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Resources\MerchandiseResource;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Services\MerchandiseService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class MerchandiseController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly MerchandiseService $merchandiseService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return MerchandiseResource::collection(
|
||||||
|
$this->merchandiseService->all($request->user()->tenant()->firstOrFail())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UpsertMerchandiseRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
$items = $this->merchandiseService->upsertMany(
|
||||||
|
$tenant,
|
||||||
|
$request->validated('items'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return MerchandiseResource::collection($items)
|
||||||
|
->response()
|
||||||
|
->setStatusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $merchandise): Response
|
||||||
|
{
|
||||||
|
$this->merchandiseService->delete(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$merchandise,
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpsertAccommodationVariantsRequest 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,title,description,stock,price'],
|
||||||
|
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
|
||||||
|
'variants.*.title' => ['required', 'string', 'max:255'],
|
||||||
|
'variants.*.description' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||||
|
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class UpsertEntriesRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$tenantCode = $this->user()?->tenant_codigo;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'entries' => ['required', 'array', 'min:1', 'max:100'],
|
||||||
|
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'],
|
||||||
|
'entries.*.id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('catalog_items', 'id')->where(
|
||||||
|
fn ($query) => $query
|
||||||
|
->where('tenant_code', $tenantCode)
|
||||||
|
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
|
||||||
|
->select('id')
|
||||||
|
->from('categorias')
|
||||||
|
->where('tenant_code', $tenantCode)
|
||||||
|
->where('nombre', 'Entradas'))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'entries.*.title' => ['required', 'string', 'max:255'],
|
||||||
|
'entries.*.description' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'entries.*.event_date_ids' => ['required', 'array', 'min:1'],
|
||||||
|
'entries.*.event_date_ids.*' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('event_dates', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'entries.*.stock' => ['required', 'integer', 'min:0'],
|
||||||
|
'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, callable> */
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
foreach ($this->input('entries', []) as $index => $entry) {
|
||||||
|
if (! is_array($entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dateIds = $entry['event_date_ids'] ?? [];
|
||||||
|
|
||||||
|
if (! is_array($dateIds)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count($dateIds) !== count(array_unique($dateIds))) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
"entries.{$index}.event_date_ids",
|
||||||
|
'Las fechas de una entrada no pueden repetirse.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class UpsertFoodVariantsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$tenantCode = $this->user()?->tenant_codigo;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||||
|
'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'],
|
||||||
|
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
|
||||||
|
'variants.*.event_date_id' => [
|
||||||
|
'required',
|
||||||
|
'integer',
|
||||||
|
Rule::exists('event_dates', 'id')->where(
|
||||||
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'variants.*.schedule' => ['required', 'string', 'max:255'],
|
||||||
|
'variants.*.service' => ['required', 'string', 'max:255'],
|
||||||
|
'variants.*.description' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||||
|
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<int, callable> */
|
||||||
|
public function after(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
function (Validator $validator): void {
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($this->input('variants', []) as $index => $variant) {
|
||||||
|
if (! is_array($variant)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$key = implode('|', [
|
||||||
|
$variant['event_date_id'] ?? '',
|
||||||
|
mb_strtolower(trim((string) ($variant['schedule'] ?? ''))),
|
||||||
|
mb_strtolower(trim((string) ($variant['service'] ?? ''))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
"variants.{$index}",
|
||||||
|
'La combinación de fecha, horario y servicio no puede repetirse.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpsertMerchandiseRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$tenantCode = $this->user()?->tenant_codigo;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'items' => ['required', 'array', 'min:1', 'max:100'],
|
||||||
|
'items.*' => ['required', 'array:id,title,description,max_units_per_user,variants'],
|
||||||
|
'items.*.id' => [
|
||||||
|
'sometimes',
|
||||||
|
'nullable',
|
||||||
|
'integer',
|
||||||
|
'distinct',
|
||||||
|
Rule::exists('catalog_items', 'id')->where(
|
||||||
|
fn ($query) => $query
|
||||||
|
->where('tenant_code', $tenantCode)
|
||||||
|
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
|
||||||
|
->select('id')
|
||||||
|
->from('categorias')
|
||||||
|
->where('tenant_code', $tenantCode)
|
||||||
|
->where('nombre', 'Merchandising'))
|
||||||
|
),
|
||||||
|
],
|
||||||
|
'items.*.title' => ['required', 'string', 'max:255'],
|
||||||
|
'items.*.description' => ['sometimes', 'nullable', 'string'],
|
||||||
|
'items.*.max_units_per_user' => ['required', 'integer', 'min:1'],
|
||||||
|
'items.*.variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||||
|
'items.*.variants.*' => ['required', 'array:id,color,size,stock,price'],
|
||||||
|
'items.*.variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
|
||||||
|
'items.*.variants.*.color' => ['required', 'string', 'max:255'],
|
||||||
|
'items.*.variants.*.size' => ['required', 'string', 'max:255'],
|
||||||
|
'items.*.variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||||
|
'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin CatalogItem */
|
||||||
|
class AccommodationResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
if ($this->resource === null) {
|
||||||
|
return [
|
||||||
|
'id' => null,
|
||||||
|
'name' => 'Alojamiento',
|
||||||
|
'variants' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$typeAttribute = $this->itemAttributes
|
||||||
|
->first(fn ($itemAttribute) => $itemAttribute->attribute?->codigo === 'tipo_alojamiento');
|
||||||
|
$options = $typeAttribute?->attribute?->options?->keyBy('value') ?? collect();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->nombre,
|
||||||
|
'variants' => $this->variants->map(function ($variant) use ($typeAttribute, $options): array {
|
||||||
|
$value = $variant->definitions
|
||||||
|
->firstWhere('item_attribute_id', $typeAttribute?->id)
|
||||||
|
?->value;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'title' => $options->get($value)?->label ?? $value,
|
||||||
|
'value' => $value,
|
||||||
|
'description' => $variant->descripcion,
|
||||||
|
'stock' => $variant->inventory->real_stock,
|
||||||
|
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
|
];
|
||||||
|
})->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php
Normal file
26
app/Domains/FiestaFutbolInfantil/Resources/EntryResource.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin CatalogItem */
|
||||||
|
class EntryResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$variant = $this->variants->sole();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->nombre,
|
||||||
|
'description' => $this->descripcion,
|
||||||
|
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
||||||
|
'stock' => $variant->inventory->real_stock,
|
||||||
|
'price' => $this->precio,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
43
app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php
Normal file
43
app/Domains/FiestaFutbolInfantil/Resources/FoodResource.php
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin CatalogItem */
|
||||||
|
class FoodResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
if ($this->resource === null) {
|
||||||
|
return [
|
||||||
|
'id' => null,
|
||||||
|
'name' => 'Comida',
|
||||||
|
'variants' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'name' => $this->nombre,
|
||||||
|
'variants' => $this->variants->map(function ($variant): array {
|
||||||
|
$values = $variant->selectionValues();
|
||||||
|
$eventDate = $variant->selectedEventDates()->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $eventDate?->id,
|
||||||
|
'event_date' => $eventDate?->date?->format('Y-m-d'),
|
||||||
|
'schedule' => $values->get('horario'),
|
||||||
|
'service' => $values->get('servicio'),
|
||||||
|
'description' => $variant->descripcion,
|
||||||
|
'stock' => $variant->inventory->real_stock,
|
||||||
|
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
|
];
|
||||||
|
})->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin CatalogItem */
|
||||||
|
class MerchandiseResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
$itemAttributes = $this->itemAttributes->keyBy(
|
||||||
|
fn ($itemAttribute) => $itemAttribute->attribute?->codigo
|
||||||
|
);
|
||||||
|
$colorAttribute = $itemAttributes->get('color');
|
||||||
|
$sizeAttribute = $itemAttributes->get('talle');
|
||||||
|
$colorOptions = $colorAttribute?->attribute?->options?->keyBy('value') ?? collect();
|
||||||
|
$sizeOptions = $sizeAttribute?->attribute?->options?->keyBy('value') ?? collect();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'title' => $this->nombre,
|
||||||
|
'description' => $this->descripcion,
|
||||||
|
'max_units_per_user' => $this->max_units_per_user,
|
||||||
|
'variants' => $this->variants->map(function ($variant) use (
|
||||||
|
$colorAttribute,
|
||||||
|
$sizeAttribute,
|
||||||
|
$colorOptions,
|
||||||
|
$sizeOptions,
|
||||||
|
): array {
|
||||||
|
$colorValue = $variant->definitions
|
||||||
|
->firstWhere('item_attribute_id', $colorAttribute?->id)
|
||||||
|
?->value;
|
||||||
|
$sizeValue = $variant->definitions
|
||||||
|
->firstWhere('item_attribute_id', $sizeAttribute?->id)
|
||||||
|
?->value;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'color' => $colorOptions->get($colorValue)?->label ?? $colorValue,
|
||||||
|
'color_value' => $colorValue,
|
||||||
|
'size' => $sizeOptions->get($sizeValue)?->label ?? $sizeValue,
|
||||||
|
'size_value' => $sizeValue,
|
||||||
|
'stock' => $variant->inventory->real_stock,
|
||||||
|
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
|
];
|
||||||
|
})->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,298 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
|
use App\Domains\Catalog\Models\AttributeOption;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
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\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AccommodationService
|
||||||
|
{
|
||||||
|
private const ATTRIBUTE_CODE = 'tipo_alojamiento';
|
||||||
|
|
||||||
|
public function __construct(private readonly CatalogService $catalogService) {}
|
||||||
|
|
||||||
|
public function current(Tenant $tenant): ?CatalogItem
|
||||||
|
{
|
||||||
|
return CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('slug', 'alojamiento')
|
||||||
|
->with([
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants.catalogItem',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.definitions',
|
||||||
|
])
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $variants
|
||||||
|
*/
|
||||||
|
public function upsertMany(Tenant $tenant, array $variants): CatalogItem
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
|
||||||
|
$attribute = $this->attribute($tenant);
|
||||||
|
$accommodation = $this->accommodation($tenant, $variants);
|
||||||
|
$itemAttribute = $accommodation->itemAttributes()->firstOrCreate(
|
||||||
|
['attribute_id' => $attribute->id],
|
||||||
|
['allow_multi_select' => false],
|
||||||
|
);
|
||||||
|
$existingVariants = $accommodation->variants()
|
||||||
|
->with(['inventory', 'definitions'])
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
$resolvedVariants = $this->resolveVariants($variants);
|
||||||
|
|
||||||
|
$this->validateValues($resolvedVariants, $existingVariants, $itemAttribute);
|
||||||
|
|
||||||
|
foreach ($resolvedVariants as $index => $data) {
|
||||||
|
$variant = isset($data['id'])
|
||||||
|
? $existingVariants->firstWhere('id', (int) $data['id'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isset($data['id']) && $variant === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.id" => ['La variante no pertenece al producto Alojamiento.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
$this->createVariant($attribute, $accommodation, $itemAttribute, $data);
|
||||||
|
} else {
|
||||||
|
$this->updateVariant($attribute, $variant, $itemAttribute, $data, $index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$minimumPrice = $accommodation->variants()->min('precio');
|
||||||
|
if ($minimumPrice !== null) {
|
||||||
|
$accommodation->update(['precio' => $minimumPrice]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $accommodation->fresh()->load([
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants.catalogItem',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.definitions',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $accommodationId): void
|
||||||
|
{
|
||||||
|
$variant = Variant::query()
|
||||||
|
->whereKey($accommodationId)
|
||||||
|
->whereHas('catalogItem', fn ($query) => $query
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('slug', 'alojamiento'))
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->catalogService->deleteVariant($variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function attribute(Tenant $tenant): Attribute
|
||||||
|
{
|
||||||
|
$attribute = Attribute::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('codigo', self::ATTRIBUTE_CODE)
|
||||||
|
->with('options')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($attribute === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'variants' => ['Falta el atributo requerido tipo_alojamiento.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attribute;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array<string, mixed>> $variants */
|
||||||
|
private function accommodation(Tenant $tenant, array $variants): CatalogItem
|
||||||
|
{
|
||||||
|
$category = Category::query()->firstOrCreate([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => 'Alojamientos',
|
||||||
|
]);
|
||||||
|
$accommodation = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('slug', 'alojamiento')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($accommodation !== null) {
|
||||||
|
$accommodation->update([
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
'has_tickets' => true,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $accommodation;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CatalogItem::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'slug' => 'alojamiento',
|
||||||
|
'nombre' => 'Alojamiento',
|
||||||
|
'descripcion' => 'Alojamiento',
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'precio' => collect($variants)->min('price') ?? 0,
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
'has_tickets' => true,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
|
||||||
|
'inventory_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $variants
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function resolveVariants(array $variants): array
|
||||||
|
{
|
||||||
|
return collect($variants)->map(fn (array $variant): array => [
|
||||||
|
...$variant,
|
||||||
|
'title' => trim($variant['title']),
|
||||||
|
'value' => $this->valueCode($variant['title']),
|
||||||
|
'description' => $variant['description'] ?? null,
|
||||||
|
'stock' => (int) $variant['stock'],
|
||||||
|
])->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $incoming
|
||||||
|
* @param Collection<int, Variant> $existing
|
||||||
|
*/
|
||||||
|
private function validateValues(array $incoming, Collection $existing, ItemAttribute $itemAttribute): void
|
||||||
|
{
|
||||||
|
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
|
||||||
|
$value = $variant->definitions->firstWhere('item_attribute_id', $itemAttribute->id)?->value;
|
||||||
|
if ($value !== null) {
|
||||||
|
$seen[mb_strtolower(trim($value))] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($incoming as $index => $variant) {
|
||||||
|
$value = $variant['value'];
|
||||||
|
|
||||||
|
if (isset($seen[$value])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.title" => ['Ya existe un tipo de alojamiento con ese título.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$value] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
private function createVariant(
|
||||||
|
Attribute $attribute,
|
||||||
|
CatalogItem $accommodation,
|
||||||
|
ItemAttribute $itemAttribute,
|
||||||
|
array $data,
|
||||||
|
): void {
|
||||||
|
$this->createOption($attribute, $data['value'], $data['title']);
|
||||||
|
|
||||||
|
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
|
||||||
|
$variant = $accommodation->variants()->create([
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'descripcion' => $data['description'],
|
||||||
|
'precio' => $data['price'],
|
||||||
|
]);
|
||||||
|
$variant->definitions()->create([
|
||||||
|
'item_attribute_id' => $itemAttribute->id,
|
||||||
|
'value' => $data['value'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
private function updateVariant(
|
||||||
|
Attribute $attribute,
|
||||||
|
Variant $variant,
|
||||||
|
ItemAttribute $itemAttribute,
|
||||||
|
array $data,
|
||||||
|
int $index,
|
||||||
|
): void {
|
||||||
|
$inventory = Inventory::query()
|
||||||
|
->whereKey($variant->inventory_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
if ($data['stock'] < $inventory->reserved_stock) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.stock" => [
|
||||||
|
'El stock no puede ser menor que la cantidad actualmente reservada.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$definition = $variant->definitions
|
||||||
|
->firstWhere('item_attribute_id', $itemAttribute->id);
|
||||||
|
$option = $definition === null
|
||||||
|
? null
|
||||||
|
: $attribute->options->firstWhere('value', $definition->value);
|
||||||
|
|
||||||
|
if ($option === null) {
|
||||||
|
$this->createOption($attribute, $data['value'], $data['title']);
|
||||||
|
} else {
|
||||||
|
$option->update([
|
||||||
|
'value' => $data['value'],
|
||||||
|
'label' => $data['title'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->update([
|
||||||
|
'descripcion' => $data['description'],
|
||||||
|
'precio' => $data['price'],
|
||||||
|
]);
|
||||||
|
$inventory->update(['real_stock' => $data['stock']]);
|
||||||
|
$variant->definitions()->updateOrCreate(
|
||||||
|
['item_attribute_id' => $itemAttribute->id],
|
||||||
|
['value' => $data['value']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createOption(Attribute $attribute, string $value, string $label): AttributeOption
|
||||||
|
{
|
||||||
|
$existing = $attribute->options->first(
|
||||||
|
fn (AttributeOption $option): bool => mb_strtolower($option->value) === $value
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($existing !== null) {
|
||||||
|
$existing->update(['label' => $label]);
|
||||||
|
|
||||||
|
return $existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
$option = $attribute->options()->create([
|
||||||
|
'value' => $value,
|
||||||
|
'label' => $label,
|
||||||
|
'sort_order' => ((int) $attribute->options->max('sort_order')) + 1,
|
||||||
|
]);
|
||||||
|
$attribute->options->push($option);
|
||||||
|
|
||||||
|
return $option;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function valueCode(string $title): string
|
||||||
|
{
|
||||||
|
return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($title)));
|
||||||
|
}
|
||||||
|
}
|
||||||
177
app/Domains/FiestaFutbolInfantil/Services/EntryService.php
Normal file
177
app/Domains/FiestaFutbolInfantil/Services/EntryService.php
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class EntryService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CatalogService $catalogService) {}
|
||||||
|
|
||||||
|
/** @return Collection<int, CatalogItem> */
|
||||||
|
public function all(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
|
||||||
|
->with([
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
|
])
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $entries
|
||||||
|
* @return Collection<int, CatalogItem>
|
||||||
|
*/
|
||||||
|
public function upsertMany(Tenant $tenant, array $entries): Collection
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $entries): Collection {
|
||||||
|
$reservedSlugs = [];
|
||||||
|
$category = Category::query()->firstOrCreate([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => 'Entradas',
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, &$reservedSlugs): CatalogItem {
|
||||||
|
if (isset($entry['id'])) {
|
||||||
|
return $this->update($tenant, $category, $entry, $index);
|
||||||
|
}
|
||||||
|
|
||||||
|
$slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs);
|
||||||
|
$reservedSlugs[] = $slug;
|
||||||
|
|
||||||
|
return $this->catalogService->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'slug' => $slug,
|
||||||
|
'nombre' => $entry['title'],
|
||||||
|
'descripcion' => $entry['description'] ?? null,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'precio' => $entry['price'],
|
||||||
|
'has_tickets' => true,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
'attribute_codes' => ['event_date'],
|
||||||
|
'multi_select_attribute_codes' => ['event_date'],
|
||||||
|
'variants' => [[
|
||||||
|
'real_stock' => $entry['stock'],
|
||||||
|
'event_date_ids' => array_values($entry['event_date_ids']),
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
})->values();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $entryId): void
|
||||||
|
{
|
||||||
|
$entry = CatalogItem::query()
|
||||||
|
->whereKey($entryId)
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->catalogService->delete($entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $entry */
|
||||||
|
private function update(Tenant $tenant, Category $category, array $entry, int $index): CatalogItem
|
||||||
|
{
|
||||||
|
$catalogItem = CatalogItem::query()
|
||||||
|
->whereKey($entry['id'])
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$variants = Variant::query()
|
||||||
|
->where('catalog_item_id', $catalogItem->id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
|
||||||
|
if ($variants->count() !== 1) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"entries.{$index}.id" => [
|
||||||
|
'La entrada no posee una única variante editable.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant = $variants->first();
|
||||||
|
$inventory = Inventory::query()
|
||||||
|
->whereKey($variant->inventory_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
if ((int) $entry['stock'] < $inventory->reserved_stock) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"entries.{$index}.stock" => [
|
||||||
|
'El stock no puede ser menor que la cantidad actualmente reservada.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$eventDateIds = collect($entry['event_date_ids'])
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$catalogItem->update([
|
||||||
|
'nombre' => $entry['title'],
|
||||||
|
'descripcion' => $entry['description'] ?? null,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'precio' => $entry['price'],
|
||||||
|
'has_tickets' => true,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
]);
|
||||||
|
$variant->update([
|
||||||
|
'event_date_id' => $eventDateIds->count() === 1 ? $eventDateIds->first() : null,
|
||||||
|
]);
|
||||||
|
$variant->eventDates()->sync($eventDateIds->all());
|
||||||
|
$catalogItem->itemAttributes()
|
||||||
|
->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date'))
|
||||||
|
->update(['allow_multi_select' => true]);
|
||||||
|
$inventory->update(['real_stock' => $entry['stock']]);
|
||||||
|
|
||||||
|
return $catalogItem->load([
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, string> $reservedSlugs */
|
||||||
|
private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string
|
||||||
|
{
|
||||||
|
$baseSlug = Str::slug($title) ?: 'entrada';
|
||||||
|
$slug = $baseSlug;
|
||||||
|
$suffix = 2;
|
||||||
|
|
||||||
|
while (
|
||||||
|
in_array($slug, $reservedSlugs, true)
|
||||||
|
|| CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('slug', $slug)
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
$slug = "{$baseSlug}-{$suffix}";
|
||||||
|
$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
331
app/Domains/FiestaFutbolInfantil/Services/FoodService.php
Normal file
331
app/Domains/FiestaFutbolInfantil/Services/FoodService.php
Normal file
@@ -0,0 +1,331 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
|
use App\Domains\Catalog\Models\AttributeOption;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
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\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class FoodService
|
||||||
|
{
|
||||||
|
private const ATTRIBUTE_CODES = ['event_date', 'horario', 'servicio'];
|
||||||
|
|
||||||
|
private const ATTRIBUTE_SORT_ORDERS = [
|
||||||
|
'event_date' => 1,
|
||||||
|
'horario' => 2,
|
||||||
|
'servicio' => 3,
|
||||||
|
];
|
||||||
|
|
||||||
|
public function __construct(private readonly CatalogService $catalogService) {}
|
||||||
|
|
||||||
|
public function current(Tenant $tenant): ?CatalogItem
|
||||||
|
{
|
||||||
|
return CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('slug', 'comida')
|
||||||
|
->with([
|
||||||
|
'variants.catalogItem',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
])
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $variants
|
||||||
|
*/
|
||||||
|
public function upsertMany(Tenant $tenant, array $variants): CatalogItem
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
|
||||||
|
$attributes = $this->attributes($tenant);
|
||||||
|
$food = $this->food($tenant, $variants);
|
||||||
|
$itemAttributes = $this->itemAttributes($food, $attributes);
|
||||||
|
|
||||||
|
$food->variants()->whereNull('precio')->update(['precio' => $food->precio]);
|
||||||
|
$existingVariants = $food->variants()
|
||||||
|
->with(['inventory', 'eventDate', 'eventDates', 'definitions.itemAttribute.attribute'])
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
$resolvedVariants = $this->resolveVariants($variants, $attributes);
|
||||||
|
|
||||||
|
$this->validateCombinations($resolvedVariants, $existingVariants);
|
||||||
|
|
||||||
|
foreach ($resolvedVariants as $index => $data) {
|
||||||
|
$variant = isset($data['id'])
|
||||||
|
? $existingVariants->firstWhere('id', (int) $data['id'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isset($data['id']) && $variant === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.id" => ['La variante no pertenece al producto Comida.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
$this->createVariant($food, $itemAttributes, $data);
|
||||||
|
} else {
|
||||||
|
$this->updateVariant($variant, $itemAttributes, $data, $index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$minimumPrice = $food->variants()->min('precio');
|
||||||
|
if ($minimumPrice !== null) {
|
||||||
|
$food->update(['precio' => $minimumPrice]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $food->fresh()->load([
|
||||||
|
'variants.catalogItem',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.eventDate',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.definitions.itemAttribute.attribute',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $foodId): void
|
||||||
|
{
|
||||||
|
$variant = Variant::query()
|
||||||
|
->whereKey($foodId)
|
||||||
|
->whereHas('catalogItem', fn ($query) => $query
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('slug', 'comida'))
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->catalogService->deleteVariant($variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<string, Attribute> */
|
||||||
|
private function attributes(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
$attributes = Attribute::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->whereIn('codigo', self::ATTRIBUTE_CODES)
|
||||||
|
->with('options')
|
||||||
|
->get()
|
||||||
|
->keyBy('codigo');
|
||||||
|
|
||||||
|
$missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys());
|
||||||
|
if ($missingCodes->isNotEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'variants' => [
|
||||||
|
'Faltan atributos requeridos para Comida: '.$missingCodes->implode(', ').'.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, array<string, mixed>> $variants */
|
||||||
|
private function food(Tenant $tenant, array $variants): CatalogItem
|
||||||
|
{
|
||||||
|
$category = Category::query()->firstOrCreate([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => 'Comidas',
|
||||||
|
]);
|
||||||
|
$food = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('slug', 'comida')
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($food !== null) {
|
||||||
|
$food->update([
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
'has_tickets' => true,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $food;
|
||||||
|
}
|
||||||
|
|
||||||
|
return CatalogItem::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'slug' => 'comida',
|
||||||
|
'nombre' => 'Comida',
|
||||||
|
'descripcion' => 'Comida',
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'precio' => collect($variants)->min('price') ?? 0,
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
'has_tickets' => true,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
|
||||||
|
'inventory_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, Attribute> $attributes
|
||||||
|
* @return Collection<string, ItemAttribute>
|
||||||
|
*/
|
||||||
|
private function itemAttributes(CatalogItem $food, Collection $attributes): Collection
|
||||||
|
{
|
||||||
|
return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($food): array {
|
||||||
|
$itemAttribute = $food->itemAttributes()->updateOrCreate(
|
||||||
|
['attribute_id' => $attribute->id],
|
||||||
|
[
|
||||||
|
'allow_multi_select' => false,
|
||||||
|
'sort_order' => self::ATTRIBUTE_SORT_ORDERS[$code],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [$code => $itemAttribute];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $variants
|
||||||
|
* @param Collection<string, Attribute> $attributes
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function resolveVariants(array $variants, Collection $attributes): array
|
||||||
|
{
|
||||||
|
return collect($variants)->map(function (array $variant, int $index) use ($attributes): array {
|
||||||
|
$schedule = $this->option($attributes['horario'], $variant['schedule'], "variants.{$index}.schedule");
|
||||||
|
$service = $this->option($attributes['servicio'], $variant['service'], "variants.{$index}.service");
|
||||||
|
|
||||||
|
return [
|
||||||
|
...$variant,
|
||||||
|
'event_date_id' => (int) $variant['event_date_id'],
|
||||||
|
'schedule' => $schedule->value,
|
||||||
|
'service' => $service->value,
|
||||||
|
'description' => (string) ($variant['description'] ?? ''),
|
||||||
|
'stock' => (int) $variant['stock'],
|
||||||
|
];
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function option(Attribute $attribute, string $value, string $validationKey): AttributeOption
|
||||||
|
{
|
||||||
|
$option = $attribute->options->first(
|
||||||
|
fn (AttributeOption $option): bool => mb_strtolower(trim($option->value)) === mb_strtolower(trim($value))
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($option === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $option;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $incoming
|
||||||
|
* @param Collection<int, Variant> $existing
|
||||||
|
*/
|
||||||
|
private function validateCombinations(array $incoming, Collection $existing): void
|
||||||
|
{
|
||||||
|
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
|
||||||
|
$values = $variant->selectionValues();
|
||||||
|
$seen[$this->combinationKey(
|
||||||
|
(int) $variant->selectedEventDates()->first()?->id,
|
||||||
|
(string) $values->get('horario'),
|
||||||
|
(string) $values->get('servicio'),
|
||||||
|
)] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($incoming as $index => $variant) {
|
||||||
|
$key = $this->combinationKey(
|
||||||
|
$variant['event_date_id'],
|
||||||
|
$variant['schedule'],
|
||||||
|
$variant['service'],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}" => ['La combinación de fecha, horario y servicio ya existe.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<string, ItemAttribute> $itemAttributes */
|
||||||
|
private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void
|
||||||
|
{
|
||||||
|
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
|
||||||
|
$variant = $food->variants()->create([
|
||||||
|
'event_date_id' => $data['event_date_id'],
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'descripcion' => $data['description'],
|
||||||
|
'precio' => $data['price'],
|
||||||
|
]);
|
||||||
|
$variant->eventDates()->sync([$data['event_date_id']]);
|
||||||
|
$this->syncDefinitions($variant, $itemAttributes, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<string, ItemAttribute> $itemAttributes */
|
||||||
|
private function updateVariant(
|
||||||
|
Variant $variant,
|
||||||
|
Collection $itemAttributes,
|
||||||
|
array $data,
|
||||||
|
int $index,
|
||||||
|
): void {
|
||||||
|
$inventory = Inventory::query()
|
||||||
|
->whereKey($variant->inventory_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
if ($data['stock'] < $inventory->reserved_stock) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.stock" => [
|
||||||
|
'El stock no puede ser menor que la cantidad actualmente reservada.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->update([
|
||||||
|
'event_date_id' => $data['event_date_id'],
|
||||||
|
'descripcion' => $data['description'],
|
||||||
|
'precio' => $data['price'],
|
||||||
|
]);
|
||||||
|
$variant->eventDates()->sync([$data['event_date_id']]);
|
||||||
|
$inventory->update(['real_stock' => $data['stock']]);
|
||||||
|
$this->syncDefinitions($variant, $itemAttributes, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<string, ItemAttribute> $itemAttributes */
|
||||||
|
private function syncDefinitions(Variant $variant, Collection $itemAttributes, array $data): void
|
||||||
|
{
|
||||||
|
$definitionAttributes = $itemAttributes->toBase()->only(['horario', 'servicio']);
|
||||||
|
$variant->definitions()->whereIn('item_attribute_id', $definitionAttributes->pluck('id'))->delete();
|
||||||
|
$variant->definitions()->createMany([
|
||||||
|
[
|
||||||
|
'item_attribute_id' => $definitionAttributes['horario']->id,
|
||||||
|
'value' => $data['schedule'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'item_attribute_id' => $definitionAttributes['servicio']->id,
|
||||||
|
'value' => $data['service'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function combinationKey(int $eventDateId, string $schedule, string $service): string
|
||||||
|
{
|
||||||
|
return implode('|', [
|
||||||
|
$eventDateId,
|
||||||
|
mb_strtolower(trim($schedule)),
|
||||||
|
mb_strtolower(trim($service)),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
443
app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php
Normal file
443
app/Domains/FiestaFutbolInfantil/Services/MerchandiseService.php
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
|
use App\Domains\Catalog\Models\AttributeOption;
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
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\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class MerchandiseService
|
||||||
|
{
|
||||||
|
private const ATTRIBUTE_CODES = ['color', 'talle'];
|
||||||
|
|
||||||
|
public function __construct(private readonly CatalogService $catalogService) {}
|
||||||
|
|
||||||
|
/** @return Collection<int, CatalogItem> */
|
||||||
|
public function all(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('category', fn ($query) => $query->where('nombre', 'Merchandising'))
|
||||||
|
->with([
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants.catalogItem',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.definitions',
|
||||||
|
])
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $items
|
||||||
|
* @return Collection<int, CatalogItem>
|
||||||
|
*/
|
||||||
|
public function upsertMany(Tenant $tenant, array $items): Collection
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $items): Collection {
|
||||||
|
$attributes = $this->attributes($tenant);
|
||||||
|
$category = Category::query()->firstOrCreate([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => 'Merchandising',
|
||||||
|
]);
|
||||||
|
$reservedSlugs = [];
|
||||||
|
|
||||||
|
return collect($items)->map(function (array $data, int $index) use (
|
||||||
|
$tenant,
|
||||||
|
$attributes,
|
||||||
|
$category,
|
||||||
|
&$reservedSlugs,
|
||||||
|
): CatalogItem {
|
||||||
|
$item = isset($data['id'])
|
||||||
|
? $this->existingItem($tenant, $category, (int) $data['id'], $index)
|
||||||
|
: $this->createItem($tenant, $category, $data, $reservedSlugs);
|
||||||
|
|
||||||
|
if (! isset($data['id'])) {
|
||||||
|
$reservedSlugs[] = $item->slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
$item->update([
|
||||||
|
'nombre' => trim($data['title']),
|
||||||
|
'descripcion' => $data['description'] ?? null,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'max_units_per_user' => (int) $data['max_units_per_user'],
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
'has_tickets' => true,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$itemAttributes = $this->itemAttributes($item, $attributes);
|
||||||
|
$existingVariants = $item->variants()
|
||||||
|
->with(['inventory', 'definitions'])
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
$variants = $this->resolveVariants($data['variants'], $attributes, $index);
|
||||||
|
|
||||||
|
$this->validateCombinations($variants, $existingVariants, $itemAttributes, $index);
|
||||||
|
|
||||||
|
foreach ($variants as $variantIndex => $variantData) {
|
||||||
|
$variant = isset($variantData['id'])
|
||||||
|
? $existingVariants->firstWhere('id', (int) $variantData['id'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isset($variantData['id']) && $variant === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"items.{$index}.variants.{$variantIndex}.id" => [
|
||||||
|
'La variante no pertenece al artículo de merchandising.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
$this->createVariant($item, $itemAttributes, $variantData);
|
||||||
|
} else {
|
||||||
|
$this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$minimumPrice = $item->variants()->min('precio');
|
||||||
|
if ($minimumPrice !== null) {
|
||||||
|
$item->update(['precio' => $minimumPrice]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item->fresh()->load([
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants.catalogItem',
|
||||||
|
'variants.inventory',
|
||||||
|
'variants.definitions',
|
||||||
|
]);
|
||||||
|
})->values();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $merchandiseId): void
|
||||||
|
{
|
||||||
|
$variant = Variant::query()
|
||||||
|
->whereKey($merchandiseId)
|
||||||
|
->whereHas('catalogItem', fn ($query) => $query
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('category', fn ($categoryQuery) => $categoryQuery
|
||||||
|
->where('nombre', 'Merchandising')))
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$this->catalogService->deleteVariant($variant);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<string, Attribute> */
|
||||||
|
private function attributes(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
$attributes = Attribute::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->whereIn('codigo', self::ATTRIBUTE_CODES)
|
||||||
|
->with('options')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('codigo');
|
||||||
|
|
||||||
|
$missingCodes = collect(self::ATTRIBUTE_CODES)->diff($attributes->keys());
|
||||||
|
if ($missingCodes->isNotEmpty()) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => [
|
||||||
|
'Faltan atributos requeridos para merchandising: '.$missingCodes->implode(', ').'.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $attributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function existingItem(
|
||||||
|
Tenant $tenant,
|
||||||
|
Category $category,
|
||||||
|
int $itemId,
|
||||||
|
int $index,
|
||||||
|
): CatalogItem {
|
||||||
|
$item = CatalogItem::query()
|
||||||
|
->whereKey($itemId)
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('category_id', $category->id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($item === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"items.{$index}.id" => ['El artículo no pertenece al merchandising del tenant.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
* @param array<int, string> $reservedSlugs
|
||||||
|
*/
|
||||||
|
private function createItem(
|
||||||
|
Tenant $tenant,
|
||||||
|
Category $category,
|
||||||
|
array $data,
|
||||||
|
array $reservedSlugs,
|
||||||
|
): CatalogItem {
|
||||||
|
return CatalogItem::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'slug' => $this->uniqueSlug($tenant, $data['title'], $reservedSlugs),
|
||||||
|
'nombre' => trim($data['title']),
|
||||||
|
'descripcion' => $data['description'] ?? null,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'precio' => collect($data['variants'])->min('price') ?? 0,
|
||||||
|
'max_units_per_user' => (int) $data['max_units_per_user'],
|
||||||
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
|
'has_tickets' => true,
|
||||||
|
'ticket_generation_policy' => TicketGenerationPolicy::OnePerUnit->value,
|
||||||
|
'inventory_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, Attribute> $attributes
|
||||||
|
* @return Collection<string, ItemAttribute>
|
||||||
|
*/
|
||||||
|
private function itemAttributes(CatalogItem $item, Collection $attributes): Collection
|
||||||
|
{
|
||||||
|
return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($item): array {
|
||||||
|
$itemAttribute = $item->itemAttributes()->firstOrCreate(
|
||||||
|
['attribute_id' => $attribute->id],
|
||||||
|
['allow_multi_select' => false],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($itemAttribute->allow_multi_select) {
|
||||||
|
$itemAttribute->update(['allow_multi_select' => false]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$code => $itemAttribute];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $variants
|
||||||
|
* @param Collection<string, Attribute> $attributes
|
||||||
|
* @return array<int, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function resolveVariants(array $variants, Collection $attributes, int $itemIndex): array
|
||||||
|
{
|
||||||
|
return collect($variants)->map(function (array $variant, int $variantIndex) use (
|
||||||
|
$attributes,
|
||||||
|
$itemIndex,
|
||||||
|
): array {
|
||||||
|
$color = $this->resolveColor($attributes['color'], $variant['color']);
|
||||||
|
$size = $this->existingOption(
|
||||||
|
$attributes['talle'],
|
||||||
|
$variant['size'],
|
||||||
|
"items.{$itemIndex}.variants.{$variantIndex}.size",
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
...$variant,
|
||||||
|
'color' => $color->value,
|
||||||
|
'size' => $size->value,
|
||||||
|
'stock' => (int) $variant['stock'],
|
||||||
|
];
|
||||||
|
})->all();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function resolveColor(Attribute $attribute, string $color): AttributeOption
|
||||||
|
{
|
||||||
|
$option = $this->findOption($attribute, $color);
|
||||||
|
if ($option !== null) {
|
||||||
|
return $option;
|
||||||
|
}
|
||||||
|
|
||||||
|
$label = trim($color);
|
||||||
|
$option = $attribute->options()->create([
|
||||||
|
'value' => $this->valueCode($label),
|
||||||
|
'label' => $label,
|
||||||
|
'sort_order' => ((int) $attribute->options->max('sort_order')) + 1,
|
||||||
|
]);
|
||||||
|
$attribute->options->push($option);
|
||||||
|
|
||||||
|
return $option;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function existingOption(
|
||||||
|
Attribute $attribute,
|
||||||
|
string $value,
|
||||||
|
string $validationKey,
|
||||||
|
): AttributeOption {
|
||||||
|
$option = $this->findOption($attribute, $value);
|
||||||
|
|
||||||
|
if ($option === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
$validationKey => ["El valor seleccionado no es válido para {$attribute->nombre}."],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $option;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findOption(Attribute $attribute, string $value): ?AttributeOption
|
||||||
|
{
|
||||||
|
$key = $this->optionKey($value);
|
||||||
|
|
||||||
|
return $attribute->options->first(
|
||||||
|
fn (AttributeOption $option): bool => $this->optionKey($option->value) === $key
|
||||||
|
|| $this->optionKey($option->label) === $key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<int, array<string, mixed>> $incoming
|
||||||
|
* @param Collection<int, Variant> $existing
|
||||||
|
* @param Collection<string, ItemAttribute> $itemAttributes
|
||||||
|
*/
|
||||||
|
private function validateCombinations(
|
||||||
|
array $incoming,
|
||||||
|
Collection $existing,
|
||||||
|
Collection $itemAttributes,
|
||||||
|
int $itemIndex,
|
||||||
|
): void {
|
||||||
|
$incomingIds = collect($incoming)->pluck('id')->filter()->map(fn ($id): int => (int) $id);
|
||||||
|
$seen = [];
|
||||||
|
|
||||||
|
foreach ($existing->whereNotIn('id', $incomingIds) as $variant) {
|
||||||
|
$values = $variant->definitions->keyBy('item_attribute_id');
|
||||||
|
$color = $values->get($itemAttributes['color']->id)?->value;
|
||||||
|
$size = $values->get($itemAttributes['talle']->id)?->value;
|
||||||
|
|
||||||
|
if ($color !== null && $size !== null) {
|
||||||
|
$seen[$this->combinationKey($color, $size)] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($incoming as $variantIndex => $variant) {
|
||||||
|
$key = $this->combinationKey($variant['color'], $variant['size']);
|
||||||
|
|
||||||
|
if (isset($seen[$key])) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"items.{$itemIndex}.variants.{$variantIndex}" => [
|
||||||
|
'La combinación de color y talle ya existe para el artículo.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$seen[$key] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, ItemAttribute> $itemAttributes
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function createVariant(
|
||||||
|
CatalogItem $item,
|
||||||
|
Collection $itemAttributes,
|
||||||
|
array $data,
|
||||||
|
): void {
|
||||||
|
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
|
||||||
|
$variant = $item->variants()->create([
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'precio' => $data['price'],
|
||||||
|
]);
|
||||||
|
$this->syncDefinitions($variant, $itemAttributes, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, ItemAttribute> $itemAttributes
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function updateVariant(
|
||||||
|
Variant $variant,
|
||||||
|
Collection $itemAttributes,
|
||||||
|
array $data,
|
||||||
|
int $itemIndex,
|
||||||
|
int $variantIndex,
|
||||||
|
): void {
|
||||||
|
$inventory = Inventory::query()
|
||||||
|
->whereKey($variant->inventory_id)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
if ($data['stock'] < $inventory->reserved_stock) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"items.{$itemIndex}.variants.{$variantIndex}.stock" => [
|
||||||
|
'El stock no puede ser menor que la cantidad actualmente reservada.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->update(['precio' => $data['price']]);
|
||||||
|
$inventory->update(['real_stock' => $data['stock']]);
|
||||||
|
$this->syncDefinitions($variant, $itemAttributes, $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<string, ItemAttribute> $itemAttributes
|
||||||
|
* @param array<string, mixed> $data
|
||||||
|
*/
|
||||||
|
private function syncDefinitions(
|
||||||
|
Variant $variant,
|
||||||
|
Collection $itemAttributes,
|
||||||
|
array $data,
|
||||||
|
): void {
|
||||||
|
$variant->definitions()
|
||||||
|
->whereIn('item_attribute_id', $itemAttributes->pluck('id'))
|
||||||
|
->delete();
|
||||||
|
$variant->definitions()->createMany([
|
||||||
|
[
|
||||||
|
'item_attribute_id' => $itemAttributes['color']->id,
|
||||||
|
'value' => $data['color'],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'item_attribute_id' => $itemAttributes['talle']->id,
|
||||||
|
'value' => $data['size'],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<int, string> $reservedSlugs */
|
||||||
|
private function uniqueSlug(Tenant $tenant, string $title, array $reservedSlugs): string
|
||||||
|
{
|
||||||
|
$baseSlug = Str::slug($title) ?: 'merchandising';
|
||||||
|
$slug = $baseSlug;
|
||||||
|
$suffix = 2;
|
||||||
|
|
||||||
|
while (
|
||||||
|
in_array($slug, $reservedSlugs, true)
|
||||||
|
|| CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('slug', $slug)
|
||||||
|
->exists()
|
||||||
|
) {
|
||||||
|
$slug = "{$baseSlug}-{$suffix}";
|
||||||
|
$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $slug;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function combinationKey(string $color, string $size): string
|
||||||
|
{
|
||||||
|
return $this->optionKey($color).'|'.$this->optionKey($size);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function optionKey(string $value): string
|
||||||
|
{
|
||||||
|
return Str::ascii(mb_strtolower((string) preg_replace('/[_\s]+/u', ' ', trim($value))));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function valueCode(string $value): string
|
||||||
|
{
|
||||||
|
return mb_strtolower((string) preg_replace('/\s+/u', '_', trim($value)));
|
||||||
|
}
|
||||||
|
}
|
||||||
48
app/Domains/FiestaFutbolInfantil/routes/api.php
Normal file
48
app/Domains/FiestaFutbolInfantil/routes/api.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('entries', [EntryController::class, 'index'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.entries.index');
|
||||||
|
Route::post('entries', [EntryController::class, 'store'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.entries.store');
|
||||||
|
Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.entries.destroy');
|
||||||
|
Route::get('foods', [FoodController::class, 'index'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.foods.index');
|
||||||
|
Route::post('foods', [FoodController::class, 'store'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.foods.store');
|
||||||
|
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
||||||
|
Route::get('accommodations', [AccommodationController::class, 'index'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.accommodations.index');
|
||||||
|
Route::post('accommodations', [AccommodationController::class, 'store'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.accommodations.store');
|
||||||
|
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
|
||||||
|
Route::get('merchandise', [MerchandiseController::class, 'index'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.merchandise.index');
|
||||||
|
Route::post('merchandise', [MerchandiseController::class, 'store'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.merchandise.store');
|
||||||
|
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
|
||||||
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
|
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
|
||||||
|
});
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\EventFormResource;
|
||||||
|
use App\Domains\Forms\Services\EventFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class EventFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected EventFormService $eventFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): EventFormResource
|
||||||
|
{
|
||||||
|
return EventFormResource::make(
|
||||||
|
$this->eventFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\FoodFormResource;
|
||||||
|
use App\Domains\Forms\Services\FoodFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class FoodFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected FoodFormService $foodFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): FoodFormResource
|
||||||
|
{
|
||||||
|
return FoodFormResource::make(
|
||||||
|
$this->foodFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\MerchandiseFormResource;
|
||||||
|
use App\Domains\Forms\Services\MerchandiseFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class MerchandiseFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected MerchandiseFormService $merchandiseFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): MerchandiseFormResource
|
||||||
|
{
|
||||||
|
return MerchandiseFormResource::make(
|
||||||
|
$this->merchandiseFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\SaleFormResource;
|
||||||
|
use App\Domains\Forms\Services\SaleFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class SaleFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected SaleFormService $saleFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(): SaleFormResource
|
||||||
|
{
|
||||||
|
return SaleFormResource::make($this->saleFormService->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\StaffFormResource;
|
||||||
|
use App\Domains\Forms\Services\StaffFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class StaffFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected StaffFormService $staffFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): StaffFormResource
|
||||||
|
{
|
||||||
|
return StaffFormResource::make(
|
||||||
|
$this->staffFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
app/Domains/Forms/Resources/EventFormResource.php
Normal file
19
app/Domains/Forms/Resources/EventFormResource.php
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class EventFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'social_media' => SocialMediaOptionResource::collection(
|
||||||
|
$this->resource['social_media']
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/Domains/Forms/Resources/FoodFormResource.php
Normal file
42
app/Domains/Forms/Resources/FoodFormResource.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\AttributeOption;
|
||||||
|
use App\Domains\Event\Models\EventDate;
|
||||||
|
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class FoodFormResource 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(),
|
||||||
|
'schedules' => $this->options($this->resource['schedules']),
|
||||||
|
'services' => $this->options($this->resource['services']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, AttributeOption> $options
|
||||||
|
* @return Collection<int, array{value: string, label: string}>
|
||||||
|
*/
|
||||||
|
private function options(Collection $options): Collection
|
||||||
|
{
|
||||||
|
return $options->map(fn (AttributeOption $option): array => [
|
||||||
|
'value' => $option->value,
|
||||||
|
'label' => $option->label,
|
||||||
|
])->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Domains/Forms/Resources/MerchandiseFormResource.php
Normal file
32
app/Domains/Forms/Resources/MerchandiseFormResource.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\AttributeOption;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class MerchandiseFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'colors' => $this->options($this->resource['colors']),
|
||||||
|
'sizes' => $this->options($this->resource['sizes']),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, AttributeOption> $options
|
||||||
|
* @return Collection<int, array{value: string, label: string}>
|
||||||
|
*/
|
||||||
|
private function options(Collection $options): Collection
|
||||||
|
{
|
||||||
|
return $options->map(fn (AttributeOption $option): array => [
|
||||||
|
'value' => $option->value,
|
||||||
|
'label' => $option->label,
|
||||||
|
])->values();
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Domains/Forms/Resources/SaleFormResource.php
Normal file
17
app/Domains/Forms/Resources/SaleFormResource.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class SaleFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'statuses' => $this->resource['statuses'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Domains/Forms/Resources/SocialMediaOptionResource.php
Normal file
22
app/Domains/Forms/Resources/SocialMediaOptionResource.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\SocialMedia;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin SocialMedia */
|
||||||
|
class SocialMediaOptionResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => $this->code,
|
||||||
|
'name' => $this->name,
|
||||||
|
'icon' => $this->icon,
|
||||||
|
'url' => $this->url,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
20
app/Domains/Forms/Resources/StaffFormResource.php
Normal file
20
app/Domains/Forms/Resources/StaffFormResource.php
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class StaffFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => $this->resource['categories']->map(fn ($category) => [
|
||||||
|
'id' => $category->id,
|
||||||
|
'nombre' => $category->nombre,
|
||||||
|
])->values(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Domains/Forms/Services/EventFormService.php
Normal file
27
app/Domains/Forms/Services/EventFormService.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\SocialMedia;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class EventFormService
|
||||||
|
{
|
||||||
|
/** @return array{social_media: Collection<int, SocialMedia>} */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$urls = $tenant->socialMedia()
|
||||||
|
->pluck('tenant_social_media.url', 'social_media.code');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'social_media' => SocialMedia::query()
|
||||||
|
->orderBy('id')
|
||||||
|
->get()
|
||||||
|
->each(fn (SocialMedia $item) => $item->setAttribute(
|
||||||
|
'url',
|
||||||
|
$urls->get($item->code)
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Domains/Forms/Services/FoodFormService.php
Normal file
35
app/Domains/Forms/Services/FoodFormService.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
|
use App\Domains\Catalog\Models\AttributeOption;
|
||||||
|
use App\Domains\Event\Models\EventDate;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class FoodFormService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* event_dates: Collection<int, EventDate>,
|
||||||
|
* schedules: Collection<int, AttributeOption>,
|
||||||
|
* services: Collection<int, AttributeOption>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$attributes = Attribute::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->whereIn('codigo', ['horario', 'servicio'])
|
||||||
|
->with('options')
|
||||||
|
->get()
|
||||||
|
->keyBy('codigo');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'event_dates' => $tenant->eventDates()->with('validityTime')->get(),
|
||||||
|
'schedules' => $attributes->get('horario')?->options ?? new Collection,
|
||||||
|
'services' => $attributes->get('servicio')?->options ?? new Collection,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Domains/Forms/Services/MerchandiseFormService.php
Normal file
32
app/Domains/Forms/Services/MerchandiseFormService.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Attribute;
|
||||||
|
use App\Domains\Catalog\Models\AttributeOption;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class MerchandiseFormService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* colors: Collection<int, AttributeOption>,
|
||||||
|
* sizes: Collection<int, AttributeOption>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$attributes = Attribute::query()
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->whereIn('codigo', ['color', 'talle'])
|
||||||
|
->with('options')
|
||||||
|
->get()
|
||||||
|
->keyBy('codigo');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'colors' => $attributes->get('color')?->options ?? new Collection,
|
||||||
|
'sizes' => $attributes->get('talle')?->options ?? new Collection,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/Domains/Forms/Services/SaleFormService.php
Normal file
31
app/Domains/Forms/Services/SaleFormService.php
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
|
||||||
|
class SaleFormService
|
||||||
|
{
|
||||||
|
/** @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_PAID => 'Confirmada',
|
||||||
|
Purchase::STATUS_CANCELLED => 'Cancelada',
|
||||||
|
Purchase::STATUS_REJECTED => 'Rechazada',
|
||||||
|
Purchase::STATUS_EXPIRED => 'Vencida',
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'statuses' => array_map(
|
||||||
|
fn (string $status): array => [
|
||||||
|
'code' => $status,
|
||||||
|
'name' => $names[$status],
|
||||||
|
],
|
||||||
|
Purchase::statuses(),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
27
app/Domains/Forms/Services/StaffFormService.php
Normal file
27
app/Domains/Forms/Services/StaffFormService.php
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class StaffFormService
|
||||||
|
{
|
||||||
|
/** @return array{categories: Collection<int, Category>} */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => Category::query()
|
||||||
|
->whereNull('categoria_id')
|
||||||
|
->where(function (Builder $query) use ($tenant): void {
|
||||||
|
$query->where('tenant_code', $tenant->codigo)
|
||||||
|
->orWhereHas('catalogItems', fn (Builder $items) => $items
|
||||||
|
->where('tenant_code', $tenant->codigo));
|
||||||
|
})
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user