Compare commits

..

4 Commits

997 changed files with 10407 additions and 79077 deletions

View File

@@ -3,17 +3,11 @@ APP_ENV=production
APP_KEY=
APP_DEBUG=false
APP_URL=http://localhost
FRONTEND_URL=http://localhost:4200
INTEGRATION_SECRET=
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
PURCHASE_QR_EXPIRATION_MINUTES=15
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
STOCK_RESERVATION_EXPIRATION_MINUTES=30
FRONTEND_URLS=http://localhost:4200
APP_LOCALE=es
APP_FALLBACK_LOCALE=es
APP_LOCALE=en
APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US
APP_MAINTENANCE_DRIVER=file
@@ -21,47 +15,25 @@ APP_MAINTENANCE_DRIVER=file
# PHP_CLI_SERVER_WORKERS=4
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GOOGLE_REDIRECT_URI=http://localhost/auth/google/callback
BCRYPT_ROUNDS=12
AUTH_MAX_LOGIN_ATTEMPTS=5
AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
AUTH_LOGIN_LOCK_MINUTES=15
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
LOG_CHANNEL=daily
LOG_CHANNEL=stack
LOG_STACK=single
LOG_DEPRECATIONS_CHANNEL=null
LOG_LEVEL=debug
LOG_DAILY_DAYS=14
TELEPAGOS_LOG_LEVEL=info
TELEPAGOS_LOG_DAYS=30
COMMANDS_LOG_LEVEL=info
COMMANDS_LOG_DAYS=30
EMAILS_LOG_LEVEL=info
EMAILS_LOG_DAYS=30
EMAIL_DELIVERY_LEASE_SECONDS=300
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=laravel
DB_USERNAME=root
DB_PASSWORD=
INTEGRATION_SECRET=change-me
DB_CONNECTION=sqlite
# DB_HOST=127.0.0.1
# DB_PORT=3306
# DB_DATABASE=laravel
# DB_USERNAME=root
# DB_PASSWORD=
SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=false
SESSION_PATH=/
SESSION_DOMAIN=null
SANCTUM_STATEFUL_DOMAINS=localhost,127.0.0.1,*.shopit.com.ar
SANCTUM_EXPIRATION=720
SESSION_SECURE_COOKIE=false
BROADCAST_CONNECTION=log
FILESYSTEM_DISK=local
@@ -77,7 +49,7 @@ REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_MAILER=log
MAIL_SCHEME=null
MAIL_HOST=127.0.0.1
MAIL_PORT=2525
@@ -86,11 +58,13 @@ MAIL_PASSWORD=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
AWS_ENDPOINT=
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_DEFAULT_REGION=
AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
AWS_HTTP_VERIFY=true
AWS_USE_PATH_STYLE_ENDPOINT=
AWS_HTTP_VERIFY=
VITE_APP_NAME="${APP_NAME}"

View File

@@ -1,29 +1,19 @@
# Project Conventions
## Test database safety
- Tests must use SQLite `:memory:` through `tests/bootstrap.php` and `Tests\TestCase`.
- Never run tests, `migrate:fresh`, `migrate:refresh`, or `db:wipe` against a persistent database, including the developer's `shopit` database.
- Never bypass the connection safety guard to resolve test failures. Use `php tests/verify-database-safety.php` to verify isolation without queries or migrations.
## Architecture
This project uses a domain-oriented structure grouped by business area under
`app/Domains`. Cross-cutting modules live under `app/Shared`.
This project uses a domain-oriented structure under `app/Domains`.
Business domains must live in:
Each domain must live in:
```text
app/Domains/<Area>/<DomainName>/
app/Domains/<DomainName>/
```
The current areas are `Core`, `Commerce`, and `Ticketing`. Cross-cutting
modules live in `app/Shared/<ModuleName>/`.
Supported folders inside each domain:
```text
app/Domains/<Area>/<DomainName>/
app/Domains/<DomainName>/
├── Models/
├── Controllers/
├── Requests/
@@ -38,28 +28,22 @@ app/Domains/<Area>/<DomainName>/
- Root API routes live in `routes/api.php`.
- `routes/api.php` should act as an aggregator only.
- Each business domain defines its own routes in
`app/Domains/<Area>/<DomainName>/routes/api.php`.
- Shared modules that expose routes define them in
`app/Shared/<ModuleName>/routes/api.php`.
- Each domain defines its own routes in `app/Domains/<DomainName>/routes/api.php`.
## Namespaces
Use this namespace pattern:
```php
App\Domains\<Area>\<DomainName>\Models
App\Domains\<Area>\<DomainName>\Controllers
App\Domains\<Area>\<DomainName>\Requests
App\Domains\<Area>\<DomainName>\Resources
App\Domains\<Area>\<DomainName>\Services
App\Domains\<Area>\<DomainName>\Policies
App\Domains\<Area>\<DomainName>\Exceptions
App\Domains\<DomainName>\Models
App\Domains\<DomainName>\Controllers
App\Domains\<DomainName>\Requests
App\Domains\<DomainName>\Resources
App\Domains\<DomainName>\Services
App\Domains\<DomainName>\Policies
App\Domains\<DomainName>\Exceptions
```
For cross-cutting modules, use `App\Shared\<ModuleName>\...`. Reusable enums
and rules live directly under `App\Shared`.
## Responsibilities
- `Models/`: Eloquent models and model relationships.
@@ -90,19 +74,13 @@ and rules live directly under `App\Shared`.
- Namespace, folder, and class names must stay aligned.
- Route files should be named `api.php`.
## Current Areas and Modules
## Current Domains
- `Core`: `Administrator`, `Auth`, `Authorization`, `Bootstrap`, `Client`,
`Menu`, `Staff`, `Tenant`.
- `Commerce`: `Cart`, `Catalog`, `Purchase`, `Sale`.
- `Ticketing`: `Desfile`, `Event`, `FiestaFutbolInfantil`, `Ticket`.
- `Shared`: `Attachable`, `Forms`, `Integration`, `Logging`, `MailTest`,
`Notification`, `StorageTest`, plus shared enums and rules.
- `Tenant`
- `Product`
## Notes For Future Changes
- New features should be added inside the corresponding domain first.
- Add new business features to the appropriate area under `app/Domains`.
- Keep generic reusable code in `app/Shared`; do not use it as a catch-all for
business features.
- If a concern does not belong to an existing domain, create a new domain under `app/Domains`.
- Avoid placing new business code in `app/Http/Controllers` or `app/Models`.

File diff suppressed because it is too large Load Diff

View File

@@ -41,18 +41,15 @@
}
],
"url": {
"raw": "{{base_url}}/api/tenants/bootstrap?dominio=acme.com&path=/",
"raw": "{{base_url}}/api/tenants/bootstrap/acme.com",
"host": [
"{{base_url}}"
],
"path": [
"api",
"tenants",
"bootstrap"
],
"query": [
{"key": "dominio", "value": "acme.com"},
{"key": "path", "value": "/"}
"bootstrap",
"acme.com"
]
}
},

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Shared\Attachable\Enums;
namespace App\Domains\Attachable\Enums;
enum AttachmentType: string
{

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Shared\Attachable\Exceptions;
namespace App\Domains\Attachable\Exceptions;
use RuntimeException;

View File

@@ -1,14 +1,11 @@
<?php
namespace App\Shared\Attachable\Models;
namespace App\Domains\Attachable\Models;
use App\Shared\Attachable\Enums\AttachmentType;
use App\Shared\Storage\Services\TemporaryUrlService;
use App\Domains\Attachable\Enums\AttachmentType;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Str;
#[Fillable([
@@ -43,36 +40,14 @@ class Attachment extends Model
];
}
public function cropVariants(): HasMany
{
return $this->hasMany(AttachmentCrop::class);
}
public function desktopCrop(): HasOne
{
return $this->hasOne(AttachmentCrop::class)
->where('variant', AttachmentCrop::DESKTOP);
}
public function mobileCrop(): HasOne
{
return $this->hasOne(AttachmentCrop::class)
->where('variant', AttachmentCrop::MOBILE);
}
public function cropSource(): HasOne
{
return $this->hasOne(AttachmentCrop::class, 'cropped_attachment_id');
}
/**
* Get the pre-signed temporary S3 URL for this attachment.
*/
public function getTemporaryUrl(int $expiresInMinutes = 10): string
{
return app(TemporaryUrlService::class)->generate(
return \Illuminate\Support\Facades\Storage::disk('s3')->temporaryUrl(
$this->path,
$expiresInMinutes,
)['temporary_url'];
now()->addMinutes($expiresInMinutes)
);
}
}

View File

@@ -0,0 +1,253 @@
<?php
namespace App\Domains\Attachable\Services;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Exceptions\AttachmentStorageException;
use App\Domains\Attachable\Models\Attachment;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Symfony\Component\Mime\MimeTypes;
use Throwable;
class AttachmentService
{
public function store(
UploadedFile|string $file,
string $path,
): Attachment {
$normalizedPath = $this->normalizeDirectory($path);
if ($normalizedPath === '') {
throw new AttachmentStorageException('The attachment path cannot be empty.');
}
$key = (string) Str::uuid();
$fileData = $this->resolveFileData($file, $key);
$storedPath = $this->storeFile(
$fileData,
$normalizedPath,
$this->buildStoredFilename($key, $fileData['extension']),
);
if (! is_string($storedPath) || $storedPath === '') {
throw new AttachmentStorageException('No se pudo subir el archivo al disco s3.');
}
try {
/** @var Attachment $attachment */
$attachment = Attachment::query()->create([
'key' => $key,
'path' => $storedPath,
'filename' => $this->resolveFilename($file, $key),
'type' => $this->resolveAttachmentType($fileData['mime_type']),
'mime_type' => $fileData['mime_type'],
'extension' => $fileData['extension'],
'size' => $fileData['size'],
]);
return $attachment;
} catch (Throwable $throwable) {
Storage::disk('s3')->delete($storedPath);
throw $throwable;
}
}
public function delete(Attachment $attachment): void
{
$deleted = Storage::disk('s3')->delete($attachment->path);
if (! $deleted) {
throw new AttachmentStorageException('No se pudo eliminar el archivo del disco s3.');
}
$attachment->delete();
}
protected function normalizeDirectory(string $path): string
{
return trim($path, '/');
}
protected function resolveFilename(UploadedFile|string $file, string $key): string
{
if ($file instanceof UploadedFile) {
$originalName = trim($file->getClientOriginalName());
if ($originalName !== '') {
return $originalName;
}
}
return $key;
}
/**
* @return array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int}
*/
protected function resolveFileData(UploadedFile|string $file, string $filename): array
{
if ($file instanceof UploadedFile) {
return $this->fileDataFromUploadedFile($file);
}
return $this->fileDataFromBase64($file, $filename);
}
/**
* @return array{contents: string|null, extension: string, file: UploadedFile, mime_type: string, size: int}
*/
protected function fileDataFromUploadedFile(UploadedFile $file): array
{
$mimeType = strtolower($file->getClientMimeType() ?? $file->getMimeType() ?? 'application/octet-stream');
return [
'contents' => null,
'extension' => strtolower($file->getClientOriginalExtension() ?: $file->extension() ?: ''),
'file' => $file,
'mime_type' => $mimeType,
'size' => $file->getSize() ?? 0,
];
}
/**
* @return array{contents: string, extension: string, file: null, mime_type: string, size: int}
*/
protected function fileDataFromBase64(string $file, string $filename): array
{
['data' => $contents, 'mime_type' => $declaredMimeType] = $this->decodeBase64File($file);
$mimeType = $this->detectMimeType($contents, $declaredMimeType);
return [
'contents' => $contents,
'extension' => $this->resolveExtension($filename, $mimeType),
'file' => null,
'mime_type' => $mimeType,
'size' => strlen($contents),
];
}
/**
* @return array{data: string, mime_type: string|null}
*/
protected function decodeBase64File(string $file): array
{
$payload = trim($file);
if ($payload === '') {
throw new AttachmentStorageException('The base64 attachment content cannot be empty.');
}
$declaredMimeType = null;
if (preg_match('/^data:(?<mime>[-\w.+\/]+);base64,(?<data>.+)$/s', $payload, $matches) === 1) {
$declaredMimeType = strtolower($matches['mime']);
$payload = $matches['data'];
}
$decoded = base64_decode(preg_replace('/\s+/', '', $payload), true);
if ($decoded === false || $decoded === '') {
throw new AttachmentStorageException('The attachment base64 payload is invalid.');
}
return [
'data' => $decoded,
'mime_type' => $declaredMimeType,
];
}
protected function detectMimeType(string $contents, ?string $fallback = null): string
{
if (is_string($fallback) && $fallback !== '') {
return strtolower($fallback);
}
$detectedMimeType = (new \finfo(FILEINFO_MIME_TYPE))->buffer($contents);
if (is_string($detectedMimeType) && $detectedMimeType !== '') {
return strtolower($detectedMimeType);
}
return strtolower($fallback ?? 'application/octet-stream');
}
protected function resolveExtension(string $filename, string $mimeType): string
{
$extension = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
if ($extension !== '') {
return $extension;
}
return strtolower(MimeTypes::getDefault()->getExtensions($mimeType)[0] ?? '');
}
/**
* @param array{contents: string|null, extension: string, file: UploadedFile|null, mime_type: string, size: int} $fileData
*/
protected function storeFile(array $fileData, string $directory, string $storedFilename): string
{
if ($fileData['file'] instanceof UploadedFile) {
return Storage::disk('s3')->putFileAs($directory, $fileData['file'], $storedFilename);
}
$storedPath = $directory !== ''
? $directory.'/'.$storedFilename
: $storedFilename;
$stored = Storage::disk('s3')->put($storedPath, $fileData['contents'] ?? '');
if (! $stored) {
return '';
}
return $storedPath;
}
protected function resolveAttachmentType(string $mimeType): AttachmentType
{
$mimeType = strtolower($mimeType);
if (str_starts_with($mimeType, 'image/')) {
return AttachmentType::Image;
}
if (str_starts_with($mimeType, 'video/')) {
return AttachmentType::Video;
}
if ($mimeType === 'application/pdf') {
return AttachmentType::Pdf;
}
if (str_starts_with($mimeType, 'audio/')) {
return AttachmentType::Audio;
}
if (
str_starts_with($mimeType, 'text/')
|| str_contains($mimeType, 'document')
|| str_contains($mimeType, 'word')
|| str_contains($mimeType, 'excel')
|| str_contains($mimeType, 'spreadsheet')
|| str_contains($mimeType, 'presentation')
|| str_contains($mimeType, 'officedocument')
) {
return AttachmentType::Document;
}
return AttachmentType::Other;
}
protected function buildStoredFilename(string $key, string $extension): string
{
if ($extension === '') {
return $key;
}
return $key.'.'.$extension;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace App\Domains\Auth\Controllers;
use App\Domains\Auth\Requests\LoginUserRequest;
use App\Domains\Auth\Models\User;
use App\Domains\Auth\Resources\UserResource;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\ValidationException;
class LoginController extends Controller
{
/**
* @throws ValidationException
*/
public function __invoke(LoginUserRequest $request): JsonResponse
{
$credentials = $request->validated();
$user = User::query()->where('email', $credentials['email'])->first();
if (! $user || ! Hash::check($credentials['password'], $user->password)) {
throw ValidationException::withMessages([
'email' => 'Email o Contraseña incorrectos.',
]);
}
$token = $user->createToken('api-token')->plainTextToken;
return response()->json([
'message' => 'Sesion iniciada correctamente.',
'token' => $token,
'token_type' => 'Bearer',
'user' => UserResource::make($user),
]);
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Core\Auth\Controllers;
namespace App\Domains\Auth\Controllers;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -23,8 +23,7 @@ class LogoutController extends Controller
}
return response()->json([
'code' => 'auth.logout_success',
'message' => __('api.auth.logout_success'),
'message' => 'Sesion cerrada correctamente.',
]);
}
}

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Core\Auth\Controllers;
namespace App\Domains\Auth\Controllers;
use App\Domains\Core\Auth\Resources\UserResource;
use App\Domains\Auth\Resources\UserResource;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Core\Auth\Controllers;
namespace App\Domains\Auth\Controllers;
use App\Domains\Core\Auth\Requests\RegisterUserRequest;
use App\Domains\Core\Auth\Resources\UserResource;
use App\Domains\Core\Auth\Services\RegisterUserService;
use App\Domains\Auth\Requests\RegisterUserRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\RegisterUserService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
@@ -12,17 +12,15 @@ class RegisterController extends Controller
{
public function __construct(
protected RegisterUserService $registerUserService,
) {}
) {
}
public function __invoke(RegisterUserRequest $request): JsonResponse
{
$user = $this->registerUserService->register($request->validated());
return UserResource::make($user)
->additional([
'code' => 'auth.register_success',
'message' => __('api.auth.register_success'),
])
->additional(['message' => 'Usuario registrado correctamente.'])
->response()
->setStatusCode(201);
}

View File

@@ -1,10 +1,10 @@
<?php
namespace App\Domains\Core\Auth\Controllers;
namespace App\Domains\Auth\Controllers;
use App\Domains\Core\Auth\Requests\UpdateProfileRequest;
use App\Domains\Core\Auth\Resources\UserResource;
use App\Domains\Core\Auth\Services\ProfileService;
use App\Domains\Auth\Requests\UpdateProfileRequest;
use App\Domains\Auth\Resources\UserResource;
use App\Domains\Auth\Services\ProfileService;
use Illuminate\Http\JsonResponse;
class UpdateProfileController

View File

@@ -0,0 +1,35 @@
<?php
namespace App\Domains\Auth\Models;
use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Attributes\Hidden;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono'])]
#[Hidden(['password', 'remember_token'])]
class User extends Authenticatable
{
/** @use HasFactory<UserFactory> */
use HasApiTokens, HasFactory, Notifiable;
protected static function newFactory(): UserFactory
{
return UserFactory::new();
}
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
}
}

View File

@@ -1,9 +1,8 @@
<?php
namespace App\Domains\Core\Auth\Requests;
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Str;
class LoginUserRequest extends FormRequest
{
@@ -12,17 +11,6 @@ class LoginUserRequest extends FormRequest
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>
*/
@@ -31,7 +19,6 @@ class LoginUserRequest extends FormRequest
return [
'email' => ['required', 'string', 'email', 'max:255'],
'password' => ['required', 'string'],
'tenant_codigo' => ['required', 'string', 'exists:tenants,codigo'],
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class RegisterUserRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'nombre_apellido' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
'password' => ['required', 'string', 'confirmed', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
'dni' => ['nullable', 'string', 'max:255'],
'telefono' => ['nullable', 'string', 'max:255'],
];
}
}

View File

@@ -1,10 +1,9 @@
<?php
namespace App\Domains\Core\Auth\Requests;
namespace App\Domains\Auth\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
class UpdateProfileRequest extends FormRequest
{
@@ -13,13 +12,6 @@ class UpdateProfileRequest extends FormRequest
return true;
}
protected function prepareForValidation(): void
{
if (is_string($this->input('email'))) {
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
}
}
public function rules(): array
{
return [
@@ -27,13 +19,11 @@ class UpdateProfileRequest extends FormRequest
'email' => [
'required',
'email',
Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo)
->whereNull('deleted_at')
->ignore($this->user()->id),
Rule::unique('users', 'email')->ignore($this->user()->id),
],
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
'password' => ['nullable', 'string', Password::min(8)->mixedCase()->symbols()],
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
];
}
}

View File

@@ -1,13 +1,12 @@
<?php
namespace App\Domains\Core\Auth\Resources;
namespace App\Domains\Auth\Resources;
use App\Domains\Core\Auth\Models\User;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin User
* @mixin \App\Domains\Auth\Models\User
*/
class UserResource extends JsonResource
{
@@ -22,13 +21,6 @@ class UserResource extends JsonResource
'email' => $this->email,
'dni' => $this->dni,
'telefono' => $this->telefono,
'rol_codigo' => $this->rol_codigo,
'tenant_codigo' => $this->tenant_codigo,
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
->map(fn ($category) => [
'id' => $category->id,
'nombre' => $category->nombre,
])->values()),
];
}
}

View File

@@ -1,8 +1,8 @@
<?php
namespace App\Domains\Core\Auth\Services;
namespace App\Domains\Auth\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Auth\Models\User;
use Illuminate\Support\Facades\Hash;
class ProfileService

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Auth\Services;
use App\Domains\Auth\Models\User;
class RegisterUserService
{
/**
* @param array{nombre_apellido: string, email: string, password: string, dni?: string|null, telefono?: string|null} $data
*/
public function register(array $data): User
{
return User::query()->create([
'nombre_apellido' => $data['nombre_apellido'],
'email' => $data['email'],
'password' => $data['password'],
'dni' => $data['dni'] ?? null,
'telefono' => $data['telefono'] ?? null,
]);
}
}

View File

@@ -0,0 +1,14 @@
<?php
use App\Domains\Auth\Controllers\LoginController;
use App\Domains\Auth\Controllers\LogoutController;
use App\Domains\Auth\Controllers\MeController;
use App\Domains\Auth\Controllers\RegisterController;
use App\Domains\Auth\Controllers\UpdateProfileController;
use Illuminate\Support\Facades\Route;
Route::post('/register', RegisterController::class);
Route::post('/login', LoginController::class);
Route::middleware('auth:sanctum')->post('/logout', LogoutController::class);
Route::middleware('auth:sanctum')->get('/me', MeController::class);
Route::middleware('auth:sanctum')->put('/me', UpdateProfileController::class);

View File

@@ -0,0 +1,145 @@
<?php
namespace App\Domains\Bundle\Models;
use App\Domains\Tenant\Models\Tenant;
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\HasMany;
use Illuminate\Database\Eloquent\Casts\Attribute;
use App\Domains\Shared\Contracts\Buyable;
#[Fillable([
'tenant_codigo',
'nombre',
'descripcion',
'precio',
])]
class Bundle extends Model implements Buyable
{
use HasFactory;
protected $table = 'bundles';
protected $appends = ['stock_tecnico'];
protected function casts(): array
{
return [
'precio' => 'decimal:2',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return HasMany<BundleItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(BundleItem::class, 'bundle_id');
}
public function getPrice(): float
{
return (float) $this->precio;
}
public function getName(): string
{
return $this->nombre ?? 'Bundle';
}
public function incrementRealStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->incrementRealStock($amount * $item->cantidad);
}
}
public function decrementRealStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->decrementRealStock($amount * $item->cantidad);
}
}
public function incrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->incrementReservedStock($amount * $item->cantidad);
}
}
public function decrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->decrementReservedStock($amount * $item->cantidad);
}
}
public function confirmReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.');
}
foreach ($this->items as $item) {
$item->variant->confirmReservedStock($amount * $item->cantidad);
}
}
public function validateStock(): void
{
if ($this->stock_tecnico <= 0) {
throw new \InvalidArgumentException('El bundle no tiene stock técnico disponible.');
}
}
protected function stockTecnico(): Attribute
{
return Attribute::get(function () {
if ($this->items->isEmpty()) {
return 0;
}
$minStock = PHP_INT_MAX;
foreach ($this->items as $item) {
// Ensure variant is loaded
if ($item->variant) {
$itemStock = floor($item->variant->stock_tecnico / $item->cantidad);
if ($itemStock < $minStock) {
$minStock = $itemStock;
}
}
}
return $minStock === PHP_INT_MAX ? 0 : (int) $minStock;
});
}
}

View File

@@ -0,0 +1,46 @@
<?php
namespace App\Domains\Bundle\Models;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'bundle_id',
'producto_variante_id',
'cantidad',
])]
class BundleItem extends Model
{
use HasFactory;
protected $table = 'bundle_items';
protected function casts(): array
{
return [
'bundle_id' => 'integer',
'producto_variante_id' => 'integer',
'cantidad' => 'integer',
];
}
/**
* @return BelongsTo<Bundle, $this>
*/
public function bundle(): BelongsTo
{
return $this->belongsTo(Bundle::class, 'bundle_id');
}
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
}

View File

@@ -0,0 +1,69 @@
<?php
namespace App\Domains\Cart\Controllers;
use App\Domains\Cart\Requests\AddCartItemRequest;
use App\Domains\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Cart\Resources\CartResource;
use App\Domains\Cart\Services\CartService;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function __construct(
protected CartService $cartService,
) {
}
public function show(Request $request, Tenant $tenant): CartResource
{
return CartResource::make($this->cartService->show($tenant, $request));
}
public function addItem(AddCartItemRequest $request, Tenant $tenant): JsonResponse
{
$result = $this->cartService->addItem(
$tenant,
$request,
$request->mappedBuyableType(),
(int) $request->validated('buyable_id'),
(int) $request->validated('cantidad'),
);
$response = CartResource::make($result['cart'])
->additional(['message' => 'Producto agregado al carrito.'])
->response();
if ($result['guest_token'] !== null) {
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
}
return $response;
}
public function updateItemQuantity(
UpdateCartItemQuantityRequest $request,
Tenant $tenant,
\App\Domains\Cart\Models\CartItem $cartItem,
): CartResource {
return CartResource::make(
$this->cartService->updateItemQuantity(
$tenant,
$request,
$cartItem->getKey(),
(int) $request->validated('cantidad'),
)
)->additional(['message' => 'Cantidad de producto actualizada.']);
}
public function removeItem(Request $request, Tenant $tenant, \App\Domains\Cart\Models\CartItem $cartItem): CartResource
{
return CartResource::make(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional(['message' => 'Producto eliminado del carrito.']);
}
}

View File

@@ -0,0 +1,73 @@
<?php
namespace App\Domains\Cart\Middleware;
use App\Domains\Cart\Models\Cart;
use App\Domains\Tenant\Models\Tenant;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Cookie;
use Symfony\Component\HttpFoundation\Response;
class MergeGuestCartMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$guestToken = $request->cookie('guest_token');
// Resolve authenticated user optionally (from default guard or sanctum guard)
$user = $request->user() ?? Auth::guard('sanctum')->user();
$userId = $user?->getKey();
\Illuminate\Support\Facades\Log::info('MergeGuestCartMiddleware processed', [
'user_id' => $userId,
'guest_token' => $guestToken,
]);
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
$tenantParam = $request->route('tenant');
$tenantCodigo = null;
if ($tenantParam instanceof Tenant) {
$tenantCodigo = $tenantParam->codigo;
} elseif (is_string($tenantParam)) {
$tenantCodigo = $tenantParam;
}
if ($tenantCodigo !== null) {
$guestCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('guest_token', $guestToken)
->first();
if ($guestCart !== null && $guestCart->items()->exists()) {
$userCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('user_id', $userId)
->first();
if ($userCart !== null) {
$userCart->delete();
}
$guestCart->update([
'user_id' => $userId,
'guest_token' => null,
]);
}
}
}
$response = $next($request);
if ($user !== null && is_string($guestToken) && $guestToken !== '') {
// Remove the cookie from the response since the user is authenticated.
if (method_exists($response, 'withCookie')) {
$response->withCookie(Cookie::forget('guest_token'));
}
}
return $response;
}
}

View File

@@ -0,0 +1,196 @@
<?php
namespace App\Domains\Cart\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
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\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
#[Fillable([
'tenant_codigo',
'user_id',
'guest_token',
'status',
])]
class Cart extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'carritos';
protected function casts(): array
{
return [
'user_id' => 'integer',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return HasMany<CartItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(CartItem::class, 'cart_id');
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
? $this->getRelation('items')
: $this->items()->with('buyable')->get();
return (float) $items->reduce(
fn (float $carry, $item): float => $carry + ($item->buyable?->getPrice() * $item->cantidad),
0.0,
);
}
public function addItem(string $buyableType, int $buyableId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
]);
}
return DB::transaction(function () use ($buyableType, $buyableId, $quantity): CartItem {
$buyable = $this->resolveScopedBuyable($buyableType, $buyableId, true);
if ($buyable->stock_tecnico < $quantity) {
throw ValidationException::withMessages([
'cantidad' => "Stock insuficiente para la variante/bundle solicitado. Maximo disponible: {$buyable->stock_tecnico}.",
]);
}
/** @var CartItem|null $item */
$item = $this->items()
->where('buyable_type', $buyableType)
->where('buyable_id', $buyable->getKey())
->lockForUpdate()
->first();
if ($item === null) {
$item = $this->items()->create([
'buyable_type' => $buyableType,
'buyable_id' => $buyable->getKey(),
'cantidad' => $quantity,
]);
} else {
$item->cantidad += $quantity;
$item->save();
}
$buyable->incrementReservedStock($quantity);
return $item->fresh();
});
}
public function updateItem(int $cartItemId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => 'La cantidad debe ser mayor a cero.',
]);
}
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$delta = $quantity - $item->cantidad;
if ($delta > 0 && $buyable->stock_tecnico < $delta) {
$maxAvailable = $buyable->stock_tecnico + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => "El máximo que se puede agregar es {$maxAvailable}.",
]);
}
$item->cantidad = $quantity;
$item->save();
if ($delta > 0) {
$buyable->incrementReservedStock($delta);
}
if ($delta < 0) {
$buyable->decrementReservedStock(abs($delta));
}
return $item->fresh();
});
}
public function removeItem(int $cartItemId): void
{
DB::transaction(function () use ($cartItemId): void {
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$buyable = $this->resolveScopedBuyable($item->buyable_type, $item->buyable_id, true);
$buyable->decrementReservedStock($item->cantidad);
$item->delete();
});
}
protected function resolveScopedBuyable(string $buyableType, int $buyableId, bool $lockForUpdate = false)
{
if ($buyableType === \App\Domains\Catalog\Models\ProductVariant::class) {
$query = \App\Domains\Catalog\Models\ProductVariant::query()
->whereKey($buyableId)
->whereHas('product', fn ($query) => $query->where('tenant_codigo', $this->tenant_codigo));
} elseif ($buyableType === \App\Domains\Bundle\Models\Bundle::class) {
$query = \App\Domains\Bundle\Models\Bundle::query()
->whereKey($buyableId)
->where('tenant_codigo', $this->tenant_codigo);
} else {
throw new \InvalidArgumentException('Invalid buyable type');
}
if ($lockForUpdate) {
$query->lockForUpdate();
}
$buyable = $query->first();
if ($buyable === null) {
throw new NotFoundHttpException('Buyable not found for tenant.');
}
return $buyable;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Domains\Cart\Models;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'cart_id',
'buyable_id',
'buyable_type',
'cantidad',
])]
class CartItem extends Model
{
use HasFactory;
protected $table = 'carrito_items';
protected function casts(): array
{
return [
'cart_id' => 'integer',
'buyable_id' => 'integer',
'buyable_type' => 'string',
'cantidad' => 'integer',
];
}
/**
* @return BelongsTo<Cart, $this>
*/
public function cart(): BelongsTo
{
return $this->belongsTo(Cart::class, 'cart_id');
}
/**
* @return \Illuminate\Database\Eloquent\Relations\MorphTo
*/
public function buyable()
{
return $this->morphTo();
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
class AddCartItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'buyable_type' => ['required', 'string', \Illuminate\Validation\Rule::in(['variant', 'bundle'])],
'buyable_id' => ['required', 'integer'],
'cantidad' => ['required', 'integer', 'min:1'],
];
}
public function mappedBuyableType(): string
{
return match ($this->input('buyable_type')) {
'variant' => \App\Domains\Catalog\Models\ProductVariant::class,
'bundle' => \App\Domains\Bundle\Models\Bundle::class,
default => throw new \InvalidArgumentException('Invalid buyable type'),
};
}
}

View File

@@ -1,6 +1,6 @@
<?php
namespace App\Domains\Commerce\Cart\Requests;
namespace App\Domains\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
@@ -18,8 +18,6 @@ class UpdateCartItemQuantityRequest extends FormRequest
{
return [
'cantidad' => ['required', 'integer', 'min:1'],
'catalog_item_id' => ['prohibited'],
'variant_id' => ['sometimes', 'nullable', 'integer'],
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Domains\Cart\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Cart\Models\CartItem
*/
class CartItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
/** @var \App\Domains\Shared\Contracts\Buyable|null $buyable */
$buyable = $this->buyable;
$productName = $buyable?->getName();
$precio = $buyable?->getPrice();
$imageUrl = null;
if ($this->buyable_type === \App\Domains\Catalog\Models\ProductVariant::class && $buyable && $buyable->relationLoaded('attachments')) {
$firstAttachment = $buyable->attachments->first();
if ($firstAttachment) {
$imageUrl = $firstAttachment->getTemporaryUrl(1440);
}
}
return [
'id' => $this->id,
'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($precio),
'buyable_type' => $this->mapBuyableTypeToAlias($this->buyable_type),
'buyable_id' => $this->buyable_id,
'product' => $buyable === null ? null : [
'nombre' => $productName,
'imagen' => $imageUrl,
],
];
}
protected function mapBuyableTypeToAlias(?string $type): string
{
return match ($type) {
\App\Domains\Catalog\Models\ProductVariant::class => 'variant',
\App\Domains\Bundle\Models\Bundle::class => 'bundle',
default => 'unknown',
};
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -1,13 +1,12 @@
<?php
namespace App\Domains\Commerce\Cart\Resources;
namespace App\Domains\Cart\Resources;
use App\Domains\Commerce\Cart\Models\Cart;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Cart
* @mixin \App\Domains\Cart\Models\Cart
*/
class CartResource extends JsonResource
{
@@ -21,8 +20,7 @@ class CartResource extends JsonResource
: collect();
$subtotal = $items->reduce(
fn (float $carry, $item): float => $carry
+ ((float) ($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
fn (float $carry, $item): float => $carry + ((float) ($item->variant?->product?->precio ?? 0) * $item->cantidad),
0.0,
);

View File

@@ -0,0 +1,205 @@
<?php
namespace App\Domains\Cart\Services;
use App\Domains\Cart\Models\Cart;
use App\Domains\Auth\Models\User;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CartService
{
public function show(Tenant $tenant, Request $request): Cart
{
$resolvedIdentity = $this->resolveIdentity($request);
if ($resolvedIdentity === null) {
return $this->makeEmptyCart($tenant);
}
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
}
return $this->loadCart($cart);
}
/**
* @return array{cart: Cart, guest_token: ?string}
*/
public function addItem(Tenant $tenant, Request $request, string $buyableType, int $buyableId, int $quantity): array
{
$resolvedIdentity = $this->resolveIdentity($request, true);
$identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($buyableType, $buyableId, $quantity);
return [
'cart' => $this->loadCart($cart),
'guest_token' => $resolvedIdentity['generated_guest_token'],
];
}
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
{
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity);
return $this->loadCart($cart);
}
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($cartItemId);
return $this->loadCart($cart);
}
public function makeGuestTokenCookie(string $guestToken): Cookie
{
return cookie(
'guest_token',
$guestToken,
60 * 24 * 180,
'/',
null,
false,
true,
false,
'lax',
);
}
protected function makeEmptyCart(Tenant $tenant): Cart
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => 'active',
]);
$cart->setRelation('items', collect());
return $cart;
}
protected function loadCart(Cart $cart): Cart
{
return $cart->fresh()->load([
'items.variant.product',
'items.variant.definitions.productAttribute.attribute',
'items.variant.attachments',
]);
}
/**
* @return array{identity: array{user_id: ?int, guest_token: ?string}, generated_guest_token: ?string}|null
*/
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
{
$user = $request->user() ?? \Illuminate\Support\Facades\Auth::guard('sanctum')->user();
if ($user instanceof User) {
return [
'identity' => [
'user_id' => $user->getKey(),
'guest_token' => null,
],
'generated_guest_token' => null,
];
}
$guestToken = $request->cookie('guest_token');
if (is_string($guestToken) && $guestToken !== '') {
return [
'identity' => [
'user_id' => null,
'guest_token' => $guestToken,
],
'generated_guest_token' => null,
];
}
if (! $generateGuestToken) {
return null;
}
$generatedGuestToken = (string) Str::uuid();
return [
'identity' => [
'user_id' => null,
'guest_token' => $generatedGuestToken,
],
'generated_guest_token' => $generatedGuestToken,
];
}
/**
* @return array{user_id: ?int, guest_token: ?string}
*/
protected function requireIdentity(Request $request): array
{
$resolvedIdentity = $this->resolveIdentity($request);
if ($resolvedIdentity === null) {
throw new NotFoundHttpException('Cart not found.');
}
return $resolvedIdentity['identity'];
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findCart(Tenant $tenant, array $identity): ?Cart
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
fn ($query) => $query->where('guest_token', $identity['guest_token']),
)
->first();
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->findCart($tenant, $identity);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
}
return $cart;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
$attributes = ['tenant_codigo' => $tenant->codigo];
if ($identity['user_id'] !== null) {
$attributes['user_id'] = $identity['user_id'];
} else {
$attributes['guest_token'] = $identity['guest_token'];
}
return Cart::query()->firstOrCreate($attributes, ['status' => 'active']);
}
}

View File

@@ -1,9 +1,10 @@
<?php
use App\Domains\Commerce\Cart\Controllers\CartController;
use App\Domains\Cart\Controllers\CartController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')
->middleware(\App\Domains\Cart\Middleware\MergeGuestCartMiddleware::class)
->group(function (): void {
Route::get('cart', [CartController::class, 'show']);
Route::post('cart/items', [CartController::class, 'addItem']);

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Requests\StoreAttributeRequest;
use App\Domains\Catalog\Requests\UpdateAttributeRequest;
use App\Domains\Catalog\Resources\AttributeResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\DB;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class AttributeController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
$query = Attribute::query()
->where('tenant_codigo', $tenant->codigo)
->with('options')
->latest();
return AttributeResource::collection($query->paginateFromRequest())->response();
}
public function store(StoreAttributeRequest $request, Tenant $tenant): JsonResponse
{
$attribute = ProductService::createAttribute($tenant, $request->validated());
return AttributeResource::make($attribute)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Attribute $attribute): AttributeResource
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
return AttributeResource::make($attribute->load('options'));
}
public function update(UpdateAttributeRequest $request, Tenant $tenant, Attribute $attribute): AttributeResource
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
$attribute = ProductService::updateAttribute($attribute, $request->validated());
return AttributeResource::make($attribute);
}
public function destroy(Tenant $tenant, Attribute $attribute): Response
{
$attribute = $this->resolveScopedAttribute($tenant, $attribute);
ProductService::deleteAttribute($attribute);
return response()->noContent();
}
protected function resolveScopedAttribute(Tenant $tenant, Attribute $attribute): Attribute
{
if ($attribute->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Attribute not found for tenant.');
}
return $attribute;
}
}

View File

@@ -0,0 +1,65 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Requests\StoreBrandRequest;
use App\Domains\Catalog\Requests\UpdateBrandRequest;
use App\Domains\Catalog\Resources\BrandResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class BrandController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return BrandResource::collection(
Brand::query()->where('tenant_codigo', $tenant->codigo)->orderByDesc('id')->paginateFromRequest()
)->response();
}
public function store(StoreBrandRequest $request, Tenant $tenant): JsonResponse
{
$brand = Brand::query()->create([
...$request->validated(),
'tenant_codigo' => $tenant->codigo,
]);
return BrandResource::make($brand)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Brand $marca): BrandResource
{
$marca = $this->resolveScopedBrand($tenant, $marca);
return BrandResource::make($marca);
}
public function update(UpdateBrandRequest $request, Tenant $tenant, Brand $marca): BrandResource
{
$marca = $this->resolveScopedBrand($tenant, $marca);
$marca->update($request->validated());
return BrandResource::make($marca);
}
public function destroy(Tenant $tenant, Brand $marca): Response
{
$marca = $this->resolveScopedBrand($tenant, $marca);
$marca->delete();
return response()->noContent();
}
protected function resolveScopedBrand(Tenant $tenant, Brand $brand): Brand
{
if ($brand->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Brand not found for tenant.');
}
return $brand;
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Catalog\Models\FeaturedGroup;
use Illuminate\Http\JsonResponse;
use Illuminate\Routing\Controller;
class CatalogController extends Controller
{
public function index(string $tenant): JsonResponse
{
$featuredGroups = FeaturedGroup::where('tenant_codigo', $tenant)
->with(['featuredVariants' => function ($query) {
$query->orderBy('order');
}, 'featuredVariants.variant.product', 'featuredVariants.variant.attachments', 'featuredVariants.variant.product.attachments'])
->orderBy('group_order')
->get();
return response()->json(CatalogFeaturedGroupResource::collection($featuredGroups)->resolve());
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Requests\StoreCategoryRequest;
use App\Domains\Catalog\Requests\UpdateCategoryRequest;
use App\Domains\Catalog\Resources\CategoryResource;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CategoryController extends Controller
{
public function index(Tenant $tenant): JsonResponse
{
return CategoryResource::collection(
Category::query()
->where(function ($query) use ($tenant): void {
$query
->where('tenant_code', $tenant->codigo)
->orWhereNull('tenant_code');
})
->with(['parent', 'subCategories', 'tenant'])
->orderByDesc('id')
->get()
)->response();
}
public function store(StoreCategoryRequest $request, Tenant $tenant): JsonResponse
{
$category = Category::query()->create([
...$request->validated(),
'tenant_code' => $tenant->codigo,
]);
return CategoryResource::make($category->load(['parent', 'subCategories', 'tenant']))
->response()
->setStatusCode(201);
}
public function show(Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function update(UpdateCategoryRequest $request, Tenant $tenant, Category $categoria): CategoryResource
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
$this->ensureCategoryIsMutable($categoria);
$categoria->update($request->validated());
return CategoryResource::make($categoria->load(['parent', 'subCategories', 'tenant']));
}
public function destroy(Tenant $tenant, Category $categoria): Response
{
$categoria = $this->resolveScopedCategory($tenant, $categoria);
$this->ensureCategoryIsMutable($categoria);
$categoria->delete();
return response()->noContent();
}
protected function resolveScopedCategory(Tenant $tenant, Category $category): Category
{
if ($category->tenant_code !== null && $category->tenant_code !== $tenant->codigo) {
throw new NotFoundHttpException('Category not found for tenant.');
}
return $category;
}
protected function ensureCategoryIsMutable(Category $category): void
{
if ($category->isGlobal()) {
throw new AccessDeniedHttpException('Global categories are read-only.');
}
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Requests\StoreFeaturedGroupRequest;
use App\Domains\Catalog\Requests\UpdateFeaturedGroupRequest;
use App\Domains\Catalog\Resources\FeaturedGroupResource;
use App\Domains\Catalog\Services\FeaturedGroupService;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
class FeaturedGroupController extends Controller
{
public function __construct(
private readonly FeaturedGroupService $featuredGroupService
) {}
public function index(string $tenant): AnonymousResourceCollection
{
$groups = FeaturedGroup::where('tenant_codigo', $tenant)->get();
return FeaturedGroupResource::collection($groups);
}
public function store(StoreFeaturedGroupRequest $request, string $tenant): FeaturedGroupResource
{
$group = $this->featuredGroupService->createGroup($tenant, $request->validated());
return new FeaturedGroupResource($group);
}
public function show(string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return new FeaturedGroupResource($featuredGroup->load('featuredVariants'));
}
public function update(UpdateFeaturedGroupRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedGroupResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$group = $this->featuredGroupService->updateGroup($featuredGroup, $request->validated());
return new FeaturedGroupResource($group);
}
public function destroy(string $tenant, FeaturedGroup $featuredGroup): JsonResponse
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$this->featuredGroupService->deleteGroup($featuredGroup);
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\FeaturedGroup;
use App\Domains\Catalog\Models\FeaturedVariant;
use App\Domains\Catalog\Requests\StoreFeaturedVariantRequest;
use App\Domains\Catalog\Requests\UpdateFeaturedVariantRequest;
use App\Domains\Catalog\Resources\FeaturedVariantResource;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Routing\Controller;
use Illuminate\Http\JsonResponse;
class FeaturedVariantController extends Controller
{
public function index(string $tenant, FeaturedGroup $featuredGroup): AnonymousResourceCollection
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
return FeaturedVariantResource::collection($featuredGroup->featuredVariants);
}
public function store(StoreFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup): FeaturedVariantResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant, 404);
$variant = $featuredGroup->featuredVariants()->create($request->validated());
return new FeaturedVariantResource($variant);
}
public function update(UpdateFeaturedVariantRequest $request, string $tenant, FeaturedGroup $featuredGroup, FeaturedVariant $featuredVariant): FeaturedVariantResource
{
abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404);
$featuredVariant->update($request->validated());
return new FeaturedVariantResource($featuredVariant);
}
public function destroy(string $tenant, FeaturedGroup $featuredGroup, FeaturedVariant $featuredVariant): JsonResponse
{
abort_if($featuredGroup->tenant_codigo !== $tenant || $featuredVariant->featured_group_id !== $featuredGroup->id, 404);
$featuredVariant->delete();
return response()->json(null, 204);
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Requests\ProductDetailRequest;
use App\Domains\Catalog\Requests\StoreProductRequest;
use App\Domains\Catalog\Requests\UpdateProductRequest;
use App\Domains\Catalog\Resources\ProductResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductController extends Controller
{
public function index(Tenant $tenant, ProductService $productService): JsonResponse
{
return ProductResource::collection(
$productService->getProductos($tenant)
)->response();
}
public function store(StoreProductRequest $request, Tenant $tenant, ProductService $productService): JsonResponse
{
$product = $productService->create($tenant, $request->validated());
return ProductResource::make($product)->response()->setStatusCode(201);
}
public function show(ProductDetailRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$variantId = $request->query('variant_id');
$variantId = $variantId !== null ? (int) $variantId : null;
$producto = $productService->getProductDetail($tenant, $producto, $variantId);
return ProductResource::make($producto);
}
public function update(UpdateProductRequest $request, Tenant $tenant, Product $producto, ProductService $productService): ProductResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$producto = $productService->update($producto, $request->validated());
return ProductResource::make($producto);
}
public function destroy(Tenant $tenant, Product $producto, ProductService $productService): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productService->delete($producto);
return response()->noContent();
}
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
{
if ($product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product not found.');
}
return $product;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace App\Domains\Catalog\Controllers;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Catalog\Requests\StoreProductVariantRequest;
use App\Domains\Catalog\Requests\UpdateProductVariantRequest;
use App\Domains\Catalog\Resources\ProductVariantResource;
use App\Domains\Catalog\Services\ProductService;
use App\Domains\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Response;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductVariantController extends Controller
{
public function index(Tenant $tenant, Product $producto): JsonResponse
{
$query = ProductVariant::query()
->where('producto_id', $producto->id)
->with(['product', 'definitions.productAttribute.attribute.options', 'attachments'])
->latest();
return ProductVariantResource::collection($query->paginateFromRequest())->response();
}
public function store(StoreProductVariantRequest $request, Tenant $tenant, Product $producto, ProductService $productService): JsonResponse
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$variant = $productService->createVariant($producto, $request->validated());
return ProductVariantResource::make($variant)->response()->setStatusCode(201);
}
public function show(Tenant $tenant, Product $producto, ProductVariant $productVariant): ProductVariantResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
return ProductVariantResource::make($productVariant->load([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
'definitions.productAttribute.attribute.options',
'product.attachments' => fn ($query) => $query->orderBy('attachments.id'),
]));
}
public function update(UpdateProductVariantRequest $request, Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): ProductVariantResource
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
$productVariant = $productService->updateVariant($productVariant, $request->validated());
return ProductVariantResource::make($productVariant);
}
public function destroy(Tenant $tenant, Product $producto, ProductVariant $productVariant, ProductService $productService): Response
{
$producto = $this->resolveScopedProduct($tenant, $producto);
$productVariant = $this->resolveScopedVariant($producto, $productVariant);
$productService->deleteVariant($productVariant);
return response()->noContent();
}
protected function resolveScopedProduct(Tenant $tenant, Product $product): Product
{
if ($product->tenant_codigo !== $tenant->codigo) {
throw new NotFoundHttpException('Product not found for tenant.');
}
return $product;
}
protected function resolveScopedVariant(Product $product, ProductVariant $variant): ProductVariant
{
if ($variant->producto_id !== $product->id) {
throw new NotFoundHttpException('Product variant not found for product.');
}
return $variant;
}
}

View File

@@ -1,15 +1,15 @@
<?php
namespace App\Domains\Commerce\Catalog\Models;
namespace App\Domains\Catalog\Models;
use App\Domains\Ticketing\Event\Models\EventDate;
use App\Shared\Enums\FieldType;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant;
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\HasMany;
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
#[Fillable([
'tenant_codigo',
@@ -54,14 +54,25 @@ class Attribute extends Model
}
/**
* @return HasMany<EventDate, $this>
* @return HasMany<ProductAttribute, $this>
*/
public function eventDates(): HasMany
public function productAttributes(): HasMany
{
return $this->hasMany(EventDate::class, 'tenant_code', 'tenant_codigo')
->whereNull('rescheduled_to_event_date_id')
->whereNull('suspended_at')
->orderBy('date')
->orderBy('time_start');
return $this->hasMany(ProductAttribute::class, 'attribute_id');
}
/**
* @return HasManyThrough<ProductVariantDefinition, ProductAttribute, $this>
*/
public function variantDefinitions(): HasManyThrough
{
return $this->hasManyThrough(
ProductVariantDefinition::class,
ProductAttribute::class,
'attribute_id',
'products_attribute_id',
'id',
'id'
);
}
}

View File

@@ -1,8 +1,7 @@
<?php
namespace App\Domains\Commerce\Catalog\Models;
namespace App\Domains\Catalog\Models;
use App\Domains\Ticketing\Ticket\Models\ValidityTime;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -10,7 +9,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'attribute_id',
'validity_time_id',
'value',
'label',
'sort_order',
@@ -28,7 +26,6 @@ class AttributeOption extends Model
protected function casts(): array
{
return [
'validity_time_id' => 'integer',
'sort_order' => 'integer',
'metadata' => 'array',
];
@@ -41,10 +38,4 @@ class AttributeOption extends Model
{
return $this->belongsTo(Attribute::class, 'attribute_id');
}
/** @return BelongsTo<ValidityTime, $this> */
public function validityTime(): BelongsTo
{
return $this->belongsTo(ValidityTime::class);
}
}

View File

@@ -1,13 +1,12 @@
<?php
namespace App\Domains\Commerce\Catalog\Models;
namespace App\Domains\Catalog\Models;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\Tenant;
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\HasMany;
#[Fillable([
'tenant_codigo',
@@ -27,10 +26,4 @@ class Brand extends Model
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
return $this->hasMany(CatalogItem::class);
}
}

View File

@@ -1,21 +1,18 @@
<?php
namespace App\Domains\Commerce\Catalog\Models;
namespace App\Domains\Catalog\Models;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\Tenant;
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;
#[Fillable([
'tenant_code',
'categoria_id',
'nombre',
'is_enabled',
])]
class Category extends Model
{
@@ -23,18 +20,10 @@ class Category extends Model
protected $table = 'categorias';
protected $attributes = [
'is_enabled' => true,
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'categoria_id' => 'integer',
'is_enabled' => 'boolean',
];
}
@@ -66,21 +55,4 @@ class Category extends Model
{
return $this->hasMany(self::class, 'categoria_id');
}
/** @return HasMany<CatalogItem, $this> */
public function catalogItems(): HasMany
{
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();
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FeaturedGroup extends Model
{
protected $fillable = [
'tenant_codigo',
'group_name',
'product_layout',
'group_order',
];
public function featuredVariants(): HasMany
{
return $this->hasMany(FeaturedVariant::class);
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FeaturedVariant extends Model
{
protected $table = 'featured_variants';
protected $fillable = [
'featured_group_id',
'product_variant_id',
'order',
];
public function featuredGroup(): BelongsTo
{
return $this->belongsTo(FeaturedGroup::class);
}
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'product_variant_id');
}
}

View File

@@ -0,0 +1,304 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\Brand;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\ProductAttribute;
use App\Domains\Tenant\Models\Tenant;
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;
#[Fillable([
'tenant_codigo',
'categoria_id',
'brand_id',
'slug',
'nombre',
'descripcion',
'precio',
])]
class Product extends Model
{
use HasFactory;
protected $table = 'productos';
protected ?ProductVariant $selectedVariant = null;
protected function casts(): array
{
return [
'categoria_id' => 'integer',
'brand_id' => 'integer',
'precio' => 'decimal:2',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<Category, $this>
*/
public function category(): BelongsTo
{
return $this->belongsTo(Category::class, 'categoria_id');
}
/**
* @return BelongsTo<Brand, $this>
*/
public function brand(): BelongsTo
{
return $this->belongsTo(Brand::class, 'brand_id');
}
/**
* @return HasMany<ProductVariant, $this>
*/
public function variants(): HasMany
{
return $this->hasMany(ProductVariant::class, 'producto_id');
}
/**
* @return BelongsToMany<Attribute, $this>
*/
public function attributes(): BelongsToMany
{
return $this->belongsToMany(
Attribute::class,
'products_attributes',
'product_id',
'attribute_id'
)->withTimestamps();
}
/**
* @return HasMany<ProductAttribute, $this>
*/
public function productAttributes(): HasMany
{
return $this->hasMany(ProductAttribute::class, 'product_id');
}
/**
* @return BelongsToMany<Attachment, $this>
*/
public function attachments(): BelongsToMany
{
return $this->belongsToMany(
Attachment::class,
'productos_attachments',
'producto_id',
'attachment_id'
)->withTimestamps();
}
/**
* Create a variant for this product with its definitions.
*
* @param array<string, mixed> $data
*/
public function createVariant(array $data): ProductVariant
{
$definitions = $data['definitions'] ?? [];
$this->validateVariantDefinitions($definitions);
unset($data['definitions']);
/** @var ProductVariant $variant */
$variant = $this->variants()->create($data);
$variant->definitions()->createMany($definitions);
return $variant;
}
/**
* Create multiple variants for this product.
*
* @param array<int, array<string, mixed>> $variantsData
* @return \Illuminate\Database\Eloquent\Collection<int, ProductVariant>
*/
public function createVariants(array $variantsData): \Illuminate\Database\Eloquent\Collection
{
$variants = new \Illuminate\Database\Eloquent\Collection();
foreach ($variantsData as $variantData) {
$variants->push($this->createVariant($variantData));
}
return $variants;
}
/**
* Update a product variant.
*
* @param array<string, mixed> $data
*/
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
{
$definitions = $data['definitions'] ?? [];
$this->validateVariantDefinitions($definitions);
unset($data['definitions']);
unset($data['producto_id']);
$variant->update($data);
$variant->definitions()->delete();
$variant->definitions()->createMany($definitions);
return $variant;
}
/**
* Delete a product variant.
*/
public function deleteVariant(ProductVariant $variant): void
{
$variant->definitions()->delete();
$variant->delete();
}
/**
* Validate variant definitions options against Attribute configuration.
*
* @param array $definitions
* @throws \InvalidArgumentException
*/
protected function validateVariantDefinitions(array $definitions): void
{
foreach ($definitions as $definition) {
$productAttributeId = $definition['products_attribute_id'] ?? null;
if (! $productAttributeId) {
continue;
}
$productAttribute = $this->productAttributes()
->with('attribute.options')
->find($productAttributeId);
$attribute = $productAttribute?->attribute;
if (! $productAttribute || ! $attribute) {
throw new \InvalidArgumentException("Product attribute with ID {$productAttributeId} not found for product {$this->id}.");
}
if ($attribute->type === \App\Domains\Shared\Enums\FieldType::Select) {
$allowedValues = $attribute->options()->pluck('value')->toArray();
$val = $definition['value'] ?? null;
if ($val !== null && ! in_array($val, $allowedValues, true)) {
throw new \InvalidArgumentException("The value '{$val}' is not a valid option for the select attribute '{$attribute->nombre}'.");
}
} elseif ($attribute->type === \App\Domains\Shared\Enums\FieldType::Multiselect) {
$allowedValues = $attribute->options()->pluck('value')->toArray();
$val = $definition['value'] ?? null;
if ($val !== null) {
$values = [];
if (is_array($val)) {
$values = $val;
} else {
$decoded = json_decode($val, true);
if (is_array($decoded)) {
$values = $decoded;
} else {
$values = array_map('trim', explode(',', $val));
}
}
foreach ($values as $v) {
if (! in_array($v, $allowedValues, true)) {
throw new \InvalidArgumentException("The value '{$v}' is not a valid option for the multiselect attribute '{$attribute->nombre}'.");
}
}
}
}
}
}
/**
* Create an attribute.
*
* @param array<string, mixed> $data
*/
public static function createAttribute(Tenant $tenant, array $data): Attribute
{
$options = $data['options'] ?? [];
unset($data['options']);
$type = \App\Domains\Shared\Enums\FieldType::from((string) $data['type']);
if (! $type->supportsOptions() && ! empty($options)) {
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
}
if (! $type->supportsOptions()) {
$data['metadata_schema'] = null;
}
/** @var Attribute $attribute */
$attribute = Attribute::query()->create([
...$data,
'tenant_codigo' => $tenant->codigo,
]);
$attribute->options()->createMany($options);
return $attribute->load('options');
}
/**
* Update an attribute.
*
* @param array<string, mixed> $data
*/
public static function updateAttribute(Attribute $attribute, array $data): Attribute
{
$options = $data['options'] ?? [];
unset($data['options']);
$typeStr = $data['type'] ?? $attribute->type->value;
$type = \App\Domains\Shared\Enums\FieldType::from((string) $typeStr);
if (! $type->supportsOptions() && ! empty($options)) {
throw new \InvalidArgumentException('Options are only allowed for select and multiselect attributes.');
}
if (! $type->supportsOptions()) {
$data['metadata_schema'] = null;
}
$attribute->update($data);
$attribute->options()->delete();
$attribute->options()->createMany($options);
return $attribute->load('options');
}
/**
* Delete an attribute.
*/
public static function deleteAttribute(Attribute $attribute): void
{
$attribute->options()->delete();
$attribute->delete();
}
public function setSelectedVariant(ProductVariant $variant): void
{
$this->selectedVariant = $variant;
}
public function getSelectedVariant(): ?ProductVariant
{
return $this->selectedVariant;
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Catalog\Models;
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\HasMany;
#[Fillable([
'product_id',
'attribute_id',
])]
class ProductAttribute extends Model
{
use HasFactory;
protected $table = 'products_attributes';
/**
* @return BelongsTo<Product, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'product_id');
}
/**
* @return BelongsTo<Attribute, $this>
*/
public function attribute(): BelongsTo
{
return $this->belongsTo(Attribute::class, 'attribute_id');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function variantDefinitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'products_attribute_id');
}
}

View File

@@ -0,0 +1,190 @@
<?php
namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
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\Database\Eloquent\Casts\Attribute;
use App\Domains\Shared\Contracts\Buyable;
#[Fillable([
'producto_id',
'stock_real',
'stock_reservado',
'stock',
'is_placeholder',
'has_tickets',
'minimum_use_date',
'maximum_use_date',
])]
class ProductVariant extends Model implements Buyable
{
use HasFactory;
protected $table = 'productos_variantes';
protected $appends = ['stock_tecnico'];
protected static function booted(): void
{
static::saving(function (ProductVariant $variant) {
$variant->validateStock();
});
}
public function validateStock(): void
{
if ($this->stock_real < 0) {
throw new \InvalidArgumentException('El stock real no puede ser negativo.');
}
if ($this->stock_reservado < 0) {
throw new \InvalidArgumentException('El stock reservado no puede ser negativo.');
}
if ($this->stock_reservado > $this->stock_real) {
throw new \InvalidArgumentException('El stock reservado no puede ser mayor que el stock real.');
}
}
public function getPrice(): float
{
return (float) ($this->product->precio ?? 0.0);
}
public function getName(): string
{
$name = $this->product->nombre ?? 'Producto';
if ($this->relationLoaded('definitions') && $this->definitions->isNotEmpty()) {
$definitions = $this->definitions->map(function ($def) {
return $def->value ?? $def->productAttribute?->attribute?->nombre;
})->filter()->implode(', ');
if ($definitions !== '') {
$name .= " ({$definitions})";
}
}
return $name;
}
public function incrementRealStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
$this->stock_real += $amount;
$this->save();
}
public function decrementRealStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
$this->stock_real -= $amount;
$this->save();
}
public function incrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a incrementar debe ser positivo.');
}
$this->stock_reservado += $amount;
$this->save();
}
public function decrementReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a decrementar debe ser positivo.');
}
$this->stock_reservado -= $amount;
$this->save();
}
public function confirmReservedStock(int $amount): void
{
if ($amount < 0) {
throw new \InvalidArgumentException('El monto a confirmar debe ser positivo.');
}
if ($this->stock_real < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la reserva.');
}
if ($this->stock_reservado < $amount) {
throw new \InvalidArgumentException('No hay suficiente stock reservado para confirmar la reserva.');
}
$this->stock_real -= $amount;
$this->stock_reservado -= $amount;
$this->save();
}
protected function stockTecnico(): Attribute
{
return Attribute::get(fn () => $this->stock_real - $this->stock_reservado);
}
protected function stock(): Attribute
{
return Attribute::make(
get: fn () => $this->stock_real,
set: fn ($value) => [
'stock_real' => $value,
]
);
}
protected function casts(): array
{
return [
'producto_id' => 'integer',
'stock_real' => 'integer',
'stock_reservado' => 'integer',
'is_placeholder' => 'boolean',
'has_tickets' => 'boolean',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
];
}
/**
* @return BelongsTo<Product, $this>
*/
public function product(): BelongsTo
{
return $this->belongsTo(Product::class, 'producto_id');
}
/**
* @return HasMany<ProductVariantDefinition, $this>
*/
public function definitions(): HasMany
{
return $this->hasMany(ProductVariantDefinition::class, 'producto_variante_id');
}
/**
* @return BelongsToMany<Attachment, $this>
*/
public function attachments(): BelongsToMany
{
return $this->belongsToMany(
Attachment::class,
'variantes_attachments',
'variante_id',
'attachment_id'
)->withTimestamps();
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace App\Domains\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'producto_variante_id',
'products_attribute_id',
'value',
])]
class ProductVariantDefinition extends Model
{
use HasFactory;
protected $table = 'productos_variantes_values';
/**
* @return BelongsTo<ProductVariant, $this>
*/
public function variant(): BelongsTo
{
return $this->belongsTo(ProductVariant::class, 'producto_variante_id');
}
/**
* @return BelongsTo<ProductAttribute, $this>
*/
public function productAttribute(): BelongsTo
{
return $this->belongsTo(ProductAttribute::class, 'products_attribute_id');
}
}

View File

@@ -1,21 +1,23 @@
<?php
namespace App\Domains\Ticketing\Desfile\Requests;
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateEntryImageRequest extends FormRequest
class ProductDetailRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'is_enabled' => ['required', 'boolean'],
'variant_id' => ['sometimes', 'integer'],
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class StoreAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('attribute', 'codigo')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(FieldType::class)],
'options' => [
Rule::requiredIf(fn (): bool => in_array($this->input('type'), [
FieldType::Select->value,
FieldType::Multiselect->value,
], true)),
Rule::prohibitedIf(fn (): bool => ! in_array($this->input('type'), [
FieldType::Select->value,
FieldType::Multiselect->value,
], true)),
'array',
],
'options.*.value' => ['required', 'string', 'max:255'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'categoria_id' => ['nullable', 'integer', 'exists:categorias,id'],
'nombre' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group_name' => ['required', 'string', 'max:255'],
'product_layout' => ['required', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
'group_order' => ['nullable', 'integer'],
];
}
}

View File

@@ -1,21 +1,21 @@
<?php
namespace App\Domains\Commerce\Catalog\Requests;
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class CategoryPageRequest extends FormRequest
class StoreFeaturedVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'page' => ['sometimes', 'integer', 'min:1'],
'product_variant_id' => ['required', 'integer'],
'order' => ['nullable', 'integer'],
];
}
}

View File

@@ -0,0 +1,47 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'categoria_id' => ['required', 'integer'],
'brand_id' => [
'required',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => ['required', 'string', 'max:255', Rule::unique('productos', 'slug')],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'stock' => ['sometimes', 'integer', 'min:0'],
'attribute_ids' => ['sometimes', 'array'],
'attribute_ids.*' => [
'required',
'integer',
Rule::exists('attribute', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'stock' => ['sometimes', 'integer', 'min:0'],
'definitions' => ['sometimes', 'array'],
'definitions.*.products_attribute_id' => [
'required',
'integer',
'distinct',
Rule::exists('products_attributes', 'id')->where(
fn ($query) => $query->where('product_id', $this->route('producto')?->id)
),
],
'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
'has_tickets' => ['boolean'],
'minimum_use_date' => ['nullable', 'date'],
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Shared\Enums\FieldType;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpdateAttributeRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Attribute|null $attribute */
$attribute = $this->route('attribute');
return [
'codigo' => [
'required',
'string',
'max:255',
Rule::unique('attribute', 'codigo')
->ignore($attribute?->id)
->where(fn ($query) => $query->where('tenant_codigo', $attribute?->tenant_codigo)),
],
'nombre' => ['required', 'string', 'max:255'],
'is_required' => ['sometimes', 'boolean'],
'metadata_schema' => ['nullable', 'array'],
'type' => ['required', Rule::enum(FieldType::class)],
'options' => ['sometimes', 'array'],
'options.*.value' => ['required', 'string', 'max:255'],
'options.*.label' => ['required', 'string', 'max:255'],
'options.*.sort_order' => ['sometimes', 'integer'],
'options.*.metadata' => ['nullable', 'array'],
];
}
public function withValidator(Validator $validator): void
{
$validator->after(function (Validator $validator): void {
$type = $this->input('type');
$supportsOptions = in_array($type, [FieldType::Select->value, FieldType::Multiselect->value], true);
if (! $supportsOptions && $this->filled('metadata_schema')) {
$validator->errors()->add('metadata_schema', 'The metadata_schema field is only allowed for select and multiselect attributes.');
}
if (! $supportsOptions && $this->filled('options')) {
$validator->errors()->add('options', 'Options are only allowed for select and multiselect attributes.');
}
});
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateBrandRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
];
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Category;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateCategoryRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Category|null $category */
$category = $this->route('categoria');
return [
'categoria_id' => [
'nullable',
'integer',
'exists:categorias,id',
Rule::notIn([$category?->id]),
],
'nombre' => ['required', 'string', 'max:255'],
];
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateFeaturedGroupRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'group_name' => ['sometimes', 'string', 'max:255'],
'product_layout' => ['sometimes', 'string', 'in:row,column_with_image,column_with_cart,vertical_with_image,vertical_with_cart'],
'group_order' => ['nullable', 'integer'],
];
}
}

View File

@@ -1,21 +1,20 @@
<?php
namespace App\Domains\Ticketing\Event\Requests;
namespace App\Domains\Catalog\Requests;
use Illuminate\Foundation\Http\FormRequest;
class RescheduleEventDateRequest extends FormRequest
class UpdateFeaturedVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, mixed> */
public function rules(): array
{
return [
'date' => ['required', 'date_format:Y-m-d'],
'order' => ['sometimes', 'integer'],
];
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Models\Product;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProductRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
/** @var Product|null $product */
$product = $this->route('producto');
return [
'categoria_id' => ['required', 'integer'],
'brand_id' => [
'required',
'integer',
Rule::exists('brands', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'slug' => [
'required',
'string',
'max:255',
Rule::unique('productos', 'slug')->ignore($product?->id),
],
'nombre' => ['required', 'string', 'max:255'],
'descripcion' => ['nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'attribute_ids' => ['sometimes', 'array'],
'attribute_ids.*' => [
'required',
'integer',
Rule::exists('attribute', 'id')->where(
fn ($query) => $query->where('tenant_codigo', $this->route('tenant')?->codigo)
),
],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
];
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace App\Domains\Catalog\Requests;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateProductVariantRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'stock' => ['sometimes', 'integer', 'min:0'],
'definitions' => ['sometimes', 'array'],
'definitions.*.products_attribute_id' => [
'required',
'integer',
'distinct',
Rule::exists('products_attributes', 'id')->where(
fn ($query) => $query->where('product_id', $this->route('producto')?->id)
),
],
'definitions.*.value' => ['nullable', 'string'],
'images' => ['sometimes', 'nullable', 'array'],
'images.*' => ['required', new ImageOrBase64Rule()],
'has_tickets' => ['boolean'],
'minimum_use_date' => ['nullable', 'date'],
'maximum_use_date' => ['nullable', 'date', 'after_or_equal:minimum_use_date'],
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\AttributeOption
*/
class AttributeOptionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'value' => $this->value,
'label' => $this->label,
'sort_order' => $this->sort_order,
'metadata' => $this->metadata,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\Attribute
*/
class AttributeResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'codigo' => $this->codigo,
'nombre' => $this->nombre,
'is_required' => $this->is_required,
'metadata_schema' => $this->metadata_schema,
'type' => $this->type?->value ?? $this->type,
'options' => AttributeOptionResource::collection($this->whenLoaded('options')),
];
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\Brand
*/
class BrandResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
];
}
}

View File

@@ -0,0 +1,42 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
use App\Domains\Catalog\Models\FeaturedVariant;
class CatalogFeaturedGroupResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'title' => $this->group_name,
'layout' => $this->product_layout,
'group_order' => $this->group_order,
'items' => $this->whenLoaded('featuredVariants', function () use ($request) {
return $this->featuredVariants->map(function (FeaturedVariant $featuredVariant) use ($request) {
$variant = $featuredVariant->variant;
$variantResource = (new ProductVariantResource($variant))->toArray($request);
if ($this->product_layout === 'row' || $this->product_layout === 'column_with_cart' || $this->product_layout === 'vertical_with_cart') {
// Devuelve el Product Variant Resource completo como pidio el usuario
// "Que todo devuelva el product variant resource"
return $variantResource;
}
// Para column_with_image o vertical_with_image
if ($this->product_layout === 'column_with_image' || $this->product_layout === 'vertical_with_image') {
if (isset($variantResource['images']) && count($variantResource['images']) > 0) {
$variantResource['images'] = [$variantResource['images'][0]];
}
return $variantResource;
}
return $variantResource;
});
}),
];
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\Category
*/
class CategoryResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'is_global' => $this->resource->isGlobal(),
'nombre' => $this->nombre,
'parent' => $this->whenLoaded('parent', fn () => $this->serializeRelatedCategory($this->parent)),
'sub_categories' => $this->whenLoaded('subCategories', fn () =>
$this->subCategories->map(fn ($category) => $this->serializeRelatedCategory($category))->values()
),
];
}
/**
* @return array<string, mixed>|null
*/
protected function serializeRelatedCategory(?\App\Domains\Catalog\Models\Category $category): ?array
{
if ($category === null) {
return null;
}
return [
'id' => $category->id,
'is_global' => $category->isGlobal(),
'nombre' => $category->nombre,
];
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FeaturedGroupResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'tenant_codigo' => $this->tenant_codigo,
'group_name' => $this->group_name,
'product_layout' => $this->product_layout,
'group_order' => $this->group_order,
'featured_variants' => FeaturedVariantResource::collection($this->whenLoaded('featuredVariants')),
];
}
}

View File

@@ -0,0 +1,19 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class FeaturedVariantResource extends JsonResource
{
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'featured_group_id' => $this->featured_group_id,
'product_variant_id' => $this->product_variant_id,
'order' => $this->order,
];
}
}

View File

@@ -0,0 +1,54 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\Product;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Product
*/
class ProductResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'category_id' => $this->categoria_id,
'brand_id' => $this->brand_id,
'slug' => $this->slug,
'nombre' => $this->nombre,
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'category' => $this->whenLoaded('category', fn () => $this->category?->nombre),
'brand' => $this->whenLoaded('brand', fn () => $this->brand?->nombre),
'images' => $this->whenLoaded('attachments', fn () => $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values()
),
'attributes' => AttributeResource::collection($this->whenLoaded('attributes')),
'variants_map' => $this->whenLoaded('variants', fn () => $this->variants
->map(fn ($variant) => [
'variant_id' => $variant->id,
'cantidad_maxima' => $variant->stock_tecnico,
'attributes' => $variant->definitions
->mapWithKeys(fn ($definition) => [
$definition->productAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key) => $key !== null)
->toArray(),
])
->values()
),
'variant' => $this->when(
$this->getSelectedVariant() !== null,
fn () => ProductVariantResource::make($this->getSelectedVariant())
),
];
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\Catalog\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin \App\Domains\Catalog\Models\ProductVariantDefinition
*/
class ProductVariantDefinitionResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'producto_variante_id' => $this->producto_variante_id,
'products_attribute_id' => $this->products_attribute_id,
'attribute_id' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute_id),
'value' => $this->value,
'attribute' => $this->whenLoaded('productAttribute', fn () => $this->productAttribute?->attribute?->nombre),
'metadata' => $this->resolveMetadata(),
];
}
/**
* @return array<string, mixed>|null
*/
protected function resolveMetadata(): ?array
{
if (! $this->relationLoaded('productAttribute')) {
return null;
}
$attribute = $this->productAttribute?->attribute;
if (! $attribute?->relationLoaded('options')) {
return null;
}
$option = $attribute->options->firstWhere('value', $this->value);
if ($option === null || $option->metadata === null) {
return null;
}
return $option->metadata;
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Domains\Catalog\Resources;
use App\Domains\Catalog\Models\ProductVariant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin ProductVariant
*/
class ProductVariantResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
return [
'id' => $this->id,
'cantidad_maxima' => $this->stock_tecnico,
'has_tickets' => $this->has_tickets,
'minimum_use_date' => $this->minimum_use_date,
'maximum_use_date' => $this->maximum_use_date,
'product' => ProductResource::make($this->whenLoaded('product')),
'definitions' => $this->whenLoaded(
'definitions',
fn () => $this->definitions
->mapWithKeys(fn ($definition) => [
$definition->productAttribute?->attribute?->codigo => $definition->value,
])
->filter(fn ($value, $key) => $key !== null)
->toArray()
),
'images' => $this->whenLoaded('attachments', function () {
if ($this->attachments->isNotEmpty()) {
return $this->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values();
}
if ($this->relationLoaded('fallbackAttachments')) {
return $this->fallbackAttachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values();
}
if ($this->relationLoaded('product') && $this->product?->relationLoaded('attachments')) {
return $this->product->attachments
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
->values();
}
return collect();
}),
];
}
}

View File

@@ -0,0 +1,25 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Models\FeaturedGroup;
class FeaturedGroupService
{
public function createGroup(string $tenantCodigo, array $data): FeaturedGroup
{
$data['tenant_codigo'] = $tenantCodigo;
return FeaturedGroup::create($data);
}
public function updateGroup(FeaturedGroup $group, array $data): FeaturedGroup
{
$group->update($data);
return $group;
}
public function deleteGroup(FeaturedGroup $group): bool|null
{
return $group->delete();
}
}

View File

@@ -0,0 +1,386 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Attachable\Services\AttachmentService;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\Product;
use App\Domains\Catalog\Models\ProductVariant;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class ProductService
{
public function __construct(protected AttachmentService $attachmentService) {}
/**
* Create a product.
*
* @param array<string, mixed> $data
*/
public function create(Tenant $tenant, array $data): Product
{
return DB::transaction(function () use ($tenant, $data) {
$attributeIds = $data['attribute_ids'] ?? [];
$images = $data['images'] ?? [];
$stock = $data['stock'] ?? 0;
unset($data['attribute_ids'], $data['images'], $data['stock']);
/** @var Product $product */
$product = Product::query()->create([
...$data,
'tenant_codigo' => $tenant->codigo,
]);
$product->attributes()->sync($attributeIds);
if (! empty($images)) {
$this->syncProductImages($product, $images);
}
// Create default variant with stock
$this->createVariant($product, [
'stock' => $stock,
'is_placeholder' => true,
'definitions' => [],
]);
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
});
}
/**
* Update a product.
*
* @param array<string, mixed> $data
*/
public function update(Product $product, array $data): Product
{
return DB::transaction(function () use ($product, $data) {
$hasAttributeIds = array_key_exists('attribute_ids', $data);
$attributeIds = $data['attribute_ids'] ?? [];
$hasImages = array_key_exists('images', $data);
$images = $data['images'] ?? [];
unset($data['attribute_ids'], $data['images']);
// Ensure tenant_codigo cannot be updated/changed
unset($data['tenant_codigo']);
$product->update($data);
if ($hasAttributeIds) {
$product->attributes()->sync($attributeIds);
}
if ($hasImages) {
$this->syncProductImages($product, $images);
}
return $product->load(['attributes.options', 'attachments', 'brand', 'category']);
});
}
/**
* Delete a product.
*/
public function delete(Product $product): void
{
DB::transaction(function () use ($product) {
foreach ($product->variants as $variant) {
$this->deleteVariantAttachments($variant);
$product->deleteVariant($variant);
}
// Delete product-level attachments from S3 and database
$existing = $product->attachments()->get();
$product->attachments()->detach();
foreach ($existing as $attachment) {
$this->attachmentService->delete($attachment);
}
$product->attributes()->detach();
$product->delete();
});
}
/**
* Create a product variant.
*
* @param array<string, mixed> $data
*/
public function createVariant(Product $product, array $data): ProductVariant
{
return DB::transaction(function () use ($product, $data) {
$images = $data['images'] ?? [];
unset($data['images']);
// Determine if the variant being created is a placeholder one
$hasDefinitions = ! empty($data['definitions']);
$isPlaceholder = $data['is_placeholder'] ?? (! $hasDefinitions);
$data['is_placeholder'] = $isPlaceholder;
// Remove any existing placeholder variants
$defaultVariants = $product->variants()->where('is_placeholder', true)->get();
foreach ($defaultVariants as $defaultVariant) {
$this->deleteVariantAttachments($defaultVariant);
$product->deleteVariant($defaultVariant);
}
$variant = $product->createVariant($data);
if (! empty($images)) {
$this->syncVariantImages($variant, $images);
}
return $variant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']);
});
}
/**
* Update a product variant.
*
* @param array<string, mixed> $data
*/
public function updateVariant(ProductVariant $variant, array $data): ProductVariant
{
return DB::transaction(function () use ($variant, $data) {
$hasImages = array_key_exists('images', $data);
$images = $data['images'] ?? [];
unset($data['images']);
/** @var Product $product */
$product = $variant->product;
$updatedVariant = $product->updateVariant($variant, $data);
if ($hasImages) {
$this->syncVariantImages($updatedVariant, $images);
}
return $updatedVariant->load(['product', 'definitions.productAttribute.attribute.options', 'attachments']);
});
}
/**
* Delete a product variant.
*/
public function deleteVariant(ProductVariant $variant): void
{
DB::transaction(function () use ($variant) {
/** @var Product $product */
$product = $variant->product;
$this->deleteVariantAttachments($variant);
$product->deleteVariant($variant);
// Re-create a default variant with stock 0 if it has no variants left
if ($product->variants()->count() === 0) {
$product->createVariant([
'stock' => 0,
'is_placeholder' => true,
'definitions' => [],
]);
}
});
}
/**
* Create an attribute.
*
* @param array<string, mixed> $data
*/
public static function createAttribute(Tenant $tenant, array $data): Attribute
{
return DB::transaction(function () use ($tenant, $data) {
return Product::createAttribute($tenant, $data);
});
}
/**
* Update an attribute.
*
* @param array<string, mixed> $data
*/
public static function updateAttribute(Attribute $attribute, array $data): Attribute
{
return DB::transaction(function () use ($attribute, $data) {
return Product::updateAttribute($attribute, $data);
});
}
/**
* Upload a list of image files/base64 strings and sync them to a variant.
*
* When called on update, the existing attachments are detached first so the
* final set always matches exactly what was sent in the request.
*
* @param array<int, UploadedFile|string> $images
*/
protected function syncVariantImages(ProductVariant $variant, array $images): void
{
$this->deleteVariantAttachments($variant);
$attachmentIds = [];
foreach ($images as $image) {
$attachment = $this->attachmentService->store($image, 'variants');
$attachmentIds[] = $attachment->id;
}
$variant->attachments()->sync($attachmentIds);
}
/**
* Upload a list of image files/base64 strings and sync them to a product.
*
* Same logic as syncVariantImages but for products without variants.
*
* @param array<int, UploadedFile|string> $images
*/
protected function syncProductImages(Product $product, array $images): void
{
// Detach pivot record and delete attachment from S3 and database
$existing = $product->attachments()->get();
$product->attachments()->detach();
foreach ($existing as $attachment) {
$this->attachmentService->delete($attachment);
}
$attachmentIds = [];
foreach ($images as $image) {
$attachment = $this->attachmentService->store($image, 'products');
$attachmentIds[] = $attachment->id;
}
$product->attachments()->sync($attachmentIds);
}
protected function deleteVariantAttachments(ProductVariant $variant): void
{
$existing = $variant->attachments()->get();
$variant->attachments()->detach();
foreach ($existing as $attachment) {
$this->attachmentService->delete($attachment);
}
}
/**
* Delete an attribute.
*/
public static function deleteAttribute(Attribute $attribute): void
{
DB::transaction(function () use ($attribute) {
Product::deleteAttribute($attribute);
});
}
/**
* Get products for a tenant with resolved first image (with fallback to first variant's first image).
*/
public function getProductos(Tenant $tenant): LengthAwarePaginator
{
$products = Product::query()
->where('tenant_codigo', $tenant->codigo)
->with([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
'brand',
'category',
'variants.attachments' => fn ($query) => $query->orderBy('attachments.id'),
])
->latest()
->paginateFromRequest();
foreach ($products as $product) {
$resolvedAttachment = null;
if ($product->attachments->isNotEmpty()) {
$resolvedAttachment = $product->attachments->first();
} else {
$firstVariant = $product->variants->sortBy('id')->first();
if ($firstVariant && $firstVariant->attachments->isNotEmpty()) {
$resolvedAttachment = $firstVariant->attachments->first();
}
}
$product->setRelation('attachments', $resolvedAttachment ? collect([$resolvedAttachment]) : collect());
$product->unsetRelation('variants');
}
return $products;
}
public function getProductDetail(Tenant $tenant, Product $product, ?int $variantId = null): Product
{
$product->load([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
'attributes.options',
'brand',
'category',
'variants' => fn ($query) => $query->orderBy('id'),
'variants.definitions.productAttribute.attribute.options',
]);
$this->filterProductDetailAttributeOptions($product);
$selectedVariant = $variantId !== null
? $product->variants->firstWhere('id', $variantId)
: $product->variants->first(fn (ProductVariant $variant) => $variant->stock_tecnico > 0);
if ($variantId !== null && $selectedVariant === null) {
throw new NotFoundHttpException('Product variant not found for product.');
}
if ($variantId !== null && $selectedVariant->stock_tecnico <= 0) {
throw ValidationException::withMessages([
'variant_id' => 'La variante seleccionada no tiene stock.',
]);
}
$selectedVariant ??= $product->variants->first();
if ($selectedVariant !== null) {
$selectedVariant->load([
'attachments' => fn ($query) => $query->orderBy('attachments.id'),
]);
$selectedVariant->setRelation('fallbackAttachments', $product->attachments);
$product->setSelectedVariant($selectedVariant);
}
return $product;
}
protected function filterProductDetailAttributeOptions(Product $product): void
{
$availableValuesByAttributeId = [];
foreach ($product->variants as $variant) {
foreach ($variant->definitions as $definition) {
$attributeId = $definition->productAttribute?->attribute_id;
if ($attributeId === null || $definition->value === null) {
continue;
}
$availableValuesByAttributeId[$attributeId][$definition->value] = true;
}
}
foreach ($product->attributes as $attribute) {
if (! $attribute->relationLoaded('options')) {
continue;
}
$availableValues = $availableValuesByAttributeId[$attribute->id] ?? [];
$attribute->setRelation(
'options',
$attribute->options
->filter(fn ($option): bool => array_key_exists($option->value, $availableValues))
->values()
);
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
use App\Domains\Catalog\Controllers\BrandController;
use App\Domains\Catalog\Controllers\CatalogController;
use App\Domains\Catalog\Controllers\CategoryController;
use App\Domains\Catalog\Controllers\ProductController;
use App\Domains\Catalog\Controllers\AttributeController;
use App\Domains\Catalog\Controllers\ProductVariantController;
use App\Domains\Catalog\Controllers\FeaturedGroupController;
use App\Domains\Catalog\Controllers\FeaturedVariantController;
use Illuminate\Support\Facades\Route;
Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
Route::get('catalog', [CatalogController::class, 'index']);
Route::apiResource('marcas', BrandController::class)->parameters(['marcas' => 'marca']);
Route::apiResource('categorias', CategoryController::class)->parameters(['categorias' => 'categoria']);
Route::apiResource('productos', ProductController::class);
Route::apiResource('attributes', AttributeController::class);
Route::apiResource('productos.variants', ProductVariantController::class)
->parameters([
'productos' => 'producto',
'variants' => 'productVariant',
]);
Route::apiResource('featured-groups', FeaturedGroupController::class);
Route::apiResource('featured-groups.variants', FeaturedVariantController::class)
->only(['index', 'store', 'update', 'destroy'])
->parameters([
'featured-groups' => 'featuredGroup',
'variants' => 'featuredVariant',
]);
});

View File

@@ -1,89 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Controllers;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Cart\Requests\AddCartItemRequest;
use App\Domains\Commerce\Cart\Requests\UpdateCartItemQuantityRequest;
use App\Domains\Commerce\Cart\Resources\CartResource;
use App\Domains\Commerce\Cart\Services\CartService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CartController extends Controller
{
public function __construct(
protected CartService $cartService,
) {}
public function show(Request $request, Tenant $tenant): CartResource
{
return CartResource::make($this->cartService->show($tenant, $request));
}
public function addItem(AddCartItemRequest $request, Tenant $tenant): JsonResponse
{
$result = $this->cartService->addItem(
$tenant,
$request,
(int) $request->validated('catalog_item_id'),
$request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null,
(int) $request->validated('cantidad'),
);
$response = CartResource::make($result['cart'])
->additional([
'code' => 'cart.item_added',
'message' => __('api.cart.item_added'),
])
->response();
if ($result['guest_token'] !== null) {
$response->withCookie($this->cartService->makeGuestTokenCookie($result['guest_token']));
}
return $response;
}
public function updateItemQuantity(
UpdateCartItemQuantityRequest $request,
Tenant $tenant,
CartItem $cartItem,
): CartResource {
$updatesVariant = $request->exists('variant_id');
return CartResource::make(
$this->cartService->updateItem(
$tenant,
$request,
$cartItem->getKey(),
(int) $request->validated('cantidad'),
$updatesVariant
? ($request->validated('variant_id') !== null
? (int) $request->validated('variant_id')
: null)
: $cartItem->variant_id,
$updatesVariant,
)
)->additional([
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
'message' => $updatesVariant
? __('api.cart.item_updated')
: __('api.cart.quantity_updated'),
]);
}
public function removeItem(Request $request, Tenant $tenant, CartItem $cartItem): CartResource
{
return CartResource::make(
$this->cartService->removeItem($tenant, $request, $cartItem->getKey())
)->additional([
'code' => 'cart.item_removed',
'message' => __('api.cart.item_removed'),
]);
}
}

View File

@@ -1,494 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Models;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\UserPurchaseLimitService;
use App\Domains\Core\Tenant\Models\Tenant;
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\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
#[Fillable([
'tenant_codigo',
'user_id',
'guest_token',
'status',
'origin',
'current_purchase_id',
'current_stock_reservation_id',
])]
class Cart extends Model
{
use HasFactory;
use SoftDeletes;
protected $table = 'carritos';
public const STATUS_ACTIVE = 'active';
public const STATUS_CHECKOUT = 'checkout';
public const STATUS_CONVERTED = 'converted';
public const STATUS_EXPIRED = 'expired';
public const STATUS_ABANDONED = 'abandoned';
public const ORIGIN_USER = 'user';
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
protected function casts(): array
{
return [
'user_id' => 'integer',
'current_purchase_id' => 'integer',
'current_stock_reservation_id' => 'integer',
];
}
/**
* @return BelongsTo<Tenant, $this>
*/
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return HasMany<CartItem, $this>
*/
public function items(): HasMany
{
return $this->hasMany(CartItem::class, 'cart_id');
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class, 'cart_id');
}
/** @return BelongsTo<Purchase, $this> */
public function currentPurchase(): BelongsTo
{
return $this->belongsTo(Purchase::class, 'current_purchase_id');
}
/** @return BelongsTo<StockReservation, $this> */
public function currentStockReservation(): BelongsTo
{
return $this->belongsTo(
StockReservation::class,
'current_stock_reservation_id',
);
}
public function getTotalAmount(): float
{
$items = $this->relationLoaded('items')
? $this->getRelation('items')
: $this->items()->with(['catalogItem', 'variant'])->get();
return (float) $items->reduce(
fn (float $carry, CartItem $item): float => $carry
+ (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
0.0,
);
}
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem
{
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'),
]);
}
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
$cartQuantity = (int) $this->items()
->where('catalog_item_id', $catalogItemId)
->sum('cantidad');
$inventoryService = app(CatalogInventoryService::class);
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
$this->assertUserPurchaseLimit(
$selectedItem,
$cartQuantity + $quantity,
heldQuantity: $cartQuantity,
maximumAddableCeiling: $availableQuantity,
);
if ($availableQuantity !== null && $availableQuantity < $quantity) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
]);
}
/** @var CartItem|null $item */
$item = $this->items()
->where('catalog_item_id', $catalogItemId)
->where('variant_id', $variantId)
->lockForUpdate()
->first();
if ($item === null) {
$item = $this->items()->create([
'catalog_item_id' => $catalogItemId,
'variant_id' => $variantId,
'cantidad' => $quantity,
]);
} else {
$item->cantidad += $quantity;
$item->save();
}
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
}
public function updateItem(
int $cartItemId,
int $quantity,
?int $variantId = null,
bool $updateVariant = false,
?int $excludedPurchaseId = null,
): CartItem {
if ($quantity <= 0) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.positive_quantity'),
]);
}
return DB::transaction(function () use (
$cartItemId,
$quantity,
$variantId,
$updateVariant,
$excludedPurchaseId,
): CartItem {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$currentSelection = $this->resolveScopedItem(
$item->catalog_item_id,
$item->variant_id,
true,
);
$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');
$nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection);
$this->assertUserPurchaseLimit(
$nextSelection,
$otherVariantsQuantity + $quantity,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$nextAvailableQuantity,
);
$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();
if ($targetItem !== null) {
$targetItem->cantidad += $quantity;
$targetItem->save();
$item->delete();
app(StockReservationService::class)->syncCart($this);
return $targetItem->fresh();
}
$item->variant_id = $variantId;
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
}
$delta = $quantity - $item->cantidad;
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
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,
$excludedPurchaseId,
$otherVariantsQuantity + $item->cantidad,
$availableQuantity,
);
}
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
$maxAvailable = $availableQuantity + $item->cantidad;
throw ValidationException::withMessages([
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
]);
}
$item->cantidad = $quantity;
$item->save();
app(StockReservationService::class)->syncCart($this);
return $item->fresh();
});
}
public function removeItem(int $cartItemId): void
{
DB::transaction(function () use ($cartItemId): void {
$this->invalidateCurrentCheckout();
app(StockReservationService::class)->assertCartReservationUsable($this);
/** @var CartItem $item */
$item = $this->items()
->where('id', $cartItemId)
->lockForUpdate()
->firstOrFail();
$item->delete();
app(StockReservationService::class)->syncCart($this);
});
}
private function invalidateCurrentCheckout(): void
{
$candidatePurchaseId = self::query()
->whereKey($this->getKey())
->value('current_purchase_id');
$currentPurchase = $candidatePurchaseId === null
? null
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
/** @var self $cart */
$cart = self::query()->lockForUpdate()->findOrFail($this->getKey());
if ($cart->current_purchase_id !== $candidatePurchaseId) {
if ($cart->current_purchase_id !== null) {
throw ValidationException::withMessages([
'cart' => __('api.purchase.checkout_in_progress'),
]);
}
$this->current_purchase_id = null;
return;
}
if ($currentPurchase === null) {
return;
}
if ($currentPurchase->status === Purchase::STATUS_PAID) {
throw ValidationException::withMessages([
'cart' => __('api.cart.editing_disabled'),
]);
}
if (in_array($currentPurchase->status, [
Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT,
], true)) {
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
$currentPurchase->update([
'status' => Purchase::STATUS_SUPERSEDED,
]);
$this->current_purchase_id = null;
return;
}
app(StockReservationService::class)->releaseForPurchase(
$currentPurchase,
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
);
self::query()
->whereKey($cart->getKey())
->where('current_purchase_id', $currentPurchase->getKey())
->update(['current_purchase_id' => null]);
$this->current_purchase_id = null;
}
protected function resolveScopedItem(
int $catalogItemId,
?int $variantId,
bool $lockForUpdate = false,
): CatalogItem|Variant {
$catalogItemQuery = CatalogItem::query()
->whereKey($catalogItemId)
->forTenantCatalog($this->tenant()->firstOrFail());
if ($lockForUpdate) {
$catalogItemQuery->lockForUpdate();
}
$catalogItem = $catalogItemQuery->first();
if ($catalogItem === null) {
throw new NotFoundHttpException('Catalog item not found for tenant.');
}
if ($catalogItem->isBundle()) {
if ($variantId !== null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.bundle_variant_forbidden'),
]);
}
if (! $catalogItem->bundleComponents()->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => __('api.cart.empty_bundle'),
]);
}
if ($catalogItem->bundleComponents()
->whereNotNull('component_variant_id')
->whereHas('variant', fn ($query) => $query
->whereNotNull('sales_disabled_at')
->orWhereNotNull('replaced_by_variant_id'))
->exists()) {
throw ValidationException::withMessages([
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
]);
}
return $catalogItem;
}
if ($variantId === null) {
if ($catalogItem->inventory_id === null) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_required'),
]);
}
$inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate);
$catalogItem->setRelation('inventory', $inventory);
return $catalogItem;
}
$variantQuery = Variant::query()
->whereKey($variantId)
->where('catalog_item_id', $catalogItem->id);
if ($lockForUpdate) {
$variantQuery->lockForUpdate();
}
$variant = $variantQuery->first();
if ($variant === null) {
throw new NotFoundHttpException('Variant not found for catalog item.');
}
if (! $variant->isSellable()) {
throw ValidationException::withMessages([
'variant_id' => [__('api.cart.variant_unavailable')],
]);
}
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('inventory', $inventory);
return $variant;
}
private function assertUserPurchaseLimit(
CatalogItem|Variant $selectedItem,
int $cartQuantity,
?int $excludedPurchaseId = null,
int $heldQuantity = 0,
?int $maximumAddableCeiling = null,
): void {
if ($this->user_id === null) {
return;
}
$catalogItem = $selectedItem instanceof Variant
? $selectedItem->catalogItem
: $selectedItem;
app(UserPurchaseLimitService::class)->assertCanPurchase(
$catalogItem,
$this->user_id,
$cartQuantity,
$excludedPurchaseId,
$this->getKey(),
$heldQuantity,
$maximumAddableCeiling,
field: 'cantidad',
);
}
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
{
$query = Inventory::query()->whereKey($inventoryId);
if ($lockForUpdate) {
$query->lockForUpdate();
}
return $query->firstOrFail();
}
}

View File

@@ -1,58 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Models;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'cart_id',
'catalog_item_id',
'variant_id',
'cantidad',
])]
class CartItem extends Model
{
use HasFactory;
protected $table = 'carrito_items';
protected function casts(): array
{
return [
'cart_id' => 'integer',
'catalog_item_id' => 'integer',
'variant_id' => 'integer',
'cantidad' => 'integer',
];
}
/**
* @return BelongsTo<Cart, $this>
*/
public function cart(): BelongsTo
{
return $this->belongsTo(Cart::class, 'cart_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class);
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class);
}
public function selectedItem(): CatalogItem|Variant|null
{
return $this->variant ?? $this->catalogItem;
}
}

View File

@@ -1,41 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AddCartItemRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
$tenantCode = $this->route('tenant')?->codigo;
return [
'catalog_item_id' => [
'required',
'integer',
Rule::exists('catalog_items', 'id')->where(
fn ($query) => $query->where('tenant_code', $tenantCode)
),
],
'variant_id' => [
'sometimes',
'nullable',
'integer',
Rule::exists('variantes', 'id')->where(
fn ($query) => $query->where('catalog_item_id', $this->input('catalog_item_id'))
),
],
'cantidad' => ['required', 'integer', 'min:1'],
];
}
}

View File

@@ -1,73 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Resources;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin CartItem
*/
class CartItemResource extends JsonResource
{
/**
* @return array<string, mixed>
*/
public function toArray(Request $request): array
{
$selectedItem = $this->selectedItem();
$imageUrl = null;
$tenant = $request->route('tenant');
$displayImage = ! $tenant instanceof Tenant || $tenant->display_cart_item_images;
$includeVariants = $tenant instanceof Tenant
&& $tenant->cart_editing_policy->allowsVariantChanges();
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
}
if ($displayImage && $imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
}
return [
'id' => $this->id,
'cantidad' => $this->cantidad,
'precio_unitario' => $this->formatMoney($selectedItem?->getPrice()),
'catalog_item_id' => $this->catalog_item_id,
'variant_id' => $this->variant_id,
'nombre' => $selectedItem?->getName(),
'imagen' => $imageUrl,
'variant' => $this->variant === null ? null : $this->variantData($this->variant),
'variants' => $this->when(
$includeVariants,
fn () => $this->catalogItem
->visibleVariants($this->variant_id)
->map(fn (Variant $variant): array => $this->variantData($variant))
->values(),
),
];
}
/** @return array<string, mixed> */
protected function variantData(Variant $variant): array
{
return [
'id' => $variant->id,
'precio' => $this->formatMoney($variant->precio ?? $this->catalogItem->precio),
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock(),
'values' => $variant->selectorOptions($this->catalogItem->itemAttributes),
];
}
protected function formatMoney(float|int|string|null $amount): string
{
return number_format((float) ($amount ?? 0), 2, '.', '');
}
}

View File

@@ -1,332 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Exceptions\StockReservationExpiredException;
use App\Domains\Commerce\Catalog\Services\ExpireStockReservationsService;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpFoundation\Cookie;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CartService
{
public function show(Tenant $tenant, Request $request): Cart
{
$resolvedIdentity = $this->resolveIdentity($request);
if ($resolvedIdentity === null) {
return $this->makeEmptyCart($tenant);
}
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
if ($cart === null) {
return $this->makeEmptyCart($tenant);
}
return $this->loadCart($cart, $tenant);
}
/**
* @return array{cart: Cart, guest_token: ?string}
*/
public function addItem(
Tenant $tenant,
Request $request,
int $catalogItemId,
?int $variantId,
int $quantity,
): array {
$resolvedIdentity = $this->resolveIdentity($request, true);
$identity = $resolvedIdentity['identity'];
$cart = $this->findOrCreateCart($tenant, $identity);
$cart->addItem($catalogItemId, $variantId, $quantity);
return [
'cart' => $this->loadCart($cart, $tenant),
'guest_token' => $resolvedIdentity['generated_guest_token'],
];
}
public function updateItem(
Tenant $tenant,
Request $request,
int $cartItemId,
int $quantity,
?int $variantId,
bool $updateVariant,
): Cart {
if ($updateVariant && ! $tenant->cart_editing_policy->allowsVariantChanges()) {
throw ValidationException::withMessages([
'variant_id' => __('api.cart.variant_change_disabled'),
]);
}
if (! $updateVariant && ! $tenant->cart_editing_policy->allowsQuantityChanges()) {
throw ValidationException::withMessages([
'cantidad' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
return $this->loadCart($cart, $tenant);
}
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
{
if (! $tenant->cart_editing_policy->allowsRemoval()) {
throw ValidationException::withMessages([
'cart_item' => __('api.cart.editing_disabled'),
]);
}
$identity = $this->requireIdentity($request);
$cart = $this->findCartOrFail($tenant, $identity);
$cart->removeItem($cartItemId);
return $this->loadCart($cart, $tenant);
}
public function makeGuestTokenCookie(string $guestToken): Cookie
{
$secure = (bool) config('session.secure');
$sameSite = config('session.same_site');
$partitioned = (bool) config('session.partitioned')
|| ($secure && strtolower((string) $sameSite) === 'none');
return Cookie::create(
name: 'guest_token',
value: $guestToken,
expire: now()->addDays(180),
path: '/',
domain: config('session.domain'),
secure: $secure,
httpOnly: true,
raw: false,
sameSite: $sameSite,
partitioned: $partitioned,
);
}
protected function makeEmptyCart(Tenant $tenant): Cart
{
$cart = new Cart([
'tenant_codigo' => $tenant->codigo,
'status' => Cart::STATUS_ACTIVE,
]);
$cart->setRelation('items', collect());
return $cart;
}
protected function loadCart(Cart $cart, Tenant $tenant): Cart
{
$relations = [
'items.catalogItem.attachments',
'items.catalogItem.inventory',
'items.catalogItem.itemAttributes.attribute',
'items.variant.attachments',
'items.variant.inventory',
'items.variant.definitions.itemAttribute.attribute.options',
'items.variant.eventDates',
'items.variant.eventDate',
];
if ($tenant->cart_editing_policy->allowsVariantChanges()) {
$relations = [
...$relations,
'items.catalogItem.variants' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.inventory',
'items.catalogItem.variants.definitions' => fn ($query) => $query->orderBy('id'),
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
'items.catalogItem.variants.eventDates',
'items.catalogItem.variants.eventDate',
];
}
return $cart->fresh()->load($relations);
}
/**
* @return array{identity: array{user_id: ?int, guest_token: ?string}, generated_guest_token: ?string}|null
*/
protected function resolveIdentity(Request $request, bool $generateGuestToken = false): ?array
{
$user = $request->user() ?? Auth::guard('sanctum')->user();
if ($user instanceof User) {
return [
'identity' => [
'user_id' => $user->getKey(),
'guest_token' => null,
],
'generated_guest_token' => null,
];
}
$guestToken = $request->cookie('guest_token');
if (is_string($guestToken) && $guestToken !== '') {
return [
'identity' => [
'user_id' => null,
'guest_token' => $guestToken,
],
'generated_guest_token' => null,
];
}
if (! $generateGuestToken) {
return null;
}
$generatedGuestToken = (string) Str::uuid();
return [
'identity' => [
'user_id' => null,
'guest_token' => $generatedGuestToken,
],
'generated_guest_token' => $generatedGuestToken,
];
}
/**
* @return array{user_id: ?int, guest_token: ?string}
*/
protected function requireIdentity(Request $request): array
{
$resolvedIdentity = $this->resolveIdentity($request);
if ($resolvedIdentity === null) {
throw new NotFoundHttpException('Cart not found.');
}
return $resolvedIdentity['identity'];
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findCart(Tenant $tenant, array $identity): ?Cart
{
return Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('origin', Cart::ORIGIN_USER)
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
->when(
$identity['user_id'] !== null,
fn ($query) => $query->where('user_id', $identity['user_id']),
fn ($query) => $query->where('guest_token', $identity['guest_token']),
)
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
->first();
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
{
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
if ($cart === null) {
throw new NotFoundHttpException('Cart not found.');
}
return $cart;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
{
return $this->resolveCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function resolveCart(
Tenant $tenant,
array $identity,
bool $replaceExpired = true,
): ?Cart {
$cart = $this->findCart($tenant, $identity);
if ($cart?->status === Cart::STATUS_ACTIVE
&& $cart->current_stock_reservation_id !== null
&& app(ExpireStockReservationsService::class)
->expireIfOverdue($cart->current_stock_reservation_id)) {
$cart = $this->findCart($tenant, $identity);
}
if ($cart?->status === Cart::STATUS_EXPIRED) {
if (! $replaceExpired) {
throw new StockReservationExpiredException;
}
return $this->replaceExpiredCart($cart, $tenant, $identity);
}
if ($cart !== null) {
return $cart;
}
return null;
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
{
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
/** @var Cart|null $lockedCart */
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
$lockedCart->update([
'status' => Cart::STATUS_ABANDONED,
'current_purchase_id' => null,
]);
}
return $this->findCart($tenant, $identity)
?? $this->createCart($tenant, $identity);
});
}
/**
* @param array{user_id: ?int, guest_token: ?string} $identity
*/
protected function createCart(Tenant $tenant, array $identity): Cart
{
$attributes = [
'tenant_codigo' => $tenant->codigo,
'status' => Cart::STATUS_ACTIVE,
'origin' => Cart::ORIGIN_USER,
];
if ($identity['user_id'] !== null) {
$attributes['user_id'] = $identity['user_id'];
} else {
$attributes['guest_token'] = $identity['guest_token'];
}
return Cart::query()->firstOrCreate($attributes);
}
}

View File

@@ -1,99 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Cart\Models\CartItem;
use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogInventoryService;
use Illuminate\Validation\ValidationException;
class CartVariantReplacementService
{
public function __construct(private readonly CatalogInventoryService $inventory) {}
public function replaceHistoricalVariants(Cart $cart): void
{
$items = $cart->items()
->whereNotNull('variant_id')
->orderBy('id')
->lockForUpdate()
->get();
foreach ($items as $item) {
$variant = Variant::query()->lockForUpdate()->find($item->variant_id);
if ($variant === null) {
throw $this->unavailableVariant();
}
$replacement = $this->latestReplacement($variant);
if ($replacement->is($variant)) {
if (! $variant->isSellable()) {
throw $this->unavailableVariant();
}
continue;
}
if (! $replacement->isSellable()) {
throw $this->unavailableVariant();
}
/** @var CartItem|null $targetItem */
$targetItem = $cart->items()
->whereKeyNot($item->getKey())
->where('catalog_item_id', $item->catalog_item_id)
->where('variant_id', $replacement->getKey())
->lockForUpdate()
->first();
$replacementQuantity = $item->cantidad + ($targetItem?->cantidad ?? 0);
if ($replacement->inventory_id !== $variant->inventory_id) {
$available = $this->inventory->availableQuantity($replacement);
if ($available !== null && $available < $replacementQuantity) {
throw $this->unavailableVariant();
}
}
if ($targetItem !== null) {
$targetItem->cantidad += $item->cantidad;
$targetItem->save();
$item->delete();
continue;
}
$item->update(['variant_id' => $replacement->getKey()]);
}
}
private function latestReplacement(Variant $variant): Variant
{
$current = $variant;
$visited = [];
while ($current->replaced_by_variant_id !== null) {
if (isset($visited[$current->getKey()])) {
throw $this->unavailableVariant();
}
$visited[$current->getKey()] = true;
$current = Variant::query()
->lockForUpdate()
->find($current->replaced_by_variant_id)
?? throw $this->unavailableVariant();
}
return $current;
}
private function unavailableVariant(): ValidationException
{
return ValidationException::withMessages([
'cart_id' => [__('api.cart.cart_variant_unavailable')],
]);
}
}

View File

@@ -1,53 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart;
use Illuminate\Support\Facades\DB;
class GuestCartMergeService
{
public function merge(string $tenantCodigo, User $user, ?string $guestToken): void
{
if ($guestToken === null || $guestToken === '') {
return;
}
DB::transaction(function () use ($tenantCodigo, $user, $guestToken): void {
$guestCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('guest_token', $guestToken)
->where('status', 'active')
->lockForUpdate()
->first();
if ($guestCart === null || ! $guestCart->items()->exists()) {
return;
}
$userCart = Cart::query()
->where('tenant_codigo', $tenantCodigo)
->where('user_id', $user->getKey())
->where('status', 'active')
->lockForUpdate()
->first();
if ($userCart !== null) {
$userCart->items()
->orderBy('id')
->pluck('id')
->each(fn (int $itemId) => $userCart->removeItem($itemId));
$userCart->update([
'status' => 'converted',
]);
$userCart->delete();
}
$guestCart->update([
'user_id' => $user->getKey(),
'guest_token' => null,
]);
});
}
}

View File

@@ -1,59 +0,0 @@
<?php
namespace App\Domains\Commerce\Cart\Services;
use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class InvalidateEventDateCartsService
{
public function __construct(private readonly StockReservationService $reservations) {}
/** @param Collection<int, int> $eventDateIds */
public function invalidate(
Tenant $tenant,
Collection $eventDateIds,
string $reason = StockReservationService::REASON_EVENT_DATE_RESCHEDULED,
): void {
DB::transaction(function () use ($tenant, $eventDateIds, $reason): void {
$carts = Cart::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Cart::STATUS_ACTIVE)
->whereNull('current_purchase_id')
->whereHas('currentStockReservation', fn ($reservation) => $reservation
->where('status', StockReservation::STATUS_ACTIVE))
->whereHas('items.variant', fn ($variant) => $variant
->withTrashed()
->where(fn ($dates) => $dates
->whereIn('event_date_id', $eventDateIds)
->orWhereHas('eventDates', fn ($date) => $date
->whereIn('event_dates.id', $eventDateIds))))
->orderBy('id')
->lockForUpdate()
->get();
foreach ($carts as $cart) {
// Checkout keeps the cart and purchase attached to the same reservation.
if (Purchase::query()
->where('stock_reservation_id', $cart->current_stock_reservation_id)
->exists()) {
continue;
}
$this->reservations->releaseCurrentCartReservation(
$cart,
$reason,
);
// Reuse the existing expired-cart flow: stale mutations receive the
// expiration response and the next GET replaces the whole cart.
$cart->update(['status' => Cart::STATUS_EXPIRED]);
}
});
}
}

View File

@@ -1,38 +0,0 @@
# 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, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
## 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. Cada ítem expone `nombre`, `imagen` y `precio_unitario` en la raíz, priorizando la variante seleccionada y usando el catálogo base como respaldo. La variante seleccionada se serializa en `variant`; las variantes alternativas no forman parte de la respuesta del carrito.
## 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.
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.

View File

@@ -1,65 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Controllers\AdminApp;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
use App\Domains\Commerce\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
use App\Domains\Commerce\Catalog\Services\OnTicketFeaturedGroupService;
use App\Domains\Core\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(in_array($tenant->storefront_website_type_code, ['onticket', 'onticket_multi_event'], true), 404);
return $tenant;
}
}

View File

@@ -1,173 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Controllers;
use App\Domains\Commerce\Cart\Services\CartService;
use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
use App\Domains\Commerce\Catalog\Requests\CatalogItemDetailRequest;
use App\Domains\Commerce\Catalog\Requests\CatalogVariantOptionsRequest;
use App\Domains\Commerce\Catalog\Requests\CategoryPageRequest;
use App\Domains\Commerce\Catalog\Requests\FeaturedGroupPageRequest;
use App\Domains\Commerce\Catalog\Requests\SearchCatalogItemsRequest;
use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedGroupResource;
use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource;
use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource;
use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource;
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Commerce\Catalog\Services\FeaturedGroupService;
use App\Domains\Commerce\Catalog\Services\VariantSelectionService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Support\Facades\Auth;
class CatalogController extends Controller
{
public function index(Request $request, Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
{
$featuredGroups = FeaturedGroup::query()
->where('tenant_code', $tenant->codigo)
->orderBy('group_order')
->get();
return response()->json($featuredGroups->map(
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
$featuredGroup,
$featuredGroupService->itemsResponse($featuredGroup, $tenant, 1, $this->userId($request)),
))->resolve()
));
}
public function search(
SearchCatalogItemsRequest $request,
Tenant $tenant,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection {
$items = $catalogService->search(
$tenant,
$request->validated('q'),
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
);
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items);
}
public function category(
CategoryPageRequest $request,
Tenant $tenant,
Category $category,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): AnonymousResourceCollection {
abort_unless($category->tenant_code === $tenant->codigo, 404);
$items = $catalogService->categoryItems(
$tenant,
$category,
$tenant->search_items_per_page,
(int) $request->validated('page', 1),
);
$allowances->attach($items->getCollection(), $this->userId($request));
return CatalogSearchItemResource::collection($items)->additional([
'category' => [
'id' => $category->id,
'nombre' => $category->nombre,
'categoria_id' => $category->categoria_id,
],
'layout' => $tenant->search_product_layout->value,
'group_layout' => $tenant->search_group_layout->value,
]);
}
public function featuredGroupItems(
FeaturedGroupPageRequest $request,
Tenant $tenant,
FeaturedGroup $featuredGroup,
FeaturedGroupService $featuredGroupService,
): JsonResponse {
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
$page = (int) $request->validated('page', 1);
return response()->json($featuredGroupService->itemsResponse(
$featuredGroup,
$tenant,
$page,
$this->userId($request),
));
}
public function show(
CatalogItemDetailRequest $request,
Tenant $tenant,
CatalogItem $catalogItem,
CatalogService $catalogService,
CatalogItemAllowanceService $allowances,
): CatalogItemDetailResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo
&& ($tenant->active_event_id === null || $catalogItem->event_id === $tenant->active_event_id), 404);
$variantId = $request->validated('variant_id');
$item = $catalogService->getDetail(
$catalogItem,
$variantId === null ? null : (int) $variantId,
);
$allowances->attach(collect([$item]), $this->userId($request));
return CatalogItemDetailResource::make($item);
}
public function variantOptions(
CatalogVariantOptionsRequest $request,
Tenant $tenant,
CatalogItem $catalogItem,
VariantSelectionService $variantSelectionService,
CartService $cartService,
): CatalogVariantOptionsResource {
abort_unless($catalogItem->tenant_code === $tenant->codigo
&& ($tenant->active_event_id === null || $catalogItem->event_id === $tenant->active_event_id), 404);
$includedVariantId = null;
$cartItemId = $request->validated('cart_item_id');
$selectedValues = $request->validated('selected_values', []);
if ($cartItemId !== null) {
$cartItem = $cartService->show($tenant, $request)
->items
->firstWhere('id', (int) $cartItemId);
abort_unless($cartItem?->catalog_item_id === $catalogItem->id, 404);
$includedVariantId = $cartItem->variant_id;
if ($selectedValues === [] && $cartItem->variant !== null) {
$selectedValues = $cartItem->variant
->selectorOptions($catalogItem->itemAttributes)
->all();
}
}
return CatalogVariantOptionsResource::make(
$variantSelectionService->options(
$catalogItem,
$selectedValues,
$includedVariantId,
)
);
}
private function userId(Request $request): ?int
{
$userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id();
return $userId === null ? null : (int) $userId;
}
}

View File

@@ -1,15 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Enums;
enum CatalogItemType: string
{
case Standard = 'standard';
case Bundle = 'bundle';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,18 +0,0 @@
<?php
namespace App\Domains\Commerce\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');
}
}

View File

@@ -1,20 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Enums;
enum GroupLayout: string
{
case Paginated = 'paginated';
case Simple = 'simple';
case SimpleVertical = 'simple_vertical';
case Carousel = 'carousel';
case Single = 'single';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,17 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Enums;
enum InventoryPolicy: string
{
case Tracked = 'tracked';
case Unlimited = 'unlimited';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,16 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Enums;
enum InventorySubject: string
{
case Product = 'product';
case Seat = 'seat';
case Ticket = 'ticket';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,19 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Enums;
enum ProductLayout: string
{
case Row = 'row';
case ColumnWithImage = 'column_with_image';
case ColumnWithCart = 'column_with_cart';
case TicketSelector = 'ticket_selector';
/**
* @return list<string>
*/
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -1,13 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Exceptions;
use RuntimeException;
class StockReservationExpiredException extends RuntimeException
{
public function __construct()
{
parent::__construct(__('api.cart.reservation_expired'));
}
}

View File

@@ -1,49 +0,0 @@
<?php
namespace App\Domains\Commerce\Catalog\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'bundle_catalog_item_id',
'component_catalog_item_id',
'component_variant_id',
'quantity',
])]
class BundleComponent extends Model
{
use HasFactory;
public $timestamps = false;
protected function casts(): array
{
return [
'bundle_catalog_item_id' => 'integer',
'component_catalog_item_id' => 'integer',
'component_variant_id' => 'integer',
'quantity' => 'integer',
];
}
/** @return BelongsTo<CatalogItem, $this> */
public function bundle(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'bundle_catalog_item_id');
}
/** @return BelongsTo<CatalogItem, $this> */
public function catalogItem(): BelongsTo
{
return $this->belongsTo(CatalogItem::class, 'component_catalog_item_id');
}
/** @return BelongsTo<Variant, $this> */
public function variant(): BelongsTo
{
return $this->belongsTo(Variant::class, 'component_variant_id');
}
}

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