Compare commits
242 Commits
tenant/des
...
e9521e65d0
| Author | SHA1 | Date | |
|---|---|---|---|
| e9521e65d0 | |||
| d0625f25e5 | |||
| 1fdcfc1547 | |||
| ca4ea0aa6c | |||
| bb1f9c8e91 | |||
| 99fe94fa6a | |||
| a4b5c2eb19 | |||
| 2feca2bed5 | |||
| a35ff69140 | |||
| 75152b53a4 | |||
| 99594b17e7 | |||
| 85edea0661 | |||
| ab5ea7dd33 | |||
| c0057c237a | |||
| e4146288f1 | |||
| f11ba5a470 | |||
| e4db36e650 | |||
| ffa3f10b18 | |||
| 7acef66ee7 | |||
| 18f1217daa | |||
| ac44e82454 | |||
| 1e7a9b6876 | |||
| deea4fd4af | |||
| c4ff695f2e | |||
| 703158bd06 | |||
| eda2343380 | |||
| 0baad641d5 | |||
| ddb8c3743f | |||
| c2233389d0 | |||
| 334ffe92e7 | |||
| a19ace3115 | |||
| acc0a68736 | |||
| dcf105c316 | |||
| f74bf41931 | |||
| 34217be630 | |||
| ee906d2d2e | |||
| 836aa1afd7 | |||
| 6896646c54 | |||
| 5e541f7cd0 | |||
| 80b8707771 | |||
| 58b15f3026 | |||
| 2e9355c85c | |||
| fa1e11a69f | |||
| 6a00d8d09c | |||
| fbfcb5cf63 | |||
| adce4ab59e | |||
| 5250d1a818 | |||
| 457eba68a9 | |||
| 52b0b339d6 | |||
| 9175df1d7f | |||
| 38341b2bda | |||
| 34a0a14404 | |||
| af516ce9e3 | |||
| dca35e7b0c | |||
| df853de843 | |||
| 4ba464bf85 | |||
| 208e929262 | |||
| ffe47d903e | |||
| 24983439b1 | |||
| d179a0f74e | |||
| 636e39e98e | |||
| bda94d02af | |||
| bfb16ef60e | |||
| 1b6303237a | |||
| 9da92c880a | |||
| 63012838a4 | |||
| 27544a23be | |||
| a71c80248c | |||
| 1ca8fb20af | |||
| ba167559a6 | |||
| 495515b1f3 | |||
| 781e282f16 | |||
| 7fb4c9674a | |||
| 0d85c3df19 | |||
| a93b260043 | |||
| c209e716e3 | |||
| 900ef6cf98 | |||
| 71c914a389 | |||
| 705b1247f5 | |||
| b1f42775d6 | |||
| 0f6f39cce7 | |||
| 1ec7ab3e35 | |||
| ba0e2dd00c | |||
| c792e7d306 | |||
| 6ee3f22e41 | |||
| d4ec34db1a | |||
| 351178d9da | |||
| b617285148 | |||
| 9bca41bfa5 | |||
| 5601285369 | |||
| 6fb60c9a73 | |||
| c3f1c320a4 | |||
| 899bf12457 | |||
| a460743ae0 | |||
| a4d7b1b789 | |||
| 10655b8c07 | |||
| adc7b21ab8 | |||
| ba1355448a | |||
| 1c76e56f2d | |||
| daa74845b7 | |||
| 05944cec9c | |||
| c820741e5f | |||
| adc4cc9595 | |||
| c67dfcc1a1 | |||
| f61b7e4dee | |||
| e53fa949cf | |||
| c51e24311f | |||
| a650de79c1 | |||
| 8f4fc39858 | |||
| 09554b9f80 | |||
| 0d2198a3db | |||
| 52732da960 | |||
| 1f7bb35f63 | |||
| 22c6631652 | |||
| bbe3cf82f5 | |||
| ff61a3d3b8 | |||
| 6c5d49bf45 | |||
| 2596b5df18 | |||
| 733064c0dc | |||
| 99808c1053 | |||
| e2ad78b1fb | |||
| 8ed8a11b7b | |||
| dc33e5dc09 | |||
| 414df97e9d | |||
| d0424c5589 | |||
| 28d09dedab | |||
| 8220fb5aaf | |||
| bf02d37a33 | |||
| 78ce263849 | |||
| 0c8419a5eb | |||
| 843659583e | |||
| c2921166bd | |||
| 0f67e66b6b | |||
| 725abed06a | |||
| 743939fc63 | |||
| eb50e65fef | |||
| 1cf13501d8 | |||
| 2eeef8d392 | |||
| 87a4fa6288 | |||
| 07d2129410 | |||
| ceb1d1f1af | |||
| 885ab2a6e3 | |||
| 47196a572a | |||
| 45bba9516d | |||
| c6da71894e | |||
| 75d3c819d9 | |||
| bd6d672df2 | |||
| 6b06028771 | |||
| 0aa423e90e | |||
| e7bf2f887a | |||
| 80a02fdc52 | |||
| 4162e2c1cc | |||
| 8d01620fa8 | |||
| f8b8bab474 | |||
| 53d1415b18 | |||
| 6ff9119086 | |||
| 24e000b423 | |||
| c69ebe5f7c | |||
| c3a2da607d | |||
| d1c4acc7ae | |||
| f8c4cc7428 | |||
| 21cfbe2a46 | |||
| abc060ab25 | |||
| 80adbd9ca9 | |||
| 12c10bbe06 | |||
| 36eb41a18a | |||
| 1dd2366957 | |||
| e8bcaa2026 | |||
| 5548fe8511 | |||
| dc0fbe06b8 | |||
| afd69b8393 | |||
| a34d5cba1a | |||
| 1650176aa0 | |||
| 79e721ae63 | |||
| 4e7e42d16e | |||
| 3a2dc8b7e0 | |||
| 0510c1aa12 | |||
| 9d870be294 | |||
| 559bf5d264 | |||
| eafb02ffd5 | |||
| 786cf2db52 | |||
| cb56e33685 | |||
| dd81e3b3f7 | |||
| 4d51531c5d | |||
| 7fd8a00054 | |||
| 23163a6eb8 | |||
| 944cf8ee18 | |||
| 8c09ca1204 | |||
| 98d502a8ef | |||
| 318e6559cb | |||
| 9fd34f900f | |||
| ae6149df84 | |||
| 1776cfe581 | |||
| 5ad18694e1 | |||
| 6775fb110c | |||
| 9c46eff880 | |||
| c8ce3b0da3 | |||
| d36a929f8c | |||
| 2de02dfd31 | |||
| f9a766dee1 | |||
| 8bc57cf0ff | |||
| a78c5cee58 | |||
| cc22e33799 | |||
| 5c0b3503b7 | |||
| 8537d2ed0a | |||
| d97cc12af6 | |||
| 7a19b3a97a | |||
| a94ac7a473 | |||
| 161693509f | |||
| 93c99afb13 | |||
| 32d2b182c6 | |||
| 2632a80722 | |||
| 3cee9c28f8 | |||
| 2091b1361a | |||
| a2c5e687f9 | |||
| 172e14ae9b | |||
| 58cbeae9d3 | |||
| 1dc4e29c69 | |||
| 093e894cc3 | |||
| fdf0f3328f | |||
| 8d6bcdcc43 | |||
| 3206e293eb | |||
| aed99bd05e | |||
| f1649e0e4b | |||
| e6c4b40a37 | |||
| 15878cd9ba | |||
| d6574bfd0d | |||
| 16bc657a31 | |||
| 7cea8d3495 | |||
| 014b5bb012 | |||
| 67e6211857 | |||
| 58471cb13d | |||
| 864bed0113 | |||
| ced7d594e8 | |||
| 38e87fff6c | |||
| a8973f6171 | |||
| 8e26611097 | |||
| 817d0de6d2 | |||
| a2298c9de2 | |||
| d73b4d1daf | |||
| e6785eb5af | |||
| 1a3f564afd |
12
.env.example
12
.env.example
@@ -8,6 +8,8 @@ PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
|||||||
PURCHASE_QR_EXPIRATION_MINUTES=15
|
PURCHASE_QR_EXPIRATION_MINUTES=15
|
||||||
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||||
|
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
|
||||||
|
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||||
FRONTEND_URLS=http://localhost:4200
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
APP_LOCALE=es
|
APP_LOCALE=es
|
||||||
@@ -30,11 +32,19 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
|||||||
AUTH_LOGIN_LOCK_MINUTES=15
|
AUTH_LOGIN_LOCK_MINUTES=15
|
||||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||||
|
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
|
||||||
|
|
||||||
LOG_CHANNEL=stack
|
LOG_CHANNEL=daily
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
LOG_DEPRECATIONS_CHANNEL=null
|
LOG_DEPRECATIONS_CHANNEL=null
|
||||||
LOG_LEVEL=debug
|
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
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Administrator\Requests\StoreAdministratorRequest;
|
||||||
|
use App\Domains\Administrator\Requests\UpdateAdministratorRequest;
|
||||||
|
use App\Domains\Administrator\Resources\AdministratorResource;
|
||||||
|
use App\Domains\Administrator\Services\AdministratorService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
|
class AdminAppAdministratorController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AdministratorService $administratorService) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return AdministratorResource::collection($this->administratorService->list(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->string('search')->trim()->toString() ?: null,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreAdministratorRequest $request): AdministratorResource
|
||||||
|
{
|
||||||
|
return AdministratorResource::make($this->administratorService->create(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateAdministratorRequest $request, int $administrator): AdministratorResource
|
||||||
|
{
|
||||||
|
return AdministratorResource::make($this->administratorService->update(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$administrator,
|
||||||
|
$request->validated(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Request $request, int $administrator): Response
|
||||||
|
{
|
||||||
|
$this->administratorService->delete($request->user()->tenant()->firstOrFail(), $administrator, $request->user());
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreAdministratorRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateAdministratorRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->rol_codigo === RoleCode::AdminApp->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$administratorId = (int) $this->route('administrator');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($administratorId),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin User */
|
||||||
|
class AdministratorResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'nombre_apellido' => $this->nombre_apellido,
|
||||||
|
'dni' => $this->dni,
|
||||||
|
'email' => $this->email,
|
||||||
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'role' => $this->whenLoaded('role', fn () => [
|
||||||
|
'codigo' => $this->role?->codigo,
|
||||||
|
'nombre' => $this->role?->nombre,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
99
app/Domains/Administrator/Services/AdministratorService.php
Normal file
99
app/Domains/Administrator/Services/AdministratorService.php
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Administrator\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Arr;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class AdministratorService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
||||||
|
|
||||||
|
/** @return Collection<int, User> */
|
||||||
|
public function list(Tenant $tenant, ?string $search = null): Collection
|
||||||
|
{
|
||||||
|
return $this->query($tenant)->with('role')
|
||||||
|
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
||||||
|
$query->where('nombre_apellido', 'like', "%{$search}%")
|
||||||
|
->orWhere('dni', 'like', "%{$search}%")
|
||||||
|
->orWhere('email', 'like', "%{$search}%");
|
||||||
|
}))
|
||||||
|
->orderBy('nombre_apellido')->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function create(Tenant $tenant, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): User {
|
||||||
|
$administrator = User::query()->create([
|
||||||
|
...$this->attributes($data),
|
||||||
|
'password' => Str::random(64),
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
||||||
|
$administrator->email,
|
||||||
|
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $administrator->load('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function update(Tenant $tenant, int $administratorId, array $data): User
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $administratorId, $data): User {
|
||||||
|
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||||
|
$administrator->update($this->attributes($data));
|
||||||
|
|
||||||
|
return $administrator->load('role');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(Tenant $tenant, int $administratorId, User $actor): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($tenant, $administratorId, $actor): void {
|
||||||
|
// Serialize deletions for this tenant, including requests already authenticated
|
||||||
|
// when another administrator removes their account.
|
||||||
|
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||||
|
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||||
|
if ($administrator->is($actor)) {
|
||||||
|
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
||||||
|
}
|
||||||
|
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get();
|
||||||
|
if ($activeAdministrators->count() <= 1) {
|
||||||
|
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
||||||
|
}
|
||||||
|
abort_unless($activeAdministrators->contains('id', $actor->id), 403);
|
||||||
|
$administrator->tokens()->delete();
|
||||||
|
$administrator->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function query(Tenant $tenant): Builder
|
||||||
|
{
|
||||||
|
return User::query()->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('rol_codigo', RoleCode::AdminApp->value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function attributes(array $data): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...Arr::only($data, ['nombre_apellido', 'dni']),
|
||||||
|
'email' => mb_strtolower(trim((string) $data['email'])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
63
app/Domains/Administrator/documentacion/README.md
Normal file
63
app/Domains/Administrator/documentacion/README.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Administradores de AdminApp
|
||||||
|
|
||||||
|
CRUD de usuarios con rol `adminapp`, limitado al tenant del usuario autenticado.
|
||||||
|
Todos los administradores del tenant pueden gestionar esta sección.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Base: `/api/v1/adminapp/tenant/administrators`.
|
||||||
|
Requieren `auth:sanctum` y `adminapp.tenant`.
|
||||||
|
|
||||||
|
- `GET /`: listado ordenado por nombre; acepta `search` por nombre, DNI o email.
|
||||||
|
- `POST /`: alta; responde `201` con `data`.
|
||||||
|
- `PUT /{administrator}`: actualización de los tres campos; responde `200` con `data`.
|
||||||
|
- `DELETE /{administrator}`: baja lógica; responde `204`.
|
||||||
|
|
||||||
|
Alta y actualización reciben:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"nombre_apellido": "Ada Lovelace",
|
||||||
|
"dni": "12345678",
|
||||||
|
"email": "ada@example.test"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Nombre (hasta 255 caracteres), DNI (hasta 50) y email (hasta 255) son obligatorios.
|
||||||
|
El email se normaliza a minúsculas antes de validar y debe ser único entre
|
||||||
|
usuarios activos, sin importar su tenant o rol. Se permite reutilizar el email
|
||||||
|
de un usuario eliminado. Rol y tenant no son editables desde esta API.
|
||||||
|
|
||||||
|
Las respuestas incluyen `id`, `nombre_apellido`, `dni`, `email`, `rol_codigo`
|
||||||
|
y `role` (`codigo`, `nombre`). Nunca incluyen contraseña ni datos de escaneo.
|
||||||
|
|
||||||
|
## Alta y acceso
|
||||||
|
|
||||||
|
Se genera una contraseña aleatoria y un intento de establecimiento de contraseña
|
||||||
|
con motivo `administrator_created`, reutilizando `createForAdminAppEmail`.
|
||||||
|
El evento usa el canal `adminapp`; el listener existente envía el email después
|
||||||
|
del commit mediante la cola `emails`. Requiere la configuración de correo,
|
||||||
|
dominio AdminApp y worker existentes. No se envían contraseñas en texto plano.
|
||||||
|
|
||||||
|
## Eliminación y aislamiento
|
||||||
|
|
||||||
|
Las consultas de usuarios se limitan por tenant y rol `adminapp`. IDs ajenos,
|
||||||
|
usuarios eliminados y usuarios de otros roles devuelven `404`.
|
||||||
|
La validación de campos devuelve `422`; falta de autenticación, `401`, y rol
|
||||||
|
no autorizado, `403`.
|
||||||
|
|
||||||
|
No se permite eliminar al propio usuario ni dejar al tenant sin administradores
|
||||||
|
(`422`, error `administrator`). La eliminación bloquea la fila del tenant dentro
|
||||||
|
de una transacción para serializar bajas concurrentes. También verifica que el
|
||||||
|
actor siga activo, revoca tokens y aplica el borrado lógico existente en `users`.
|
||||||
|
|
||||||
|
No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el frontend.
|
||||||
|
|
||||||
|
## Verificación
|
||||||
|
|
||||||
|
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
||||||
|
|
||||||
|
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
||||||
|
contraseña, restricciones de rol y tenant, baja lógica, tokens y protecciones de
|
||||||
|
eliminación. El caso de petición autenticada antes de la baja del actor se simula;
|
||||||
|
no es una prueba con conexiones concurrentes reales.
|
||||||
10
app/Domains/Administrator/routes/api.php
Normal file
10
app/Domains/Administrator/routes/api.php
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Administrator\Controllers\AdminAppAdministratorController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::apiResource('administrators', AdminAppAdministratorController::class)->except('show');
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
use App\Domains\Auth\Requests\ResetPasswordRequest;
|
||||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -26,6 +27,7 @@ class ResetPasswordController extends Controller
|
|||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
$data['password'],
|
$data['password'],
|
||||||
|
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||||
)) {
|
)) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.password_reset_invalid'),
|
'codigo' => __('api.auth.password_reset_invalid'),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Auth\Controllers;
|
|||||||
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
use App\Domains\Auth\Requests\ValidateResetPasswordAttemptRequest;
|
||||||
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -22,10 +23,19 @@ class ValidateResetPasswordAttemptController extends Controller
|
|||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
if (! $this->resetPasswordAttemptService->validateCode(
|
$result = $this->resetPasswordAttemptService->validateCode(
|
||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
)) {
|
RoleCode::from($request->route('reset_role', RoleCode::User->value)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.reset_code_expired'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.reset_code_invalid'),
|
'codigo' => __('api.auth.reset_code_invalid'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
|
||||||
#[Hidden(['codigo'])]
|
#[Hidden(['codigo'])]
|
||||||
class ResetPasswordAttempt extends Model
|
class ResetPasswordAttempt extends Model
|
||||||
{
|
{
|
||||||
@@ -17,6 +17,8 @@ class ResetPasswordAttempt extends Model
|
|||||||
|
|
||||||
public const REASON_STAFF_CREATED = 'staff_created';
|
public const REASON_STAFF_CREATED = 'staff_created';
|
||||||
|
|
||||||
|
public const REASON_ADMINISTRATOR_CREATED = 'administrator_created';
|
||||||
|
|
||||||
public const STATUS_PENDING = 'pending';
|
public const STATUS_PENDING = 'pending';
|
||||||
|
|
||||||
public const STATUS_VALIDATED = 'validated';
|
public const STATUS_VALIDATED = 'validated';
|
||||||
@@ -31,6 +33,7 @@ class ResetPasswordAttempt extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,16 +13,17 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||||
use Illuminate\Notifications\Notifiable;
|
use Illuminate\Notifications\Notifiable;
|
||||||
use Laravel\Sanctum\HasApiTokens;
|
use Laravel\Sanctum\HasApiTokens;
|
||||||
|
|
||||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
|
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
|
||||||
#[Hidden(['password', 'remember_token'])]
|
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
/** @use HasFactory<UserFactory> */
|
/** @use HasFactory<UserFactory> */
|
||||||
use HasApiTokens, HasFactory, Notifiable;
|
use HasApiTokens, HasFactory, Notifiable, SoftDeletes;
|
||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'rol_codigo' => RoleCode::User->value,
|
'rol_codigo' => RoleCode::User->value,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Auth\Requests;
|
namespace App\Domains\Auth\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Illuminate\Validation\Rules\Password;
|
use Illuminate\Validation\Rules\Password;
|
||||||
@@ -13,15 +14,26 @@ class RegisterUserRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
protected function prepareForValidation(): void
|
||||||
* @return array<string, mixed>
|
{
|
||||||
*/
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
'tenant_codigo' => ['nullable', 'string', Rule::exists('tenants', 'codigo')],
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'email' => ['required', 'string', 'email', 'max:255', Rule::unique('users', 'email')],
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'string',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::User->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
'password' => ['required', 'string', 'confirmed', Password::min(8)->mixedCase()->symbols()],
|
||||||
'dni' => ['nullable', 'string', 'max:255'],
|
'dni' => ['nullable', 'string', 'max:255'],
|
||||||
'telefono' => ['nullable', 'string', 'max:255'],
|
'telefono' => ['nullable', 'string', 'max:255'],
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Auth\Requests;
|
|||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
|
use Illuminate\Validation\Rules\Password;
|
||||||
|
|
||||||
class UpdateProfileRequest extends FormRequest
|
class UpdateProfileRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -12,6 +13,13 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
if (is_string($this->input('email'))) {
|
||||||
|
$this->merge(['email' => mb_strtolower(trim($this->input('email')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
@@ -19,11 +27,13 @@ class UpdateProfileRequest extends FormRequest
|
|||||||
'email' => [
|
'email' => [
|
||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
Rule::unique('users', 'email')->ignore($this->user()->id),
|
Rule::unique('users', 'active_email')->where('rol_codigo', $this->user()->rol_codigo)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($this->user()->id),
|
||||||
],
|
],
|
||||||
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
'dni' => ['nullable', 'string', 'regex:/^[0-9]{7,8}$/'],
|
||||||
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
'telefono' => ['nullable', 'string', 'regex:/^\+?[0-9\s\-]+$/'],
|
||||||
'password' => ['nullable', 'string', \Illuminate\Validation\Rules\Password::min(8)->mixedCase()->symbols()],
|
'password' => ['nullable', 'string', Password::min(8)->mixedCase()->symbols()],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
22
app/Domains/Auth/Services/AdminCredentialVerifier.php
Normal file
22
app/Domains/Auth/Services/AdminCredentialVerifier.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
|
||||||
|
class AdminCredentialVerifier
|
||||||
|
{
|
||||||
|
public function verify(string $email, string $password): bool
|
||||||
|
{
|
||||||
|
$admin = User::query()
|
||||||
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', RoleCode::Admin->value)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
return $admin !== null
|
||||||
|
&& ! $admin->locked_until?->isFuture()
|
||||||
|
&& Hash::check($password, $admin->getAuthPassword());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Auth\Services;
|
namespace App\Domains\Auth\Services;
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
||||||
@@ -124,12 +125,12 @@ class GoogleAuthService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->where('google_id', $googleId)->first();
|
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('google_id', $googleId)->first();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
return $user;
|
return $user;
|
||||||
}
|
}
|
||||||
|
|
||||||
$user = User::query()->where('email', $email)->first();
|
$user = User::query()->where('rol_codigo', RoleCode::User->value)->where('active_email', mb_strtolower(trim($email)))->first();
|
||||||
if ($user) {
|
if ($user) {
|
||||||
$user->forceFill(['google_id' => $googleId])->save();
|
$user->forceFill(['google_id' => $googleId])->save();
|
||||||
|
|
||||||
@@ -155,16 +156,19 @@ class GoogleAuthService
|
|||||||
$parts = parse_url($returnUrl);
|
$parts = parse_url($returnUrl);
|
||||||
if (! is_array($parts)
|
if (! is_array($parts)
|
||||||
|| ! isset($parts['scheme'], $parts['host'])
|
|| ! isset($parts['scheme'], $parts['host'])
|
||||||
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])
|
|| isset($parts['user'], $parts['pass'], $parts['query'], $parts['fragment'])) {
|
||||||
|| ($parts['path'] ?? '') !== '') {
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
$scheme = strtolower($parts['scheme']);
|
$scheme = strtolower($parts['scheme']);
|
||||||
$host = TenantDomainNormalizer::normalize($parts['host']);
|
$host = TenantDomainNormalizer::normalize($parts['host']);
|
||||||
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
|
$tenantDomain = TenantDomainNormalizer::normalize($tenant->dominio);
|
||||||
|
$returnPath = TenantDomainNormalizer::normalizePath($parts['path'] ?? '/');
|
||||||
|
|
||||||
if ($host === null || $tenantDomain === null || $host !== $tenantDomain) {
|
if ($host === null
|
||||||
|
|| $tenantDomain === null
|
||||||
|
|| $host !== $tenantDomain
|
||||||
|
|| $returnPath !== $tenant->base_path) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ class PasswordLoginService
|
|||||||
null,
|
null,
|
||||||
$ipAddress,
|
$ipAddress,
|
||||||
$userAgent,
|
$userAgent,
|
||||||
null,
|
RoleCode::Scanner,
|
||||||
true,
|
true,
|
||||||
PermissionCode::ScanTickets->value,
|
PermissionCode::ScanTickets->value,
|
||||||
PasswordResetRequested::CHANNEL_SCANNER,
|
PasswordResetRequested::CHANNEL_SCANNER,
|
||||||
@@ -120,7 +120,7 @@ class PasswordLoginService
|
|||||||
$passwordResetChannel,
|
$passwordResetChannel,
|
||||||
): array {
|
): array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $normalizedEmail)
|
->where('active_email', $normalizedEmail)
|
||||||
->when(
|
->when(
|
||||||
$requiredRole !== null,
|
$requiredRole !== null,
|
||||||
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
fn ($query) => $query->where('rol_codigo', $requiredRole->value),
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use Throwable;
|
|||||||
|
|
||||||
class ResetPasswordAttemptService
|
class ResetPasswordAttemptService
|
||||||
{
|
{
|
||||||
|
public const CODE_VALID = 'valid';
|
||||||
|
|
||||||
|
public const CODE_INVALID = 'invalid';
|
||||||
|
|
||||||
|
public const CODE_EXPIRED = 'expired';
|
||||||
|
|
||||||
public function createForEmail(
|
public function createForEmail(
|
||||||
string $email,
|
string $email,
|
||||||
string $tenantCode,
|
string $tenantCode,
|
||||||
@@ -22,7 +28,8 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
$attemptId = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?int {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', RoleCode::User->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -59,7 +66,7 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::AdminApp->value)
|
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
->whereNotNull('tenant_codigo')
|
->whereNotNull('tenant_codigo')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -107,7 +114,7 @@ class ResetPasswordAttemptService
|
|||||||
try {
|
try {
|
||||||
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
$result = DB::transaction(function () use ($email, $emailFingerprint, $reason): ?array {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::Scanner->value)
|
->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
->whereNotNull('tenant_codigo')
|
->whereNotNull('tenant_codigo')
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -146,14 +153,15 @@ class ResetPasswordAttemptService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateCode(string $email, string $code): bool
|
public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $emailFingerprint, $role): string {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', $role->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -169,14 +177,27 @@ class ResetPasswordAttemptService
|
|||||||
'email_fingerprint' => $emailFingerprint,
|
'email_fingerprint' => $emailFingerprint,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return false;
|
return self::CODE_INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attempt->expires_at?->isPast()) {
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Password reset code validation failed: attempt expired.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'attempt_id' => $attempt->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::CODE_EXPIRED;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attempt->update([
|
$attempt->update([
|
||||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return self::CODE_VALID;
|
||||||
});
|
});
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::error('Failed to validate password reset code.', [
|
Log::error('Failed to validate password reset code.', [
|
||||||
@@ -188,14 +209,15 @@ class ResetPasswordAttemptService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetPassword(string $email, string $code, string $password): bool
|
public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $password, $emailFingerprint, $role): bool {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
|
->where('rol_codigo', $role->value)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -214,6 +236,19 @@ class ResetPasswordAttemptService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($attempt->expires_at?->isPast()) {
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Password reset failed: attempt expired.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'attempt_id' => $attempt->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$user->password = $password;
|
$user->password = $password;
|
||||||
$user->failed_login_attempts = 0;
|
$user->failed_login_attempts = 0;
|
||||||
$user->last_failed_login_at = null;
|
$user->last_failed_login_at = null;
|
||||||
@@ -270,6 +305,7 @@ class ResetPasswordAttemptService
|
|||||||
'codigo' => $this->generateCode(),
|
'codigo' => $this->generateCode(),
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $attempt->getKey();
|
return $attempt->getKey();
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Route::prefix('v1/adminapp')->group(function (): void {
|
|||||||
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts', CreateAdminAppResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->defaults('reset_role', 'adminapp')
|
||||||
->middleware('throttle:10,1');
|
->middleware('throttle:10,1');
|
||||||
Route::post('password/reset', ResetPasswordController::class)
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->defaults('reset_role', 'adminapp')
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->get('me', AdminAppMeController::class);
|
->get('me', AdminAppMeController::class);
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ Route::prefix('v1/scanner')->group(function (): void {
|
|||||||
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts', CreateScannerResetPasswordAttemptController::class)
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
Route::post('password/reset-attempts/validate', ValidateResetPasswordAttemptController::class)
|
||||||
|
->defaults('reset_role', 'scanner')
|
||||||
->middleware('throttle:10,1');
|
->middleware('throttle:10,1');
|
||||||
Route::post('password/reset', ResetPasswordController::class)
|
Route::post('password/reset', ResetPasswordController::class)
|
||||||
|
->defaults('reset_role', 'scanner')
|
||||||
->middleware('throttle:5,1');
|
->middleware('throttle:5,1');
|
||||||
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
Route::middleware(['auth:sanctum', 'scanner.tenant'])
|
||||||
->get('me', ScannerMeController::class);
|
->get('me', ScannerMeController::class);
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AdminAppBootstrapResource extends JsonResource
|
|||||||
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||||
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||||
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminAppBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('dominio', $domain)
|
->where('dominio', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ScannerBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('scanner_domain', $domain)
|
->where('scanner_domain', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -14,14 +14,15 @@ class TenantBootstrapService
|
|||||||
|
|
||||||
public function get(string $domain, string $path = '/'): Tenant
|
public function get(string $domain, string $path = '/'): Tenant
|
||||||
{
|
{
|
||||||
$candidateKeys = TenantDomainNormalizer::tenantKeyCandidates($domain, $path);
|
$candidateBasePaths = TenantDomainNormalizer::basePathCandidates($path);
|
||||||
$tenantsByDomain = Tenant::query()
|
$tenantsByBasePath = Tenant::query()
|
||||||
->whereIn('dominio', $candidateKeys)
|
->where('dominio', $domain)
|
||||||
|
->whereIn('base_path', $candidateBasePaths)
|
||||||
->get()
|
->get()
|
||||||
->keyBy('dominio');
|
->keyBy('base_path');
|
||||||
|
|
||||||
$tenant = collect($candidateKeys)
|
$tenant = collect($candidateBasePaths)
|
||||||
->map(fn (string $candidate): ?Tenant => $tenantsByDomain->get($candidate))
|
->map(fn (string $candidate): ?Tenant => $tenantsByBasePath->get($candidate))
|
||||||
->first(fn (?Tenant $candidate): bool => $candidate !== null);
|
->first(fn (?Tenant $candidate): bool => $candidate !== null);
|
||||||
|
|
||||||
if (! $tenant instanceof Tenant) {
|
if (! $tenant instanceof Tenant) {
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ namespace App\Domains\Cart\Models;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
@@ -24,6 +27,9 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
'user_id',
|
'user_id',
|
||||||
'guest_token',
|
'guest_token',
|
||||||
'status',
|
'status',
|
||||||
|
'origin',
|
||||||
|
'current_purchase_id',
|
||||||
|
'current_stock_reservation_id',
|
||||||
])]
|
])]
|
||||||
class Cart extends Model
|
class Cart extends Model
|
||||||
{
|
{
|
||||||
@@ -32,10 +38,26 @@ class Cart extends Model
|
|||||||
|
|
||||||
protected $table = 'carritos';
|
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
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'current_purchase_id' => 'integer',
|
||||||
|
'current_stock_reservation_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,6 +85,27 @@ class Cart extends Model
|
|||||||
return $this->hasMany(CartItem::class, 'cart_id');
|
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
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
$items = $this->relationLoaded('items')
|
$items = $this->relationLoaded('items')
|
||||||
@@ -85,14 +128,20 @@ class Cart extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||||
self::query()->whereKey($this->getKey())->lockForUpdate()->firstOrFail();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||||
$cartQuantity = (int) $this->items()
|
$cartQuantity = (int) $this->items()
|
||||||
->where('catalog_item_id', $catalogItemId)
|
->where('catalog_item_id', $catalogItemId)
|
||||||
->sum('cantidad');
|
->sum('cantidad');
|
||||||
$this->assertUserPurchaseLimit($selectedItem, $cartQuantity + $quantity);
|
|
||||||
$inventoryService = app(CatalogInventoryService::class);
|
$inventoryService = app(CatalogInventoryService::class);
|
||||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||||
|
$this->assertUserPurchaseLimit(
|
||||||
|
$selectedItem,
|
||||||
|
$cartQuantity + $quantity,
|
||||||
|
heldQuantity: $cartQuantity,
|
||||||
|
maximumAddableCeiling: $availableQuantity,
|
||||||
|
);
|
||||||
|
|
||||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -118,7 +167,7 @@ class Cart extends Model
|
|||||||
$item->save();
|
$item->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
$inventoryService->reserve($selectedItem, $quantity);
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -129,6 +178,7 @@ class Cart extends Model
|
|||||||
int $quantity,
|
int $quantity,
|
||||||
?int $variantId = null,
|
?int $variantId = null,
|
||||||
bool $updateVariant = false,
|
bool $updateVariant = false,
|
||||||
|
?int $excludedPurchaseId = null,
|
||||||
): CartItem {
|
): CartItem {
|
||||||
if ($quantity <= 0) {
|
if ($quantity <= 0) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -141,7 +191,11 @@ class Cart extends Model
|
|||||||
$quantity,
|
$quantity,
|
||||||
$variantId,
|
$variantId,
|
||||||
$updateVariant,
|
$updateVariant,
|
||||||
|
$excludedPurchaseId,
|
||||||
): CartItem {
|
): CartItem {
|
||||||
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
|
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
->where('id', $cartItemId)
|
->where('id', $cartItemId)
|
||||||
@@ -165,12 +219,15 @@ class Cart extends Model
|
|||||||
->where('catalog_item_id', $item->catalog_item_id)
|
->where('catalog_item_id', $item->catalog_item_id)
|
||||||
->whereKeyNot($item->getKey())
|
->whereKeyNot($item->getKey())
|
||||||
->sum('cantidad');
|
->sum('cantidad');
|
||||||
|
$nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||||
$this->assertUserPurchaseLimit(
|
$this->assertUserPurchaseLimit(
|
||||||
$nextSelection,
|
$nextSelection,
|
||||||
$otherVariantsQuantity + $quantity,
|
$otherVariantsQuantity + $quantity,
|
||||||
|
$excludedPurchaseId,
|
||||||
|
$otherVariantsQuantity + $item->cantidad,
|
||||||
|
$nextAvailableQuantity,
|
||||||
);
|
);
|
||||||
|
|
||||||
$inventoryService->release($currentSelection, $item->cantidad);
|
|
||||||
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||||
|
|
||||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
@@ -186,12 +243,11 @@ class Cart extends Model
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
$inventoryService->reserve($nextSelection, $quantity);
|
|
||||||
|
|
||||||
if ($targetItem !== null) {
|
if ($targetItem !== null) {
|
||||||
$targetItem->cantidad += $quantity;
|
$targetItem->cantidad += $quantity;
|
||||||
$targetItem->save();
|
$targetItem->save();
|
||||||
$item->delete();
|
$item->delete();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $targetItem->fresh();
|
return $targetItem->fresh();
|
||||||
}
|
}
|
||||||
@@ -199,11 +255,13 @@ class Cart extends Model
|
|||||||
$item->variant_id = $variantId;
|
$item->variant_id = $variantId;
|
||||||
$item->cantidad = $quantity;
|
$item->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
$delta = $quantity - $item->cantidad;
|
$delta = $quantity - $item->cantidad;
|
||||||
|
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
|
||||||
|
|
||||||
if ($delta > 0) {
|
if ($delta > 0) {
|
||||||
$otherVariantsQuantity = (int) $this->items()
|
$otherVariantsQuantity = (int) $this->items()
|
||||||
@@ -213,11 +271,12 @@ class Cart extends Model
|
|||||||
$this->assertUserPurchaseLimit(
|
$this->assertUserPurchaseLimit(
|
||||||
$currentSelection,
|
$currentSelection,
|
||||||
$otherVariantsQuantity + $quantity,
|
$otherVariantsQuantity + $quantity,
|
||||||
|
$excludedPurchaseId,
|
||||||
|
$otherVariantsQuantity + $item->cantidad,
|
||||||
|
$availableQuantity,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
|
|
||||||
|
|
||||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -227,14 +286,7 @@ class Cart extends Model
|
|||||||
|
|
||||||
$item->cantidad = $quantity;
|
$item->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
if ($delta > 0) {
|
|
||||||
$inventoryService->reserve($currentSelection, $delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($delta < 0) {
|
|
||||||
$inventoryService->release($currentSelection, abs($delta));
|
|
||||||
}
|
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -243,25 +295,79 @@ class Cart extends Model
|
|||||||
public function removeItem(int $cartItemId): void
|
public function removeItem(int $cartItemId): void
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($cartItemId): void {
|
DB::transaction(function () use ($cartItemId): void {
|
||||||
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
|
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
->where('id', $cartItemId)
|
->where('id', $cartItemId)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$selectedItem = $this->resolveScopedItem(
|
|
||||||
$item->catalog_item_id,
|
|
||||||
$item->variant_id,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
app(CatalogInventoryService::class)->release(
|
|
||||||
$selectedItem,
|
|
||||||
$item->cantidad,
|
|
||||||
);
|
|
||||||
$item->delete();
|
$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(
|
protected function resolveScopedItem(
|
||||||
int $catalogItemId,
|
int $catalogItemId,
|
||||||
?int $variantId,
|
?int $variantId,
|
||||||
@@ -334,6 +440,9 @@ class Cart extends Model
|
|||||||
private function assertUserPurchaseLimit(
|
private function assertUserPurchaseLimit(
|
||||||
CatalogItem|Variant $selectedItem,
|
CatalogItem|Variant $selectedItem,
|
||||||
int $cartQuantity,
|
int $cartQuantity,
|
||||||
|
?int $excludedPurchaseId = null,
|
||||||
|
int $heldQuantity = 0,
|
||||||
|
?int $maximumAddableCeiling = null,
|
||||||
): void {
|
): void {
|
||||||
if ($this->user_id === null) {
|
if ($this->user_id === null) {
|
||||||
return;
|
return;
|
||||||
@@ -347,6 +456,10 @@ class Cart extends Model
|
|||||||
$catalogItem,
|
$catalogItem,
|
||||||
$this->user_id,
|
$this->user_id,
|
||||||
$cartQuantity,
|
$cartQuantity,
|
||||||
|
$excludedPurchaseId,
|
||||||
|
$this->getKey(),
|
||||||
|
$heldQuantity,
|
||||||
|
$maximumAddableCeiling,
|
||||||
field: 'cantidad',
|
field: 'cantidad',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace App\Domains\Cart\Resources;
|
|||||||
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -19,12 +21,16 @@ class CartItemResource extends JsonResource
|
|||||||
{
|
{
|
||||||
$selectedItem = $this->selectedItem();
|
$selectedItem = $this->selectedItem();
|
||||||
$imageUrl = null;
|
$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 ($selectedItem?->relationLoaded('attachments')) {
|
if ($displayImage && $selectedItem?->relationLoaded('attachments')) {
|
||||||
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
|
$imageUrl = $selectedItem->attachments->first()?->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
|
if ($displayImage && $imageUrl === null && $this->catalogItem?->relationLoaded('attachments')) {
|
||||||
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
|
$imageUrl = $this->catalogItem->attachments->first()?->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,14 +42,27 @@ class CartItemResource extends JsonResource
|
|||||||
'variant_id' => $this->variant_id,
|
'variant_id' => $this->variant_id,
|
||||||
'nombre' => $selectedItem?->getName(),
|
'nombre' => $selectedItem?->getName(),
|
||||||
'imagen' => $imageUrl,
|
'imagen' => $imageUrl,
|
||||||
'variant' => $this->variant === null ? null : [
|
'variant' => $this->variant === null ? null : $this->variantData($this->variant),
|
||||||
'id' => $this->variant->id,
|
'variants' => $this->when(
|
||||||
'precio' => $this->formatMoney($this->variant->getPrice()),
|
$includeVariants,
|
||||||
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
fn () => $this->catalogItem
|
||||||
? null
|
->visibleVariants($this->variant_id)
|
||||||
: $this->variant->inventory->availableStock(),
|
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||||
'values' => $this->variant->selectorOptions($this->catalogItem->itemAttributes),
|
->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),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ namespace App\Domains\Cart\Services;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
|
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
@@ -21,13 +25,13 @@ class CartService
|
|||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->loadCart($cart);
|
return $this->loadCart($cart, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,7 +50,7 @@ class CartService
|
|||||||
$cart->addItem($catalogItemId, $variantId, $quantity);
|
$cart->addItem($catalogItemId, $variantId, $quantity);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'cart' => $this->loadCart($cart),
|
'cart' => $this->loadCart($cart, $tenant),
|
||||||
'guest_token' => $resolvedIdentity['generated_guest_token'],
|
'guest_token' => $resolvedIdentity['generated_guest_token'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -59,34 +63,58 @@ class CartService
|
|||||||
?int $variantId,
|
?int $variantId,
|
||||||
bool $updateVariant,
|
bool $updateVariant,
|
||||||
): Cart {
|
): 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);
|
$identity = $this->requireIdentity($request);
|
||||||
$cart = $this->findCartOrFail($tenant, $identity);
|
$cart = $this->findCartOrFail($tenant, $identity);
|
||||||
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
|
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
|
||||||
|
|
||||||
return $this->loadCart($cart);
|
return $this->loadCart($cart, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function removeItem(Tenant $tenant, Request $request, int $cartItemId): Cart
|
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);
|
$identity = $this->requireIdentity($request);
|
||||||
$cart = $this->findCartOrFail($tenant, $identity);
|
$cart = $this->findCartOrFail($tenant, $identity);
|
||||||
$cart->removeItem($cartItemId);
|
$cart->removeItem($cartItemId);
|
||||||
|
|
||||||
return $this->loadCart($cart);
|
return $this->loadCart($cart, $tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function makeGuestTokenCookie(string $guestToken): Cookie
|
public function makeGuestTokenCookie(string $guestToken): Cookie
|
||||||
{
|
{
|
||||||
return cookie(
|
$secure = (bool) config('session.secure');
|
||||||
'guest_token',
|
$sameSite = config('session.same_site');
|
||||||
$guestToken,
|
$partitioned = (bool) config('session.partitioned')
|
||||||
60 * 24 * 180,
|
|| ($secure && strtolower((string) $sameSite) === 'none');
|
||||||
'/',
|
|
||||||
config('session.domain'),
|
return Cookie::create(
|
||||||
(bool) config('session.secure'),
|
name: 'guest_token',
|
||||||
true,
|
value: $guestToken,
|
||||||
false,
|
expire: now()->addDays(180),
|
||||||
config('session.same_site'),
|
path: '/',
|
||||||
|
domain: config('session.domain'),
|
||||||
|
secure: $secure,
|
||||||
|
httpOnly: true,
|
||||||
|
raw: false,
|
||||||
|
sameSite: $sameSite,
|
||||||
|
partitioned: $partitioned,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,7 +122,7 @@ class CartService
|
|||||||
{
|
{
|
||||||
$cart = new Cart([
|
$cart = new Cart([
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$cart->setRelation('items', collect());
|
$cart->setRelation('items', collect());
|
||||||
@@ -102,9 +130,9 @@ class CartService
|
|||||||
return $cart;
|
return $cart;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function loadCart(Cart $cart): Cart
|
protected function loadCart(Cart $cart, Tenant $tenant): Cart
|
||||||
{
|
{
|
||||||
return $cart->fresh()->load([
|
$relations = [
|
||||||
'items.catalogItem.attachments',
|
'items.catalogItem.attachments',
|
||||||
'items.catalogItem.inventory',
|
'items.catalogItem.inventory',
|
||||||
'items.catalogItem.itemAttributes.attribute',
|
'items.catalogItem.itemAttributes.attribute',
|
||||||
@@ -113,7 +141,21 @@ class CartService
|
|||||||
'items.variant.definitions.itemAttribute.attribute.options',
|
'items.variant.definitions.itemAttribute.attribute.options',
|
||||||
'items.variant.eventDates',
|
'items.variant.eventDates',
|
||||||
'items.variant.eventDate',
|
'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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -181,12 +223,14 @@ class CartService
|
|||||||
{
|
{
|
||||||
return Cart::query()
|
return Cart::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('status', 'active')
|
->where('origin', Cart::ORIGIN_USER)
|
||||||
|
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
|
||||||
->when(
|
->when(
|
||||||
$identity['user_id'] !== null,
|
$identity['user_id'] !== null,
|
||||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||||
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||||
)
|
)
|
||||||
|
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +239,7 @@ class CartService
|
|||||||
*/
|
*/
|
||||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$cart = $this->findCart($tenant, $identity);
|
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
throw new NotFoundHttpException('Cart not found.');
|
throw new NotFoundHttpException('Cart not found.');
|
||||||
@@ -208,10 +252,73 @@ class CartService
|
|||||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
*/
|
*/
|
||||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
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 = [
|
$attributes = [
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
|
'origin' => Cart::ORIGIN_USER,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($identity['user_id'] !== null) {
|
if ($identity['user_id'] !== null) {
|
||||||
|
|||||||
@@ -34,6 +34,10 @@ class GuestCartMergeService
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($userCart !== null) {
|
if ($userCart !== null) {
|
||||||
|
$userCart->items()
|
||||||
|
->orderBy('id')
|
||||||
|
->pluck('id')
|
||||||
|
->each(fn (int $itemId) => $userCart->removeItem($itemId));
|
||||||
$userCart->update([
|
$userCart->update([
|
||||||
'status' => 'converted',
|
'status' => 'converted',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
|
|||||||
|
|
||||||
## Modelo
|
## Modelo
|
||||||
|
|
||||||
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
|
- `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`; expone la selección efectiva.
|
- `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
|
## Servicios
|
||||||
|
|
||||||
@@ -30,3 +30,9 @@ Bajo `/tenants/{tenant:codigo}`:
|
|||||||
## Dependencias y reglas
|
## 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.
|
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.
|
||||||
|
|||||||
@@ -17,17 +17,20 @@ use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
|||||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
||||||
use App\Domains\Catalog\Resources\CatalogVariantOptionsResource;
|
use App\Domains\Catalog\Resources\CatalogVariantOptionsResource;
|
||||||
|
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
|
||||||
use App\Domains\Catalog\Services\CatalogService;
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Catalog\Services\FeaturedGroupService;
|
use App\Domains\Catalog\Services\FeaturedGroupService;
|
||||||
use App\Domains\Catalog\Services\VariantSelectionService;
|
use App\Domains\Catalog\Services\VariantSelectionService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
|
||||||
class CatalogController extends Controller
|
class CatalogController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
|
public function index(Request $request, Tenant $tenant, FeaturedGroupService $featuredGroupService): JsonResponse
|
||||||
{
|
{
|
||||||
$featuredGroups = FeaturedGroup::query()
|
$featuredGroups = FeaturedGroup::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
@@ -37,7 +40,7 @@ class CatalogController extends Controller
|
|||||||
return response()->json($featuredGroups->map(
|
return response()->json($featuredGroups->map(
|
||||||
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
fn (FeaturedGroup $featuredGroup): array => (new CatalogFeaturedGroupResource(
|
||||||
$featuredGroup,
|
$featuredGroup,
|
||||||
$featuredGroupService->itemsResponse($featuredGroup, 1),
|
$featuredGroupService->itemsResponse($featuredGroup, 1, $this->userId($request)),
|
||||||
))->resolve()
|
))->resolve()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -46,15 +49,17 @@ class CatalogController extends Controller
|
|||||||
SearchCatalogItemsRequest $request,
|
SearchCatalogItemsRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
CatalogService $catalogService,
|
CatalogService $catalogService,
|
||||||
|
CatalogItemAllowanceService $allowances,
|
||||||
): AnonymousResourceCollection {
|
): AnonymousResourceCollection {
|
||||||
return CatalogSearchItemResource::collection(
|
$items = $catalogService->search(
|
||||||
$catalogService->search(
|
$tenant,
|
||||||
$tenant,
|
$request->validated('q'),
|
||||||
$request->validated('q'),
|
$tenant->search_items_per_page,
|
||||||
$tenant->search_items_per_page,
|
(int) $request->validated('page', 1),
|
||||||
(int) $request->validated('page', 1),
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
$allowances->attach($items->getCollection(), $this->userId($request));
|
||||||
|
|
||||||
|
return CatalogSearchItemResource::collection($items);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function category(
|
public function category(
|
||||||
@@ -62,17 +67,19 @@ class CatalogController extends Controller
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
Category $category,
|
Category $category,
|
||||||
CatalogService $catalogService,
|
CatalogService $catalogService,
|
||||||
|
CatalogItemAllowanceService $allowances,
|
||||||
): AnonymousResourceCollection {
|
): AnonymousResourceCollection {
|
||||||
abort_unless($category->tenant_code === $tenant->codigo, 404);
|
abort_unless($category->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
return CatalogSearchItemResource::collection(
|
$items = $catalogService->categoryItems(
|
||||||
$catalogService->categoryItems(
|
$tenant,
|
||||||
$tenant,
|
$category,
|
||||||
$category,
|
$tenant->search_items_per_page,
|
||||||
$tenant->search_items_per_page,
|
(int) $request->validated('page', 1),
|
||||||
(int) $request->validated('page', 1),
|
);
|
||||||
)
|
$allowances->attach($items->getCollection(), $this->userId($request));
|
||||||
)->additional([
|
|
||||||
|
return CatalogSearchItemResource::collection($items)->additional([
|
||||||
'category' => [
|
'category' => [
|
||||||
'id' => $category->id,
|
'id' => $category->id,
|
||||||
'nombre' => $category->nombre,
|
'nombre' => $category->nombre,
|
||||||
@@ -93,7 +100,11 @@ class CatalogController extends Controller
|
|||||||
|
|
||||||
$page = (int) $request->validated('page', 1);
|
$page = (int) $request->validated('page', 1);
|
||||||
|
|
||||||
return response()->json($featuredGroupService->itemsResponse($featuredGroup, $page));
|
return response()->json($featuredGroupService->itemsResponse(
|
||||||
|
$featuredGroup,
|
||||||
|
$page,
|
||||||
|
$this->userId($request),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function show(
|
public function show(
|
||||||
@@ -101,17 +112,19 @@ class CatalogController extends Controller
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
CatalogItem $catalogItem,
|
CatalogItem $catalogItem,
|
||||||
CatalogService $catalogService,
|
CatalogService $catalogService,
|
||||||
|
CatalogItemAllowanceService $allowances,
|
||||||
): CatalogItemDetailResource {
|
): CatalogItemDetailResource {
|
||||||
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
|
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
$variantId = $request->validated('variant_id');
|
$variantId = $request->validated('variant_id');
|
||||||
|
|
||||||
return CatalogItemDetailResource::make(
|
$item = $catalogService->getDetail(
|
||||||
$catalogService->getDetail(
|
$catalogItem,
|
||||||
$catalogItem,
|
$variantId === null ? null : (int) $variantId,
|
||||||
$variantId === null ? null : (int) $variantId,
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
|
$allowances->attach(collect([$item]), $this->userId($request));
|
||||||
|
|
||||||
|
return CatalogItemDetailResource::make($item);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function variantOptions(
|
public function variantOptions(
|
||||||
@@ -164,4 +177,11 @@ class CatalogController extends Controller
|
|||||||
->response()
|
->response()
|
||||||
->setStatusCode(201);
|
->setStatusCode(201);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function userId(Request $request): ?int
|
||||||
|
{
|
||||||
|
$userId = $request->user()?->getAuthIdentifier() ?? Auth::guard('sanctum')->id();
|
||||||
|
|
||||||
|
return $userId === null ? null : (int) $userId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class StockReservationExpiredException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(__('api.cart.reservation_expired'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ use Illuminate\Support\Collection;
|
|||||||
'type',
|
'type',
|
||||||
'slug',
|
'slug',
|
||||||
'nombre',
|
'nombre',
|
||||||
|
'group_order',
|
||||||
'descripcion',
|
'descripcion',
|
||||||
'precio',
|
'precio',
|
||||||
'inventory_policy',
|
'inventory_policy',
|
||||||
@@ -47,6 +48,7 @@ class CatalogItem extends Model
|
|||||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
'inventory_subject' => InventorySubject::Product->value,
|
'inventory_subject' => InventorySubject::Product->value,
|
||||||
'has_tickets' => false,
|
'has_tickets' => false,
|
||||||
|
'group_order' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -56,6 +58,7 @@ class CatalogItem extends Model
|
|||||||
'brand_id' => 'integer',
|
'brand_id' => 'integer',
|
||||||
'inventory_id' => 'integer',
|
'inventory_id' => 'integer',
|
||||||
'type' => CatalogItemType::class,
|
'type' => CatalogItemType::class,
|
||||||
|
'group_order' => 'integer',
|
||||||
'precio' => 'decimal:2',
|
'precio' => 'decimal:2',
|
||||||
'inventory_policy' => InventoryPolicy::class,
|
'inventory_policy' => InventoryPolicy::class,
|
||||||
'inventory_subject' => InventorySubject::class,
|
'inventory_subject' => InventorySubject::class,
|
||||||
@@ -172,17 +175,29 @@ class CatalogItem extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param Builder<CatalogItem> $query */
|
/** @param Builder<CatalogItem> $query */
|
||||||
public function scopeWhereVariantsAvailable(Builder $query): Builder
|
public function scopeWhereAvailable(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where(function (Builder $query): void {
|
return $query->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->whereDoesntHave('variants')
|
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||||
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
|
||||||
->orWhereHas(
|
->orWhereHas(
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||||
);
|
)
|
||||||
|
->orWhere(function (Builder $directItemQuery): void {
|
||||||
|
$directItemQuery
|
||||||
|
->whereDoesntHave('variants')
|
||||||
|
->where(function (Builder $inventoryQuery): void {
|
||||||
|
$inventoryQuery
|
||||||
|
->whereNull('catalog_items.inventory_id')
|
||||||
|
->orWhereHas(
|
||||||
|
'inventory',
|
||||||
|
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
|
||||||
|
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'tenant_code',
|
'tenant_code',
|
||||||
'categoria_id',
|
'categoria_id',
|
||||||
'nombre',
|
'nombre',
|
||||||
|
'is_enabled',
|
||||||
])]
|
])]
|
||||||
class Category extends Model
|
class Category extends Model
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,10 @@ class Category extends Model
|
|||||||
|
|
||||||
protected $table = 'categorias';
|
protected $table = 'categorias';
|
||||||
|
|
||||||
|
protected $attributes = [
|
||||||
|
'is_enabled' => true,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -29,6 +34,7 @@ class Category extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'categoria_id' => 'integer',
|
'categoria_id' => 'integer',
|
||||||
|
'is_enabled' => 'boolean',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
|
'code',
|
||||||
'source_type',
|
'source_type',
|
||||||
'category_id',
|
'category_id',
|
||||||
'product_layout',
|
'product_layout',
|
||||||
@@ -33,6 +35,29 @@ class FeaturedGroup extends Model
|
|||||||
'source_type' => FeaturedGroupSource::Manual->value,
|
'source_type' => FeaturedGroupSource::Manual->value,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
protected static function booted(): void
|
||||||
|
{
|
||||||
|
static::creating(function (FeaturedGroup $featuredGroup): void {
|
||||||
|
if (filled($featuredGroup->code)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$baseCode = Str::slug($featuredGroup->group_name) ?: 'group';
|
||||||
|
$code = $baseCode;
|
||||||
|
$suffix = 2;
|
||||||
|
|
||||||
|
while (static::query()
|
||||||
|
->where('tenant_code', $featuredGroup->tenant_code)
|
||||||
|
->where('code', $code)
|
||||||
|
->exists()) {
|
||||||
|
$code = "{$baseCode}-{$suffix}";
|
||||||
|
$suffix++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$featuredGroup->code = $code;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models;
|
|||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -47,6 +48,12 @@ class Inventory extends Model
|
|||||||
return $this->hasOne(Variant::class);
|
return $this->hasOne(Variant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<StockReservationLine, $this> */
|
||||||
|
public function stockReservationLines(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(StockReservationLine::class);
|
||||||
|
}
|
||||||
|
|
||||||
public function availableStock(): int
|
public function availableStock(): int
|
||||||
{
|
{
|
||||||
return max(0, $this->real_stock - $this->reserved_stock);
|
return max(0, $this->real_stock - $this->reserved_stock);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'allow_multi_select',
|
'allow_multi_select',
|
||||||
'sort_order',
|
'sort_order',
|
||||||
'show_in_selector',
|
'show_in_selector',
|
||||||
|
'ticket_label',
|
||||||
])]
|
])]
|
||||||
class ItemAttribute extends Model
|
class ItemAttribute extends Model
|
||||||
{
|
{
|
||||||
|
|||||||
57
app/Domains/Catalog/Models/StockReservation.php
Normal file
57
app/Domains/Catalog/Models/StockReservation.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'committed_at',
|
||||||
|
'released_at',
|
||||||
|
'expired_at',
|
||||||
|
'release_reason',
|
||||||
|
])]
|
||||||
|
class StockReservation extends Model
|
||||||
|
{
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATUS_COMMITTED = 'committed';
|
||||||
|
|
||||||
|
public const STATUS_RELEASED = 'released';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'expires_at' => 'datetime',
|
||||||
|
'committed_at' => 'datetime',
|
||||||
|
'released_at' => 'datetime',
|
||||||
|
'expired_at' => 'datetime',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<StockReservationLine, $this> */
|
||||||
|
public function lines(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(StockReservationLine::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasOne<Cart, $this> */
|
||||||
|
public function currentCart(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasOne<Purchase, $this> */
|
||||||
|
public function purchase(): HasOne
|
||||||
|
{
|
||||||
|
return $this->hasOne(Purchase::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'stock_reservation_id',
|
||||||
|
'inventory_id',
|
||||||
|
'quantity',
|
||||||
|
'tracks_inventory',
|
||||||
|
])]
|
||||||
|
class StockReservationLine extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stock_reservation_id' => 'integer',
|
||||||
|
'inventory_id' => 'integer',
|
||||||
|
'quantity' => 'integer',
|
||||||
|
'tracks_inventory' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
|
public function reservation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(StockReservation::class, 'stock_reservation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Inventory, $this> */
|
||||||
|
public function inventory(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Inventory::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ class StoreCatalogItemRequest extends FormRequest
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
|
'group_order' => ['sometimes', 'integer', 'min:0'],
|
||||||
'descripcion' => ['sometimes', 'nullable', 'string'],
|
'descripcion' => ['sometimes', 'nullable', 'string'],
|
||||||
'precio' => ['required', 'numeric', 'min:0'],
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ class OnTicketFeaturedGroupResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
|
'code' => $this->code,
|
||||||
'category_id' => $this->category_id,
|
'category_id' => $this->category_id,
|
||||||
'category_name' => $this->category->nombre,
|
'category_name' => $this->category->nombre,
|
||||||
'group_name' => $this->group_name,
|
'group_name' => $this->group_name,
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CatalogFeaturedGroupResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
|
'code' => $this->code,
|
||||||
'title' => $this->group_name,
|
'title' => $this->group_name,
|
||||||
'layout' => $this->product_layout->value,
|
'layout' => $this->product_layout->value,
|
||||||
'group_layout' => $this->group_layout->value,
|
'group_layout' => $this->group_layout->value,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\ProductLayout;
|
|||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -28,27 +29,48 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
return $this->ticketSelectorData($catalogItem);
|
return $this->ticketSelectorData($catalogItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
|
||||||
|
$availableStock = $catalogItem->availableStock();
|
||||||
|
|
||||||
$data = [
|
$data = [
|
||||||
'id' => $catalogItem->id,
|
'id' => $catalogItem->id,
|
||||||
'type' => $catalogItem->type->value,
|
'type' => $catalogItem->type->value,
|
||||||
'nombre' => $catalogItem->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'descripcion' => $catalogItem->descripcion,
|
'descripcion' => $catalogItem->descripcion,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'stock_tecnico' => $catalogItem->availableStock(),
|
'maximum_addable_quantity' => $this->maximumAddable(
|
||||||
|
$availableStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->unavailableMessage(
|
||||||
|
$availableStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
'variants' => $catalogItem->visibleVariants()
|
'variants' => $catalogItem->visibleVariants()
|
||||||
->map(fn (Variant $variant): array => [
|
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
|
||||||
'id' => $variant->id,
|
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
'event_date_id' => $variant->event_date_id,
|
|
||||||
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
|
||||||
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
|
||||||
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
|
||||||
'descripcion' => $variant->getDescription(),
|
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
|
||||||
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
|
||||||
? null
|
? null
|
||||||
: $variant->inventory->availableStock(),
|
: $variant->inventory->availableStock();
|
||||||
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
|
||||||
])
|
return [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
|
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
||||||
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
|
'descripcion' => $variant->getDescription(),
|
||||||
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
|
'maximum_addable_quantity' => $this->maximumAddable(
|
||||||
|
$variantStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->unavailableMessage(
|
||||||
|
$variantStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
||||||
|
];
|
||||||
|
})
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -58,6 +80,9 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
private function ticketSelectorData(CatalogItem $catalogItem): array
|
private function ticketSelectorData(CatalogItem $catalogItem): array
|
||||||
{
|
{
|
||||||
|
$availableStock = $catalogItem->availableStock();
|
||||||
|
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $catalogItem->id,
|
'id' => $catalogItem->id,
|
||||||
'type' => $catalogItem->type->value,
|
'type' => $catalogItem->type->value,
|
||||||
@@ -65,28 +90,47 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'descripcion' => $catalogItem->descripcion,
|
'descripcion' => $catalogItem->descripcion,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'image' => $this->firstImageUrl($catalogItem),
|
'image' => $this->firstImageUrl($catalogItem),
|
||||||
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
private function columnWithImageData(CatalogItem $catalogItem): array
|
private function columnWithImageData(CatalogItem $catalogItem): array
|
||||||
{
|
{
|
||||||
|
$availableStock = $catalogItem->availableStock();
|
||||||
|
$remainingUserQuota = $catalogItem->getAttribute('remaining_user_quota');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $catalogItem->id,
|
'id' => $catalogItem->id,
|
||||||
'type' => $catalogItem->type->value,
|
'type' => $catalogItem->type->value,
|
||||||
'nombre' => $catalogItem->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'image' => $this->firstImageUrl($catalogItem),
|
'image' => $this->firstImageUrl($catalogItem),
|
||||||
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
||||||
{
|
{
|
||||||
$attachment = $catalogItem->attachments->first()
|
$attachment = $catalogItem->attachments->first()
|
||||||
?? $catalogItem->variants
|
?? $catalogItem->visibleVariants()
|
||||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
return $attachment?->getTemporaryUrl(1440);
|
return $attachment?->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)
|
||||||
|
->maximumAddableQuantity($stock, $remainingUserQuota);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)
|
||||||
|
->unavailableMessage($stock, $remainingUserQuota);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use App\Domains\Catalog\Enums\InventoryPolicy;
|
|||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\ItemAttribute;
|
use App\Domains\Catalog\Models\ItemAttribute;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
|
||||||
use App\Domains\Shared\Enums\FieldType;
|
use App\Domains\Shared\Enums\FieldType;
|
||||||
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
use App\Domains\Ticket\Resources\ValidityTimeResource;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -37,15 +38,19 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
'max_units_per_user' => $this->max_units_per_user,
|
'max_units_per_user' => $this->max_units_per_user,
|
||||||
'has_tickets' => $this->has_tickets,
|
'has_tickets' => $this->has_tickets,
|
||||||
'attributes' => $this->attributesData(),
|
'attributes' => $this->attributesData(),
|
||||||
'stock_tecnico' => $this->when(
|
'maximum_addable_quantity' => $this->when(
|
||||||
$selectedVariant === null,
|
$selectedVariant === null,
|
||||||
fn () => $this->availableStock(),
|
fn () => $this->maximumAddable($this->availableStock()),
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->when(
|
||||||
|
$selectedVariant === null,
|
||||||
|
fn () => $this->unavailableMessage($this->availableStock()),
|
||||||
),
|
),
|
||||||
'images' => $this->when(
|
'images' => $this->when(
|
||||||
$selectedVariant === null,
|
$selectedVariant === null,
|
||||||
fn () => $this->imageUrls($this->attachments),
|
fn () => $this->imageUrls($this->attachments),
|
||||||
),
|
),
|
||||||
'variants' => $this->visibleVariants()
|
'variants' => $this->variants
|
||||||
->map(fn (Variant $variant): array => $this->variantData($variant))
|
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||||
->values(),
|
->values(),
|
||||||
'selected_variant' => $this->when(
|
'selected_variant' => $this->when(
|
||||||
@@ -85,6 +90,7 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
'nombre' => $attribute->nombre,
|
'nombre' => $attribute->nombre,
|
||||||
'sort_order' => $itemAttribute->sort_order,
|
'sort_order' => $itemAttribute->sort_order,
|
||||||
'show_in_selector' => $itemAttribute->show_in_selector,
|
'show_in_selector' => $itemAttribute->show_in_selector,
|
||||||
|
'ticket_label' => $itemAttribute->ticket_label,
|
||||||
'is_required' => $attribute->is_required,
|
'is_required' => $attribute->is_required,
|
||||||
'allow_multi_select' => $itemAttribute->allow_multi_select,
|
'allow_multi_select' => $itemAttribute->allow_multi_select,
|
||||||
'metadata_schema' => $attribute->metadata_schema,
|
'metadata_schema' => $attribute->metadata_schema,
|
||||||
@@ -157,6 +163,7 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
{
|
{
|
||||||
$values = $variant->selectionOptions($this->itemAttributes);
|
$values = $variant->selectionOptions($this->itemAttributes);
|
||||||
$eventDates = $variant->selectedEventDates();
|
$eventDates = $variant->selectedEventDates();
|
||||||
|
$variantStock = $this->variantStock($variant);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
@@ -166,7 +173,8 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'stock_tecnico' => $this->variantStock($variant),
|
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($variantStock),
|
||||||
'values' => $values,
|
'values' => $values,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -185,4 +193,20 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
? null
|
? null
|
||||||
: $variant->inventory->availableStock();
|
: $variant->inventory->availableStock();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function maximumAddable(?int $stock): ?int
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
|
||||||
|
$stock,
|
||||||
|
$this->getAttribute('remaining_user_quota'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)->unavailableMessage(
|
||||||
|
$stock,
|
||||||
|
$this->getAttribute('remaining_user_quota'),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Resources;
|
|||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -14,8 +15,10 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$availableStock = $this->availableStock();
|
||||||
|
$visibleVariants = $this->visibleVariants();
|
||||||
$attachment = $this->attachments->first()
|
$attachment = $this->attachments->first()
|
||||||
?? $this->variants
|
?? $visibleVariants
|
||||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -26,22 +29,44 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
'descripcion' => $this->descripcion,
|
'descripcion' => $this->descripcion,
|
||||||
'precio' => $this->precio,
|
'precio' => $this->precio,
|
||||||
'image' => $attachment?->getTemporaryUrl(1440),
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
'stock_tecnico' => $this->availableStock(),
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
|
||||||
'variants' => $this->visibleVariants()
|
'unavailable_message' => $this->unavailableMessage($availableStock),
|
||||||
->map(fn (Variant $variant): array => [
|
'variants' => $visibleVariants
|
||||||
'id' => $variant->id,
|
->map(function (Variant $variant): array {
|
||||||
'event_date_id' => $variant->event_date_id,
|
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
|
||||||
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
|
||||||
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
|
||||||
'descripcion' => $variant->getDescription(),
|
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
|
||||||
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
|
|
||||||
? null
|
? null
|
||||||
: $variant->inventory?->availableStock(),
|
: $variant->inventory?->availableStock();
|
||||||
'values' => $variant->selectorOptions($this->itemAttributes),
|
|
||||||
])
|
return [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'event_date_id' => $variant->event_date_id,
|
||||||
|
'event_date' => $variant->eventDate?->date?->format('Y-m-d'),
|
||||||
|
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
||||||
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
|
'descripcion' => $variant->getDescription(),
|
||||||
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
|
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($variantStock),
|
||||||
|
'values' => $variant->selectorOptions($this->itemAttributes),
|
||||||
|
];
|
||||||
|
})
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function maximumAddable(?int $stock): ?int
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
|
||||||
|
$stock,
|
||||||
|
$this->getAttribute('remaining_user_quota'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)->unavailableMessage(
|
||||||
|
$stock,
|
||||||
|
$this->getAttribute('remaining_user_quota'),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,37 @@ use Illuminate\Support\Facades\DB;
|
|||||||
|
|
||||||
class CatalogInventoryService
|
class CatalogInventoryService
|
||||||
{
|
{
|
||||||
|
/** @return array<int, int> */
|
||||||
|
public function requirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
|
||||||
|
{
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (array $requirement): int => $requirement['quantity'] * $quantity,
|
||||||
|
$this->inventoryRequirements($selection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||||
|
*/
|
||||||
|
public function detailedRequirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
|
||||||
|
{
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (array $requirement): array => [
|
||||||
|
...$requirement,
|
||||||
|
'quantity' => $requirement['quantity'] * $quantity,
|
||||||
|
],
|
||||||
|
$this->inventoryRequirements($selection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||||
{
|
{
|
||||||
if ($selection instanceof CatalogItem
|
if ($selection instanceof CatalogItem
|
||||||
|
|||||||
57
app/Domains/Catalog/Services/CatalogItemAllowanceService.php
Normal file
57
app/Domains/Catalog/Services/CatalogItemAllowanceService.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class CatalogItemAllowanceService
|
||||||
|
{
|
||||||
|
private const USER_QUOTA_REACHED_MESSAGE = 'Alcanzaste el cupo máximo permitido para este producto.';
|
||||||
|
|
||||||
|
private const OUT_OF_STOCK_MESSAGE = 'Este producto no tiene stock disponible.';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly UserPurchaseLimitService $purchaseLimits,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @param Collection<int, CatalogItem> $catalogItems */
|
||||||
|
public function attach(Collection $catalogItems, ?int $userId): void
|
||||||
|
{
|
||||||
|
$remaining = $this->purchaseLimits->remainingByCatalogItem($catalogItems, $userId);
|
||||||
|
|
||||||
|
foreach ($catalogItems as $catalogItem) {
|
||||||
|
$catalogItem->setAttribute(
|
||||||
|
'remaining_user_quota',
|
||||||
|
$remaining->get($catalogItem->getKey()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function maximumAddableQuantity(?int $availableStock, ?int $remainingUserQuota): ?int
|
||||||
|
{
|
||||||
|
if ($availableStock === null) {
|
||||||
|
return $remainingUserQuota;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($remainingUserQuota === null) {
|
||||||
|
return $availableStock;
|
||||||
|
}
|
||||||
|
|
||||||
|
return min($availableStock, $remainingUserQuota);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
|
||||||
|
{
|
||||||
|
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
|
||||||
|
return self::USER_QUOTA_REACHED_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($availableStock !== null && $availableStock <= 0) {
|
||||||
|
return self::OUT_OF_STOCK_MESSAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,6 +38,7 @@ class CatalogService
|
|||||||
$attributeCodes = $data['attribute_codes'] ?? [];
|
$attributeCodes = $data['attribute_codes'] ?? [];
|
||||||
$multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? [];
|
$multiSelectAttributeCodes = $data['multi_select_attribute_codes'] ?? [];
|
||||||
$hiddenAttributeCodes = $data['hidden_attribute_codes'] ?? [];
|
$hiddenAttributeCodes = $data['hidden_attribute_codes'] ?? [];
|
||||||
|
$ticketAttributeLabels = $data['ticket_attribute_labels'] ?? [];
|
||||||
$components = $data['components'] ?? [];
|
$components = $data['components'] ?? [];
|
||||||
$hasDirectStock = array_key_exists('real_stock', $data);
|
$hasDirectStock = array_key_exists('real_stock', $data);
|
||||||
$realStock = (int) ($data['real_stock'] ?? 0);
|
$realStock = (int) ($data['real_stock'] ?? 0);
|
||||||
@@ -73,6 +74,14 @@ class CatalogService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (array_diff(array_keys($ticketAttributeLabels), $attributeCodes) !== []) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'ticket_attribute_labels' => [
|
||||||
|
__('api.catalog.ticket_label_attribute_not_on_item'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
|
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
|
||||||
|
|
||||||
if ($type === CatalogItemType::Bundle) {
|
if ($type === CatalogItemType::Bundle) {
|
||||||
@@ -98,6 +107,7 @@ class CatalogService
|
|||||||
$data['attribute_codes'],
|
$data['attribute_codes'],
|
||||||
$data['multi_select_attribute_codes'],
|
$data['multi_select_attribute_codes'],
|
||||||
$data['hidden_attribute_codes'],
|
$data['hidden_attribute_codes'],
|
||||||
|
$data['ticket_attribute_labels'],
|
||||||
$data['components'],
|
$data['components'],
|
||||||
$data['real_stock'],
|
$data['real_stock'],
|
||||||
$data['reserved_stock'],
|
$data['reserved_stock'],
|
||||||
@@ -124,6 +134,7 @@ class CatalogService
|
|||||||
$attributeCodes,
|
$attributeCodes,
|
||||||
$multiSelectAttributeCodes,
|
$multiSelectAttributeCodes,
|
||||||
$hiddenAttributeCodes,
|
$hiddenAttributeCodes,
|
||||||
|
$ticketAttributeLabels,
|
||||||
)
|
)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
@@ -194,6 +205,13 @@ class CatalogService
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$visibleVariants = $catalogItem->visibleVariants();
|
$visibleVariants = $catalogItem->visibleVariants();
|
||||||
|
if ($catalogItem->type === CatalogItemType::Standard
|
||||||
|
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
|
||||||
|
&& ! $catalogItem->isAvailable()) {
|
||||||
|
throw new NotFoundHttpException('Catalog item is out of stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$catalogItem->setRelation('variants', $visibleVariants);
|
||||||
$selectedVariant = $variantId === null
|
$selectedVariant = $variantId === null
|
||||||
? $visibleVariants->first()
|
? $visibleVariants->first()
|
||||||
: $visibleVariants->firstWhere('id', $variantId);
|
: $visibleVariants->firstWhere('id', $variantId);
|
||||||
@@ -220,7 +238,7 @@ class CatalogService
|
|||||||
|
|
||||||
$paginator = CatalogItem::query()
|
$paginator = CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->whereVariantsAvailable()
|
->whereAvailable()
|
||||||
->where(function (Builder $query) use ($containsPattern): void {
|
->where(function (Builder $query) use ($containsPattern): void {
|
||||||
$query
|
$query
|
||||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
@@ -270,7 +288,7 @@ class CatalogService
|
|||||||
return CatalogItem::query()
|
return CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('category_id', $category->id)
|
->where('category_id', $category->id)
|
||||||
->whereVariantsAvailable()
|
->whereAvailable()
|
||||||
->with([
|
->with([
|
||||||
'attachments',
|
'attachments',
|
||||||
'inventory',
|
'inventory',
|
||||||
@@ -461,6 +479,7 @@ class CatalogService
|
|||||||
* @param array<int, string> $attributeCodes
|
* @param array<int, string> $attributeCodes
|
||||||
* @param array<int, string> $multiSelectAttributeCodes
|
* @param array<int, string> $multiSelectAttributeCodes
|
||||||
* @param array<int, string> $hiddenAttributeCodes
|
* @param array<int, string> $hiddenAttributeCodes
|
||||||
|
* @param array<string, string> $ticketAttributeLabels
|
||||||
* @return array<string, ItemAttribute>
|
* @return array<string, ItemAttribute>
|
||||||
*/
|
*/
|
||||||
private function createItemAttributes(
|
private function createItemAttributes(
|
||||||
@@ -468,6 +487,7 @@ class CatalogService
|
|||||||
array $attributeCodes,
|
array $attributeCodes,
|
||||||
array $multiSelectAttributeCodes = [],
|
array $multiSelectAttributeCodes = [],
|
||||||
array $hiddenAttributeCodes = [],
|
array $hiddenAttributeCodes = [],
|
||||||
|
array $ticketAttributeLabels = [],
|
||||||
): array {
|
): array {
|
||||||
$itemAttributes = [];
|
$itemAttributes = [];
|
||||||
$attributeCodes = array_values(array_unique($attributeCodes));
|
$attributeCodes = array_values(array_unique($attributeCodes));
|
||||||
@@ -492,6 +512,7 @@ class CatalogService
|
|||||||
'attribute_id' => $attribute->id,
|
'attribute_id' => $attribute->id,
|
||||||
'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true),
|
'allow_multi_select' => in_array($attributeCode, $multiSelectAttributeCodes, true),
|
||||||
'show_in_selector' => ! in_array($attributeCode, $hiddenAttributeCodes, true),
|
'show_in_selector' => ! in_array($attributeCode, $hiddenAttributeCodes, true),
|
||||||
|
'ticket_label' => $ticketAttributeLabels[$attributeCode] ?? null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$itemAttributes[$attributeCode] = $itemAttribute;
|
$itemAttributes[$attributeCode] = $itemAttribute;
|
||||||
|
|||||||
185
app/Domains/Catalog/Services/ExpireStockReservationsService.php
Normal file
185
app/Domains/Catalog/Services/ExpireStockReservationsService.php
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class ExpireStockReservationsService
|
||||||
|
{
|
||||||
|
private const BATCH_SIZE = 500;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ReleaseCheckoutService $purchases,
|
||||||
|
private readonly StockReservationService $reservations,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
||||||
|
*/
|
||||||
|
public function expireOverdue(): array
|
||||||
|
{
|
||||||
|
$summary = [
|
||||||
|
'purchases' => 0,
|
||||||
|
'cart_reservations' => 0,
|
||||||
|
'orphan_reservations' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
$lastReservationId = 0;
|
||||||
|
|
||||||
|
do {
|
||||||
|
$reservationIds = StockReservation::query()
|
||||||
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->where('id', '>', $lastReservationId)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(self::BATCH_SIZE)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
|
foreach ($reservationIds as $reservationId) {
|
||||||
|
$lastReservationId = (int) $reservationId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$owner = $this->expireReservation($lastReservationId);
|
||||||
|
if ($owner !== null) {
|
||||||
|
$summary[$owner]++;
|
||||||
|
}
|
||||||
|
} catch (Throwable $exception) {
|
||||||
|
$summary['failed']++;
|
||||||
|
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
|
||||||
|
'command' => 'reservations:expire',
|
||||||
|
'stock_reservation_id' => $lastReservationId,
|
||||||
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while ($reservationIds->count() === self::BATCH_SIZE);
|
||||||
|
|
||||||
|
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||||
|
'command' => 'reservations:expire',
|
||||||
|
'expired_purchases' => $summary['purchases'],
|
||||||
|
'expired_cart_reservations' => $summary['cart_reservations'],
|
||||||
|
'expired_orphan_reservations' => $summary['orphan_reservations'],
|
||||||
|
'failed_reservations' => $summary['failed'],
|
||||||
|
'total_expired' => $summary['purchases']
|
||||||
|
+ $summary['cart_reservations']
|
||||||
|
+ $summary['orphan_reservations'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expireIfOverdue(int $reservationId): bool
|
||||||
|
{
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->find($reservationId);
|
||||||
|
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expireReservation($reservationId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
|
||||||
|
private function expireReservation(int $reservationId): ?string
|
||||||
|
{
|
||||||
|
$purchaseId = Purchase::query()
|
||||||
|
->where('stock_reservation_id', $reservationId)
|
||||||
|
->value('id');
|
||||||
|
if ($purchaseId !== null) {
|
||||||
|
return $this->expirePurchase((int) $purchaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartId = Cart::query()
|
||||||
|
->where('current_stock_reservation_id', $reservationId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->value('id');
|
||||||
|
if ($cartId !== null) {
|
||||||
|
return $this->expireCart((int) $cartId, $reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expireOrphan($reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'purchases'|null */
|
||||||
|
private function expirePurchase(int $purchaseId): ?string
|
||||||
|
{
|
||||||
|
/** @var Purchase|null $purchase */
|
||||||
|
$purchase = Purchase::query()->find($purchaseId);
|
||||||
|
if ($purchase === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase = $this->purchases->expire($purchase);
|
||||||
|
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||||
|
return 'purchases';
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation = $purchase->stockReservation;
|
||||||
|
if ($this->isOverdue($reservation)) {
|
||||||
|
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'cart_reservations'|null */
|
||||||
|
private function expireCart(int $cartId, int $reservationId): ?string
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($cartId, $reservationId): ?string {
|
||||||
|
/** @var Cart|null $cart */
|
||||||
|
$cart = Cart::query()
|
||||||
|
->whereKey($cartId)
|
||||||
|
->where('current_stock_reservation_id', $reservationId)
|
||||||
|
->where('status', Cart::STATUS_ACTIVE)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
if ($cart === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reservations->expire($reservation);
|
||||||
|
$cart->update(['status' => Cart::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
return 'cart_reservations';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'orphan_reservations'|null */
|
||||||
|
private function expireOrphan(int $reservationId): ?string
|
||||||
|
{
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->find($reservationId);
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reservations->expire($reservation);
|
||||||
|
|
||||||
|
return 'orphan_reservations';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isOverdue(?StockReservation $reservation): bool
|
||||||
|
{
|
||||||
|
return $reservation !== null
|
||||||
|
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||||
|
&& $reservation->expires_at !== null
|
||||||
|
&& ! $reservation->expires_at->isFuture();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,7 +15,11 @@ class FeaturedGroupService
|
|||||||
private const ITEMS_PER_PAGE = 12;
|
private const ITEMS_PER_PAGE = 12;
|
||||||
|
|
||||||
/** @return array<array-key, mixed> */
|
/** @return array<array-key, mixed> */
|
||||||
public function itemsResponse(FeaturedGroup $featuredGroup, int $page): array
|
public function __construct(
|
||||||
|
private readonly CatalogItemAllowanceService $allowances,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
|
||||||
{
|
{
|
||||||
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
|
if ($featuredGroup->group_layout !== GroupLayout::Paginated) {
|
||||||
$query = $this->itemsQuery($featuredGroup);
|
$query = $this->itemsQuery($featuredGroup);
|
||||||
@@ -26,12 +30,14 @@ class FeaturedGroupService
|
|||||||
|
|
||||||
$items = $query->get();
|
$items = $query->get();
|
||||||
$this->attachGroup($items, $featuredGroup);
|
$this->attachGroup($items, $featuredGroup);
|
||||||
|
$this->allowances->attach($items, $userId);
|
||||||
|
|
||||||
return CatalogFeaturedItemResource::collection($items)->resolve();
|
return CatalogFeaturedItemResource::collection($items)->resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
$paginator = $this->paginateItems($featuredGroup, $page);
|
$paginator = $this->paginateItems($featuredGroup, $page);
|
||||||
$this->attachGroup($paginator->getCollection(), $featuredGroup);
|
$this->attachGroup($paginator->getCollection(), $featuredGroup);
|
||||||
|
$this->allowances->attach($paginator->getCollection(), $userId);
|
||||||
|
|
||||||
return CatalogFeaturedItemResource::collection($paginator)
|
return CatalogFeaturedItemResource::collection($paginator)
|
||||||
->response()
|
->response()
|
||||||
@@ -43,7 +49,16 @@ class FeaturedGroupService
|
|||||||
{
|
{
|
||||||
$query = CatalogItem::query()
|
$query = CatalogItem::query()
|
||||||
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||||
->whereVariantsAvailable()
|
->whereAvailable()
|
||||||
|
->where(function (Builder $query): void {
|
||||||
|
$query
|
||||||
|
->whereDoesntHave('category')
|
||||||
|
->orWhereHas(
|
||||||
|
'category',
|
||||||
|
fn (Builder $categoryQuery): Builder => $categoryQuery
|
||||||
|
->where('is_enabled', true),
|
||||||
|
);
|
||||||
|
})
|
||||||
->with([
|
->with([
|
||||||
'inventory',
|
'inventory',
|
||||||
'attachments',
|
'attachments',
|
||||||
@@ -67,7 +82,9 @@ class FeaturedGroupService
|
|||||||
FeaturedGroupSource::Category => $query
|
FeaturedGroupSource::Category => $query
|
||||||
->where('catalog_items.category_id', $featuredGroup->category_id)
|
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||||
->orderBy('catalog_items.id'),
|
->orderBy('catalog_items.id'),
|
||||||
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
|
FeaturedGroupSource::All => $query
|
||||||
|
->orderBy('catalog_items.group_order')
|
||||||
|
->orderBy('catalog_items.id'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
493
app/Domains/Catalog/Services/StockReservationService.php
Normal file
493
app/Domains/Catalog/Services/StockReservationService.php
Normal file
@@ -0,0 +1,493 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Catalog\Models\StockReservationLine;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class StockReservationService
|
||||||
|
{
|
||||||
|
public const REASON_CART_EMPTY = 'cart_empty';
|
||||||
|
|
||||||
|
public const REASON_CART_CHANGED = 'cart_changed';
|
||||||
|
|
||||||
|
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
|
||||||
|
|
||||||
|
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
|
||||||
|
|
||||||
|
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
|
||||||
|
|
||||||
|
public const REASON_MANUAL_RELEASE = 'manual_release';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly CatalogInventoryService $inventory,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function syncCart(Cart $cart): ?StockReservation
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($cart): ?StockReservation {
|
||||||
|
/** @var Cart $lockedCart */
|
||||||
|
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
|
$this->loadSelections($items);
|
||||||
|
$requirements = $this->requirementsForItems($items);
|
||||||
|
|
||||||
|
$reservation = $lockedCart->current_stock_reservation_id === null
|
||||||
|
? null
|
||||||
|
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||||
|
|
||||||
|
if ($reservation !== null) {
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($requirements === []) {
|
||||||
|
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
|
||||||
|
$this->finalizeLocked(
|
||||||
|
$reservation,
|
||||||
|
StockReservation::STATUS_RELEASED,
|
||||||
|
self::REASON_CART_EMPTY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedCart->update(['current_stock_reservation_id' => null]);
|
||||||
|
$cart->current_stock_reservation_id = null;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reservation === null) {
|
||||||
|
$reservation = StockReservation::query()->create([
|
||||||
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
]);
|
||||||
|
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
|
||||||
|
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$currentLines = StockReservationLine::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
|
->orderBy('inventory_id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('inventory_id');
|
||||||
|
$inventoryIds = collect(array_keys($requirements))
|
||||||
|
->merge($currentLines->keys())
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->unique()
|
||||||
|
->sort()
|
||||||
|
->values();
|
||||||
|
$inventories = Inventory::query()
|
||||||
|
->whereKey($inventoryIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
|
foreach ($inventoryIds as $inventoryId) {
|
||||||
|
$inventory = $inventories->get($inventoryId)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario requerido.');
|
||||||
|
$previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0);
|
||||||
|
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||||
|
$delta = $required - $previous;
|
||||||
|
|
||||||
|
if ($delta > 0
|
||||||
|
&& $requirements[$inventoryId]['tracks_inventory']
|
||||||
|
&& $inventory->availableStock() < $delta) {
|
||||||
|
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($delta < 0 && $inventory->reserved_stock < abs($delta)) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($inventoryIds as $inventoryId) {
|
||||||
|
/** @var Inventory $inventory */
|
||||||
|
$inventory = $inventories->get($inventoryId);
|
||||||
|
$line = $currentLines->get($inventoryId);
|
||||||
|
$previous = (int) ($line?->quantity ?? 0);
|
||||||
|
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||||
|
$delta = $required - $previous;
|
||||||
|
|
||||||
|
if ($delta > 0) {
|
||||||
|
$inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']);
|
||||||
|
} elseif ($delta < 0) {
|
||||||
|
$inventory->release(abs($delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($required === 0) {
|
||||||
|
$line?->delete();
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
StockReservationLine::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'stock_reservation_id' => $reservation->getKey(),
|
||||||
|
'inventory_id' => $inventoryId,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'quantity' => $required,
|
||||||
|
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
'release_reason' => null,
|
||||||
|
]);
|
||||||
|
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachToPurchase(
|
||||||
|
Cart $cart,
|
||||||
|
Purchase $purchase,
|
||||||
|
Carbon $expiresAt,
|
||||||
|
): StockReservation {
|
||||||
|
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
|
||||||
|
/** @var Cart $lockedCart */
|
||||||
|
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
/** @var Purchase $lockedPurchase */
|
||||||
|
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if ($lockedCart->current_stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
|
||||||
|
$linkedPurchase = Purchase::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
|
->whereKeyNot($lockedPurchase->getKey())
|
||||||
|
->exists();
|
||||||
|
if ($linkedPurchase) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
|
||||||
|
$reservation->update(['expires_at' => $expiresAt]);
|
||||||
|
$purchase->stock_reservation_id = $reservation->getKey();
|
||||||
|
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($purchase): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id);
|
||||||
|
if ($reservation->status === StockReservation::STATUS_COMMITTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||||
|
}
|
||||||
|
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $this->lockLines($reservation);
|
||||||
|
if ($lines->isEmpty()) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventories = $this->lockInventories($lines);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventory = $inventories->get($line->inventory_id)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||||
|
if ($inventory->reserved_stock < $line->quantity
|
||||||
|
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventories->get($line->inventory_id)->buy(
|
||||||
|
(int) $line->quantity,
|
||||||
|
(bool) $line->tracks_inventory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'status' => StockReservation::STATUS_COMMITTED,
|
||||||
|
'expires_at' => null,
|
||||||
|
'committed_at' => now(),
|
||||||
|
'released_at' => null,
|
||||||
|
'expired_at' => null,
|
||||||
|
'release_reason' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseForPurchase(
|
||||||
|
Purchase $purchase,
|
||||||
|
string $status = StockReservation::STATUS_RELEASED,
|
||||||
|
?string $reason = null,
|
||||||
|
): void {
|
||||||
|
DB::transaction(function () use ($purchase, $status, $reason): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
|
||||||
|
if ($reservation !== null) {
|
||||||
|
$this->finalizeLocked($reservation, $status, $reason);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($purchase, $cart): StockReservation {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
/** @var Cart $cart */
|
||||||
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
|
||||||
|
if ($purchase->stock_reservation_id === null
|
||||||
|
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
|
||||||
|
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
|
||||||
|
$purchase->update(['stock_reservation_id' => null]);
|
||||||
|
$cart->update(['current_purchase_id' => null]);
|
||||||
|
$reservation->update(['expires_at' => $this->expiration()]);
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function assertCartReservationUsable(Cart $cart): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($cart): void {
|
||||||
|
/** @var Cart $cart */
|
||||||
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
if ($cart->current_stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($cart->current_stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseCurrentCartReservation(
|
||||||
|
Cart $cart,
|
||||||
|
string $reason = self::REASON_CART_CHANGED,
|
||||||
|
): void {
|
||||||
|
DB::transaction(function () use ($cart, $reason): void {
|
||||||
|
/** @var Cart $cart */
|
||||||
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
if ($cart->current_stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
|
||||||
|
if ($reservation !== null) {
|
||||||
|
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
|
||||||
|
}
|
||||||
|
$cart->update(['current_stock_reservation_id' => null]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expire(StockReservation $reservation): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($reservation): void {
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->expires_at === null
|
||||||
|
|| $reservation->expires_at->isFuture()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearExpirationForReview(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($purchase): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->stock_reservation_id);
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||||
|
}
|
||||||
|
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update(['expires_at' => null]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, CartItem> $items
|
||||||
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||||
|
*/
|
||||||
|
private function requirementsForItems(Collection $items): array
|
||||||
|
{
|
||||||
|
$requirements = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$selection = $item->selectedItem();
|
||||||
|
if ($selection === null) {
|
||||||
|
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
|
||||||
|
if (isset($requirements[$inventoryId])) {
|
||||||
|
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
|
||||||
|
$requirements[$inventoryId]['tracks_inventory'] =
|
||||||
|
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requirements[$inventoryId] = $requirement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($requirements);
|
||||||
|
|
||||||
|
return $requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, CartItem> $items */
|
||||||
|
private function loadSelections(Collection $items): void
|
||||||
|
{
|
||||||
|
$items->load([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.catalogItem',
|
||||||
|
'variant.inventory',
|
||||||
|
'variant.catalogItem',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, StockReservationLine> */
|
||||||
|
private function lockLines(StockReservation $reservation): Collection
|
||||||
|
{
|
||||||
|
return StockReservationLine::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
|
->orderBy('inventory_id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, StockReservationLine> $lines
|
||||||
|
* @return Collection<int, Inventory>
|
||||||
|
*/
|
||||||
|
private function lockInventories(Collection $lines): Collection
|
||||||
|
{
|
||||||
|
return Inventory::query()
|
||||||
|
->whereKey($lines->pluck('inventory_id'))
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finalizeLocked(
|
||||||
|
StockReservation $reservation,
|
||||||
|
string $status,
|
||||||
|
?string $reason,
|
||||||
|
): void {
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
|
||||||
|
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $this->lockLines($reservation);
|
||||||
|
$inventories = $this->lockInventories($lines);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventory = $inventories->get($line->inventory_id)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||||
|
$inventory->release((int) $line->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$reservation->update([
|
||||||
|
'status' => $status,
|
||||||
|
'expires_at' => null,
|
||||||
|
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
|
||||||
|
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
|
||||||
|
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === StockReservation::STATUS_RELEASED) {
|
||||||
|
Cart::query()
|
||||||
|
->where('current_stock_reservation_id', $reservation->getKey())
|
||||||
|
->update(['current_stock_reservation_id' => null]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function assertUsableCartReservation(StockReservation $reservation): void
|
||||||
|
{
|
||||||
|
if ($reservation->status === StockReservation::STATUS_EXPIRED
|
||||||
|
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->expires_at === null) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function expiration(): Carbon
|
||||||
|
{
|
||||||
|
return now()->addMinutes(
|
||||||
|
max(1, (int) config('catalog.stock_reservation_expiration_minutes', 30)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
||||||
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
||||||
- `Inventory` administra stock disponible, reservado y comprado.
|
- `Inventory` administra stock disponible, reservado y comprado.
|
||||||
|
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
|
||||||
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||||
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||||
- `BundleComponent` representa los componentes de un paquete.
|
- `BundleComponent` representa los componentes de un paquete.
|
||||||
@@ -17,6 +18,8 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
|
|
||||||
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
||||||
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
||||||
|
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
||||||
|
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
|
||||||
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||||
|
|
||||||
|
|||||||
47
app/Domains/Client/Controllers/ClientController.php
Normal file
47
app/Domains/Client/Controllers/ClientController.php
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Client\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Client\Requests\StoreClientRequest;
|
||||||
|
use App\Domains\Client\Requests\UpdateClientRequest;
|
||||||
|
use App\Domains\Client\Resources\ClientResource;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class ClientController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
return ClientResource::collection(
|
||||||
|
Client::query()->with('tenants')->latest()->paginateFromRequest()
|
||||||
|
)->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreClientRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return ClientResource::make(
|
||||||
|
Client::query()->create($request->validated())->load('tenants')
|
||||||
|
)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Client $client): ClientResource
|
||||||
|
{
|
||||||
|
return ClientResource::make($client->load('tenants'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateClientRequest $request, Client $client): ClientResource
|
||||||
|
{
|
||||||
|
$client->update($request->validated());
|
||||||
|
|
||||||
|
return ClientResource::make($client->fresh()->load('tenants'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client): Response
|
||||||
|
{
|
||||||
|
$client->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
30
app/Domains/Client/Models/Client.php
Normal file
30
app/Domains/Client/Models/Client.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Client\Models;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
#[Fillable(['code', 'name'])]
|
||||||
|
class Client extends Model
|
||||||
|
{
|
||||||
|
public function getRouteKeyName(): string
|
||||||
|
{
|
||||||
|
return 'code';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Tenant, $this> */
|
||||||
|
public function tenants(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Tenant::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function integrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ClientIntegration::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Domains/Client/Requests/StoreClientRequest.php
Normal file
22
app/Domains/Client/Requests/StoreClientRequest.php
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Client\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class StoreClientRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => ['required', 'string', 'max:255', Rule::unique('clients', 'code')],
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Client/Requests/UpdateClientRequest.php
Normal file
26
app/Domains/Client/Requests/UpdateClientRequest.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Client\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateClientRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
/** @var Client|null $client */
|
||||||
|
$client = $this->route('client');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => ['sometimes', 'string', 'max:255', Rule::unique('clients', 'code')->ignore($client?->id)],
|
||||||
|
'name' => ['sometimes', 'string', 'max:255'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Client/Resources/ClientResource.php
Normal file
26
app/Domains/Client/Resources/ClientResource.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Client\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin Client */
|
||||||
|
class ClientResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'code' => $this->code,
|
||||||
|
'name' => $this->name,
|
||||||
|
'tenants' => $this->whenLoaded('tenants', fn () => $this->tenants->map(fn ($tenant): array => [
|
||||||
|
'id' => $tenant->id,
|
||||||
|
'codigo' => $tenant->codigo,
|
||||||
|
'nombre' => $tenant->nombre,
|
||||||
|
'dominio' => $tenant->dominio,
|
||||||
|
])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/Domains/Client/routes/api.php
Normal file
6
app/Domains/Client/routes/api.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Client\Controllers\ClientController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::apiResource('clients', ClientController::class);
|
||||||
443
app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php
Normal file
443
app/Domains/Desfile/Services/InvitationPurchaseProvisioner.php
Normal file
@@ -0,0 +1,443 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Desfile\Services;
|
||||||
|
|
||||||
|
use DateTimeInterface;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class InvitationPurchaseProvisioner
|
||||||
|
{
|
||||||
|
public const TENANT_CODE = 'desfile_pura_tendencia';
|
||||||
|
|
||||||
|
public const USER_EMAIL = 'invitados@puratendencia.com';
|
||||||
|
|
||||||
|
private const PAYMENT_METHOD = 'invitation';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var list<array{sector: string, row: int, first_seat: int, last_seat: int, type: string}>
|
||||||
|
*/
|
||||||
|
private const ALLOCATIONS = [
|
||||||
|
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
|
||||||
|
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
|
||||||
|
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 17, 'type' => 'VIP + LUNCH'],
|
||||||
|
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array{sector: string, row: int, first_seat: int, last_seat: int, type: string}>|null $allocations
|
||||||
|
*/
|
||||||
|
public function provision(?array $allocations = null): void
|
||||||
|
{
|
||||||
|
if (! $this->prerequisitesExist()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($allocations): void {
|
||||||
|
$now = now();
|
||||||
|
$userId = $this->userId($now);
|
||||||
|
$purchaseId = $this->purchaseId($userId, $now);
|
||||||
|
$catalogItem = DB::table('catalog_items')
|
||||||
|
->where('tenant_code', self::TENANT_CODE)
|
||||||
|
->where('slug', 'entrada')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($catalogItem === null) {
|
||||||
|
throw new RuntimeException('No se encontró el catálogo de entradas del desfile.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($allocations ?? self::ALLOCATIONS as $allocation) {
|
||||||
|
foreach (range($allocation['first_seat'], $allocation['last_seat']) as $seat) {
|
||||||
|
$variant = $this->variant(
|
||||||
|
(int) $catalogItem->id,
|
||||||
|
$allocation['sector'],
|
||||||
|
$allocation['row'],
|
||||||
|
$seat,
|
||||||
|
$allocation['type'],
|
||||||
|
);
|
||||||
|
|
||||||
|
$purchaseItemId = $this->createPurchaseItem(
|
||||||
|
$purchaseId,
|
||||||
|
$catalogItem,
|
||||||
|
$variant,
|
||||||
|
$allocation['sector'],
|
||||||
|
$allocation['row'],
|
||||||
|
$seat,
|
||||||
|
$allocation['type'],
|
||||||
|
$now,
|
||||||
|
);
|
||||||
|
$this->createTicketAndCommitStock(
|
||||||
|
$purchaseId,
|
||||||
|
$purchaseItemId,
|
||||||
|
$userId,
|
||||||
|
(int) $catalogItem->id,
|
||||||
|
$variant,
|
||||||
|
$now,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function prerequisitesExist(): bool
|
||||||
|
{
|
||||||
|
if (! DB::table('tenants')->where('codigo', self::TENANT_CODE)->exists()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::table('roles')->where('codigo', 'user')->exists()) {
|
||||||
|
throw new RuntimeException('No se encontró el rol de usuario común.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! DB::table('catalog_items')
|
||||||
|
->where('tenant_code', self::TENANT_CODE)
|
||||||
|
->where('slug', 'entrada')
|
||||||
|
->exists()) {
|
||||||
|
throw new RuntimeException('No se encontró el catálogo de entradas del desfile.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function userId(DateTimeInterface $now): int
|
||||||
|
{
|
||||||
|
$user = DB::table('users')
|
||||||
|
->where('active_email', self::USER_EMAIL)
|
||||||
|
->where('rol_codigo', 'user')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($user !== null) {
|
||||||
|
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
||||||
|
throw new RuntimeException('El email de invitados ya pertenece a otro tenant.');
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('users')->where('id', $user->id)->update([
|
||||||
|
'rol_codigo' => 'user',
|
||||||
|
'nombre_apellido' => 'Invitados Pura Tendencia',
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $user->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table('users')->insertGetId([
|
||||||
|
'rol_codigo' => 'user',
|
||||||
|
'tenant_codigo' => self::TENANT_CODE,
|
||||||
|
'nombre_apellido' => 'Invitados Pura Tendencia',
|
||||||
|
'email' => self::USER_EMAIL,
|
||||||
|
'email_verified_at' => $now,
|
||||||
|
'password' => Hash::make(Str::random(64)),
|
||||||
|
'dni' => null,
|
||||||
|
'telefono' => null,
|
||||||
|
'remember_token' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function purchaseId(int $userId, DateTimeInterface $now): int
|
||||||
|
{
|
||||||
|
$purchaseId = DB::table('compras')
|
||||||
|
->where('tenant_codigo', self::TENANT_CODE)
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('payment_method', self::PAYMENT_METHOD)
|
||||||
|
->value('id');
|
||||||
|
|
||||||
|
if ($purchaseId !== null) {
|
||||||
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
|
'status' => 'paid',
|
||||||
|
'total' => 0,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return (int) $purchaseId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table('compras')->insertGetId([
|
||||||
|
'tenant_codigo' => self::TENANT_CODE,
|
||||||
|
'user_id' => $userId,
|
||||||
|
'cart_id' => null,
|
||||||
|
'status' => 'paid',
|
||||||
|
'payment_method' => self::PAYMENT_METHOD,
|
||||||
|
'total' => 0,
|
||||||
|
'dni' => null,
|
||||||
|
'transfer_payer_dni' => null,
|
||||||
|
'telefono' => null,
|
||||||
|
'nombre_apellido' => 'Invitados Pura Tendencia',
|
||||||
|
'email' => self::USER_EMAIL,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function variant(
|
||||||
|
int $catalogItemId,
|
||||||
|
string $sector,
|
||||||
|
int $row,
|
||||||
|
int $seat,
|
||||||
|
string $type,
|
||||||
|
): object {
|
||||||
|
$variant = $this->findVariant($catalogItemId, $sector, $row, $seat);
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
$variant = $this->createVariant($catalogItemId, $sector, $row, $seat, $type);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->ensureAttributeOptions($catalogItemId, [
|
||||||
|
'tipo' => $type,
|
||||||
|
'sector' => $sector,
|
||||||
|
'fila' => (string) $row,
|
||||||
|
'asiento' => (string) $seat,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$typeValueId = DB::table('variant_values as variant_value')
|
||||||
|
->join('item_attributes as item_attribute', 'item_attribute.id', '=', 'variant_value.item_attribute_id')
|
||||||
|
->join('attribute', 'attribute.id', '=', 'item_attribute.attribute_id')
|
||||||
|
->where('variant_value.variant_id', $variant->id)
|
||||||
|
->where('attribute.codigo', 'tipo')
|
||||||
|
->value('variant_value.id');
|
||||||
|
|
||||||
|
if ($typeValueId === null) {
|
||||||
|
throw new RuntimeException("La variante {$variant->id} no tiene definido el tipo de entrada.");
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('variant_values')->where('id', $typeValueId)->update(['value' => $type]);
|
||||||
|
DB::table('variantes')->where('id', $variant->id)->update([
|
||||||
|
'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}",
|
||||||
|
]);
|
||||||
|
|
||||||
|
return DB::table('variantes')->where('id', $variant->id)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $values */
|
||||||
|
private function ensureAttributeOptions(int $catalogItemId, array $values): void
|
||||||
|
{
|
||||||
|
$attributes = DB::table('item_attributes')
|
||||||
|
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
|
||||||
|
->where('item_attributes.catalog_item_id', $catalogItemId)
|
||||||
|
->pluck('attribute.id', 'attribute.codigo');
|
||||||
|
|
||||||
|
foreach ($values as $code => $value) {
|
||||||
|
$attributeId = $attributes[$code] ?? null;
|
||||||
|
|
||||||
|
if ($attributeId === null) {
|
||||||
|
throw new RuntimeException("Falta el atributo {$code} en el catálogo de entradas.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DB::table('attribute_options')
|
||||||
|
->where('attribute_id', $attributeId)
|
||||||
|
->where('value', $value)
|
||||||
|
->exists()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastSortOrder = (int) DB::table('attribute_options')
|
||||||
|
->where('attribute_id', $attributeId)
|
||||||
|
->max('sort_order');
|
||||||
|
|
||||||
|
DB::table('attribute_options')->insert([
|
||||||
|
'attribute_id' => $attributeId,
|
||||||
|
'validity_time_id' => null,
|
||||||
|
'value' => $value,
|
||||||
|
'label' => $value,
|
||||||
|
'sort_order' => $lastSortOrder + 1,
|
||||||
|
'metadata' => null,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function findVariant(int $catalogItemId, string $sector, int $row, int $seat): ?object
|
||||||
|
{
|
||||||
|
$query = DB::table('variantes')->where('catalog_item_id', $catalogItemId);
|
||||||
|
|
||||||
|
foreach (['sector' => $sector, 'fila' => (string) $row, 'asiento' => (string) $seat] as $code => $value) {
|
||||||
|
$query->whereExists(fn ($subquery) => $subquery
|
||||||
|
->selectRaw('1')
|
||||||
|
->from('variant_values as selected_value')
|
||||||
|
->join('item_attributes as selected_item_attribute', 'selected_item_attribute.id', '=', 'selected_value.item_attribute_id')
|
||||||
|
->join('attribute as selected_attribute', 'selected_attribute.id', '=', 'selected_item_attribute.attribute_id')
|
||||||
|
->whereColumn('selected_value.variant_id', 'variantes.id')
|
||||||
|
->where('selected_attribute.codigo', $code)
|
||||||
|
->where('selected_value.value', $value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $query->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createVariant(
|
||||||
|
int $catalogItemId,
|
||||||
|
string $sector,
|
||||||
|
int $row,
|
||||||
|
int $seat,
|
||||||
|
string $type,
|
||||||
|
): object {
|
||||||
|
$itemAttributes = DB::table('item_attributes')
|
||||||
|
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
|
||||||
|
->where('item_attributes.catalog_item_id', $catalogItemId)
|
||||||
|
->pluck('item_attributes.id', 'attribute.codigo');
|
||||||
|
|
||||||
|
foreach (['tipo', 'sector', 'fila', 'asiento'] as $requiredCode) {
|
||||||
|
if (! isset($itemAttributes[$requiredCode])) {
|
||||||
|
throw new RuntimeException("Falta el atributo {$requiredCode} en el catálogo de entradas.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventoryId = DB::table('inventories')->insertGetId([
|
||||||
|
'sold_units' => 0,
|
||||||
|
'reserved_stock' => 0,
|
||||||
|
'real_stock' => 1,
|
||||||
|
]);
|
||||||
|
$variantId = DB::table('variantes')->insertGetId([
|
||||||
|
'catalog_item_id' => $catalogItemId,
|
||||||
|
'event_date_id' => null,
|
||||||
|
'inventory_id' => $inventoryId,
|
||||||
|
'descripcion' => "Sector {$sector} - Fila {$row} - Asiento {$seat} - {$type}",
|
||||||
|
'precio' => $this->catalogPrice($sector, $row),
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach (['tipo' => $type, 'sector' => $sector, 'fila' => (string) $row, 'asiento' => (string) $seat] as $code => $value) {
|
||||||
|
DB::table('variant_values')->insert([
|
||||||
|
'variant_id' => $variantId,
|
||||||
|
'item_attribute_id' => $itemAttributes[$code],
|
||||||
|
'value' => $value,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return DB::table('variantes')->where('id', $variantId)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
private function catalogPrice(string $sector, int $row): int
|
||||||
|
{
|
||||||
|
$prices = in_array($sector, ['A', 'C'], true)
|
||||||
|
? [1 => 250000, 2 => 200000, 3 => 100000, 4 => 75000, 5 => 50000]
|
||||||
|
: [1 => 240000, 2 => 190000, 3 => 90000, 4 => 65000, 5 => 40000];
|
||||||
|
|
||||||
|
return $prices[$row];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createPurchaseItem(
|
||||||
|
int $purchaseId,
|
||||||
|
object $catalogItem,
|
||||||
|
object $variant,
|
||||||
|
string $sector,
|
||||||
|
int $row,
|
||||||
|
int $seat,
|
||||||
|
string $type,
|
||||||
|
DateTimeInterface $now,
|
||||||
|
): int {
|
||||||
|
$existingId = DB::table('compra_items')
|
||||||
|
->where('compra_id', $purchaseId)
|
||||||
|
->where('source_variant_id', $variant->id)
|
||||||
|
->value('id');
|
||||||
|
if ($existingId !== null) {
|
||||||
|
return (int) $existingId;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attributes = [
|
||||||
|
['name' => 'Tipo', 'value' => $type],
|
||||||
|
['name' => 'Sector', 'value' => $sector],
|
||||||
|
['name' => 'Fila', 'value' => (string) $row],
|
||||||
|
['name' => 'Asiento', 'value' => (string) $seat],
|
||||||
|
];
|
||||||
|
|
||||||
|
return DB::table('compra_items')->insertGetId([
|
||||||
|
'compra_id' => $purchaseId,
|
||||||
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
|
'source_variant_id' => $variant->id,
|
||||||
|
'image_attachment_id' => DB::table('catalog_items_attachments')
|
||||||
|
->where('catalog_item_id', $catalogItem->id)
|
||||||
|
->whereNull('variant_id')
|
||||||
|
->orderBy('orden')
|
||||||
|
->value('attachment_id'),
|
||||||
|
'nombre' => $catalogItem->nombre,
|
||||||
|
'descripcion' => $variant->descripcion,
|
||||||
|
'slug' => $catalogItem->slug,
|
||||||
|
'item_nombre' => "Entrada (Sector {$sector}, Fila {$row}, Asiento {$seat})",
|
||||||
|
'variant_attributes' => json_encode($attributes, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||||||
|
'cantidad' => 1,
|
||||||
|
'precio_unitario' => 0,
|
||||||
|
'discount_total' => 0,
|
||||||
|
'tax_total' => 0,
|
||||||
|
'total' => 0,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createTicketAndCommitStock(
|
||||||
|
int $purchaseId,
|
||||||
|
int $purchaseItemId,
|
||||||
|
int $userId,
|
||||||
|
int $catalogItemId,
|
||||||
|
object $variant,
|
||||||
|
DateTimeInterface $now,
|
||||||
|
): void {
|
||||||
|
$purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id')
|
||||||
|
? ['source_purchase_item_id' => $purchaseItemId]
|
||||||
|
: ['source_purchase_id' => $purchaseId];
|
||||||
|
|
||||||
|
if (DB::table('tickets')
|
||||||
|
->where($purchaseReference)
|
||||||
|
->where('source_variant_id', $variant->id)
|
||||||
|
->exists()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0) {
|
||||||
|
throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible.");
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('inventories')->where('id', $inventory->id)->update([
|
||||||
|
'real_stock' => $inventory->real_stock - 1,
|
||||||
|
'sold_units' => $inventory->sold_units + 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
|
||||||
|
if ($reservationId === null) {
|
||||||
|
$reservationId = DB::table('stock_reservations')->insertGetId([
|
||||||
|
'status' => 'committed',
|
||||||
|
'committed_at' => $now,
|
||||||
|
'released_at' => null,
|
||||||
|
'expired_at' => null,
|
||||||
|
'release_reason' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('stock_reservation_lines')->insert([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
'inventory_id' => $inventory->id,
|
||||||
|
'quantity' => 1,
|
||||||
|
'tracks_inventory' => true,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
|
||||||
|
DB::table('tickets')->insert([
|
||||||
|
'tenant_code' => self::TENANT_CODE,
|
||||||
|
'ticket' => (string) Str::uuid(),
|
||||||
|
'name' => null,
|
||||||
|
'description' => null,
|
||||||
|
...$purchaseReference,
|
||||||
|
'source_catalog_item_id' => $catalogItemId,
|
||||||
|
'source_variant_id' => $variant->id,
|
||||||
|
'used_at' => null,
|
||||||
|
'scanner_user_id' => null,
|
||||||
|
'user_id' => $userId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Requests\UpdateCategoryVisibilityRequest;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Services\CategoryVisibilityService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class CategoryVisibilityController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly CategoryVisibilityService $visibilityService) {}
|
||||||
|
|
||||||
|
public function show(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$category = $this->category($request);
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'is_enabled' => $this->visibilityService->isEnabled($tenant, $category),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateCategoryVisibilityRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$category = $this->category($request);
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
$model = $this->visibilityService->update(
|
||||||
|
$tenant,
|
||||||
|
$category,
|
||||||
|
$request->boolean('is_enabled'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'data' => [
|
||||||
|
'is_enabled' => $model->is_enabled,
|
||||||
|
],
|
||||||
|
'message' => $model->is_enabled
|
||||||
|
? 'Mostrar en sitio web se activo correctamente'
|
||||||
|
: 'Mostrar en sitio web se desactivo correctamente',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function category(Request $request): FiestaCategory
|
||||||
|
{
|
||||||
|
return FiestaCategory::from((string) $request->route('category'));
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php
Normal file
21
app/Domains/FiestaFutbolInfantil/Enums/FiestaCategory.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Enums;
|
||||||
|
|
||||||
|
enum FiestaCategory: string
|
||||||
|
{
|
||||||
|
case Entries = 'entries';
|
||||||
|
case Foods = 'foods';
|
||||||
|
case Accommodations = 'accommodations';
|
||||||
|
case Merchandise = 'merchandise';
|
||||||
|
|
||||||
|
public function categoryName(): string
|
||||||
|
{
|
||||||
|
return match ($this) {
|
||||||
|
self::Entries => 'Entradas',
|
||||||
|
self::Foods => 'Comidas',
|
||||||
|
self::Accommodations => 'Alojamientos',
|
||||||
|
self::Merchandise => 'Merchandising',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateCategoryVisibilityRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'is_enabled' => ['required', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
|
||||||
|
class CategoryVisibilityService
|
||||||
|
{
|
||||||
|
public function isEnabled(Tenant $tenant, FiestaCategory $category): bool
|
||||||
|
{
|
||||||
|
return Category::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('nombre', $category->categoryName())
|
||||||
|
->value('is_enabled') ?? true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Tenant $tenant, FiestaCategory $category, bool $isEnabled): Category
|
||||||
|
{
|
||||||
|
$model = Category::query()->firstOrCreate([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'nombre' => $category->categoryName(),
|
||||||
|
]);
|
||||||
|
$model->update(['is_enabled' => $isEnabled]);
|
||||||
|
|
||||||
|
return $model;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
|
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
|
||||||
|
use App\Domains\FiestaFutbolInfantil\Controllers\CategoryVisibilityController;
|
||||||
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
|
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
|
||||||
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
|
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
|
||||||
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
|
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
|
||||||
@@ -9,6 +10,20 @@ use Illuminate\Support\Facades\Route;
|
|||||||
Route::prefix('v1/adminapp/tenant')
|
Route::prefix('v1/adminapp/tenant')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
|
$visibilityRoutes = static function (string $endpoint, string $category, string $menuCode): void {
|
||||||
|
Route::get("{$endpoint}/visibility", [CategoryVisibilityController::class, 'show'])
|
||||||
|
->defaults('category', $category)
|
||||||
|
->middleware("tenant.menu:{$menuCode}");
|
||||||
|
Route::patch("{$endpoint}/visibility", [CategoryVisibilityController::class, 'update'])
|
||||||
|
->defaults('category', $category)
|
||||||
|
->middleware("tenant.menu:{$menuCode}");
|
||||||
|
};
|
||||||
|
|
||||||
|
$visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
|
||||||
|
$visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
|
||||||
|
$visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
|
||||||
|
$visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
|
||||||
|
|
||||||
Route::get('entries', [EntryController::class, 'index'])
|
Route::get('entries', [EntryController::class, 'index'])
|
||||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
->name('adminapp.fiesta-futbol-infantil.entries.index');
|
->name('adminapp.fiesta-futbol-infantil.entries.index');
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\TicketFilterFormResource;
|
||||||
|
use App\Domains\Forms\Services\TicketFilterFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TicketFilterFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TicketFilterFormService $formService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): TicketFilterFormResource
|
||||||
|
{
|
||||||
|
$tenant = $request->user('sanctum')->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
return TicketFilterFormResource::make($this->formService->get($tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Forms\Resources\TicketFormResource;
|
||||||
|
use App\Domains\Forms\Services\TicketFormService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class TicketFormController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(protected TicketFormService $ticketFormService) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): TicketFormResource
|
||||||
|
{
|
||||||
|
return TicketFormResource::make(
|
||||||
|
$this->ticketFormService->get(
|
||||||
|
$request->user('sanctum')->tenant()->firstOrFail()
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
app/Domains/Forms/Resources/TicketFilterFormResource.php
Normal file
21
app/Domains/Forms/Resources/TicketFilterFormResource.php
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TicketFilterFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'code' => $this->resource['code'],
|
||||||
|
'action' => $this->resource['action'],
|
||||||
|
'method' => $this->resource['method'],
|
||||||
|
'fields' => $this->resource['fields'],
|
||||||
|
'columns' => $this->resource['columns'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
18
app/Domains/Forms/Resources/TicketFormResource.php
Normal file
18
app/Domains/Forms/Resources/TicketFormResource.php
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class TicketFormResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'statuses' => $this->resource['statuses'],
|
||||||
|
'categories' => $this->resource['categories'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,25 +6,18 @@ use App\Domains\Purchase\Models\Purchase;
|
|||||||
|
|
||||||
class SaleFormService
|
class SaleFormService
|
||||||
{
|
{
|
||||||
/** @return array{statuses: list<array{code: string, name: string}>} */
|
/** @return array{statuses: list<array{value: string, label: string, real_statuses: list<string>}>} */
|
||||||
public function get(): array
|
public function get(): array
|
||||||
{
|
{
|
||||||
$names = [
|
|
||||||
Purchase::STATUS_CREATED => 'Creada',
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
|
||||||
Purchase::STATUS_PAID => 'Confirmada',
|
|
||||||
Purchase::STATUS_CANCELLED => 'Cancelada',
|
|
||||||
Purchase::STATUS_REJECTED => 'Rechazada',
|
|
||||||
Purchase::STATUS_EXPIRED => 'Vencida',
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'statuses' => array_map(
|
'statuses' => array_map(
|
||||||
fn (string $status): array => [
|
fn (string $code, array $definition): array => [
|
||||||
'code' => $status,
|
'value' => $code,
|
||||||
'name' => $names[$status],
|
'label' => $definition['name'],
|
||||||
|
'real_statuses' => $definition['statuses'],
|
||||||
],
|
],
|
||||||
Purchase::statuses(),
|
array_keys(Purchase::adminStatuses()),
|
||||||
|
array_values(Purchase::adminStatuses()),
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
152
app/Domains/Forms/Services/TicketFilterFormService.php
Normal file
152
app/Domains/Forms/Services/TicketFilterFormService.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
||||||
|
|
||||||
|
class TicketFilterFormService
|
||||||
|
{
|
||||||
|
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly TicketFormService $ticketFormService,
|
||||||
|
private readonly AdminAppTicketColumnService $columnService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$fields = $this->commonFields();
|
||||||
|
|
||||||
|
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
|
||||||
|
$fields = [
|
||||||
|
...$this->fiestaFutbolInfantilFields($tenant),
|
||||||
|
...$this->commonFields(includeDate: false),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'tickets_filter',
|
||||||
|
'action' => '/api/v1/adminapp/tenant/tickets',
|
||||||
|
'method' => 'GET',
|
||||||
|
'fields' => $fields,
|
||||||
|
'columns' => $this->columnService->publicColumns($tenant),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function fiestaFutbolInfantilFields(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$form = $this->ticketFormService->getForFilters($tenant);
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
'name' => 'category',
|
||||||
|
'query_param' => 'category',
|
||||||
|
'label' => 'Categoría',
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => 'Categoría',
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $category): array => [
|
||||||
|
'value' => $category['value'],
|
||||||
|
'label' => $category['label'],
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'field' => 'product',
|
||||||
|
'disabled' => $category['products'] === [],
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'children' => [
|
||||||
|
[
|
||||||
|
'field' => 'type',
|
||||||
|
'disabled' => $product['types'] === [],
|
||||||
|
'options' => array_map(
|
||||||
|
fn (array $type): array => [
|
||||||
|
'value' => $type['value'],
|
||||||
|
'label' => $type['label'],
|
||||||
|
'children' => [[
|
||||||
|
'field' => 'size',
|
||||||
|
'disabled' => ($type['sizes'] ?? []) === [],
|
||||||
|
'options' => $type['sizes'] ?? [],
|
||||||
|
]],
|
||||||
|
],
|
||||||
|
$product['types'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$category['products'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'field' => 'date',
|
||||||
|
'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true),
|
||||||
|
'options' => [],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
$form['categories'],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
$this->dependentSelect('product', 'Producto', 'category'),
|
||||||
|
$this->dependentSelect('type', 'Tipo', 'product'),
|
||||||
|
$this->dependentSelect('size', 'Talle', 'type'),
|
||||||
|
[
|
||||||
|
...$this->dependentSelect('date', 'Fecha', 'category'),
|
||||||
|
'type' => 'date',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function commonFields(bool $includeDate = true): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
...($includeDate ? [[
|
||||||
|
'name' => 'date',
|
||||||
|
'query_param' => 'date',
|
||||||
|
'label' => 'Fecha',
|
||||||
|
'type' => 'date',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
]] : []),
|
||||||
|
[
|
||||||
|
'name' => 'status',
|
||||||
|
'query_param' => 'status',
|
||||||
|
'label' => 'Estado',
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => 'Estado',
|
||||||
|
'options' => [
|
||||||
|
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
||||||
|
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
||||||
|
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
private function dependentSelect(string $name, string $label, string $dependency): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $name,
|
||||||
|
'query_param' => $name,
|
||||||
|
'label' => $label,
|
||||||
|
'type' => 'select',
|
||||||
|
'required' => false,
|
||||||
|
'default' => null,
|
||||||
|
'placeholder' => $label,
|
||||||
|
'depends_on' => $dependency,
|
||||||
|
'disabled' => true,
|
||||||
|
'options' => [],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
400
app/Domains/Forms/Services/TicketFormService.php
Normal file
400
app/Domains/Forms/Services/TicketFormService.php
Normal file
@@ -0,0 +1,400 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Forms\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
|
||||||
|
class TicketFormService
|
||||||
|
{
|
||||||
|
private const PRODUCT = 'product';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string|null, product: string, type: string|null, order: int}>
|
||||||
|
*/
|
||||||
|
private const CATEGORY_PRESENTATIONS = [
|
||||||
|
'entradas' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => null,
|
||||||
|
'order' => 1,
|
||||||
|
],
|
||||||
|
'alojamientos' => [
|
||||||
|
'label' => 'Camping',
|
||||||
|
'product' => 'tipo_alojamiento',
|
||||||
|
'type' => null,
|
||||||
|
'order' => 2,
|
||||||
|
],
|
||||||
|
'camping' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => 'tipo_alojamiento',
|
||||||
|
'type' => null,
|
||||||
|
'order' => 2,
|
||||||
|
],
|
||||||
|
'comidas' => [
|
||||||
|
'label' => 'Comida',
|
||||||
|
'product' => 'event_date',
|
||||||
|
'type' => 'horario',
|
||||||
|
'order' => 3,
|
||||||
|
],
|
||||||
|
'comida' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => 'event_date',
|
||||||
|
'type' => 'horario',
|
||||||
|
'order' => 3,
|
||||||
|
],
|
||||||
|
'merchandising' => [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => 'color',
|
||||||
|
'order' => 4,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, array{label: string|null, product: string, type: string|null, size: string|null, order: int}>
|
||||||
|
*/
|
||||||
|
private const FILTER_CATEGORY_PRESENTATIONS = [
|
||||||
|
'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1],
|
||||||
|
'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
|
||||||
|
'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
|
||||||
|
'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
|
||||||
|
'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
|
||||||
|
'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function get(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$items = CatalogItem::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('has_tickets', true)
|
||||||
|
->whereHas('category')
|
||||||
|
->with($this->relations())
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->build($items, self::CATEGORY_PRESENTATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return active catalog options plus soft-deleted sources still referenced by
|
||||||
|
* tickets, so historical tickets never become impossible to filter.
|
||||||
|
*
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
public function getForFilters(Tenant $tenant): array
|
||||||
|
{
|
||||||
|
$historicalVariantIds = Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereNotNull('source_variant_id')
|
||||||
|
->distinct()
|
||||||
|
->pluck('source_variant_id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->all();
|
||||||
|
$historicalCatalogItemIds = Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereNotNull('source_catalog_item_id')
|
||||||
|
->distinct()
|
||||||
|
->pluck('source_catalog_item_id')
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->merge(
|
||||||
|
Variant::withTrashed()
|
||||||
|
->whereKey($historicalVariantIds)
|
||||||
|
->pluck('catalog_item_id')
|
||||||
|
->map(fn ($id): int => (int) $id),
|
||||||
|
)
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
$items = CatalogItem::withTrashed()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereHas('category')
|
||||||
|
->where(function ($query) use ($historicalCatalogItemIds): void {
|
||||||
|
$query
|
||||||
|
->where(function ($activeQuery): void {
|
||||||
|
$activeQuery
|
||||||
|
->whereNull('catalog_items.deleted_at')
|
||||||
|
->where('has_tickets', true);
|
||||||
|
})
|
||||||
|
->orWhereIn('catalog_items.id', $historicalCatalogItemIds);
|
||||||
|
})
|
||||||
|
->with([
|
||||||
|
'category',
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants' => fn ($query) => $query
|
||||||
|
->withTrashed()
|
||||||
|
->where(function ($variantQuery) use ($historicalVariantIds): void {
|
||||||
|
$variantQuery
|
||||||
|
->whereNull('variantes.deleted_at')
|
||||||
|
->orWhereIn('variantes.id', $historicalVariantIds);
|
||||||
|
}),
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.eventDate',
|
||||||
|
])
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('nombre')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, CatalogItem> $items
|
||||||
|
* @return array{
|
||||||
|
* statuses: list<array{value: string, label: string}>,
|
||||||
|
* categories: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* products: list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
* }>
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
private function build(Collection $items, array $presentations, bool $includeSizes = false): array
|
||||||
|
{
|
||||||
|
$categories = [];
|
||||||
|
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$sourceCategory = trim((string) $item->category?->nombre);
|
||||||
|
$categoryValue = mb_strtolower($sourceCategory);
|
||||||
|
$presentation = $presentations[$categoryValue] ?? [
|
||||||
|
'label' => null,
|
||||||
|
'product' => self::PRODUCT,
|
||||||
|
'type' => null,
|
||||||
|
'size' => null,
|
||||||
|
'order' => PHP_INT_MAX,
|
||||||
|
];
|
||||||
|
|
||||||
|
$categories[$categoryValue] ??= [
|
||||||
|
'value' => $categoryValue,
|
||||||
|
'label' => $presentation['label'] ?? $sourceCategory,
|
||||||
|
'order' => $presentation['order'],
|
||||||
|
'products' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->products(
|
||||||
|
$item,
|
||||||
|
$presentation['product'],
|
||||||
|
$presentation['type'],
|
||||||
|
$presentation['size'] ?? null,
|
||||||
|
) as $product) {
|
||||||
|
$productValue = $product['value'];
|
||||||
|
$existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [
|
||||||
|
'value' => $productValue,
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => [],
|
||||||
|
'sizes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($product['types'] as $type) {
|
||||||
|
$existingProduct['types'][$type['value']] = $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($product['sizes'] as $size) {
|
||||||
|
$existingProduct['sizes'][$size['value']] = $size;
|
||||||
|
}
|
||||||
|
|
||||||
|
$categories[$categoryValue]['products'][$productValue] = $existingProduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order']
|
||||||
|
?: $left['label'] <=> $right['label']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'statuses' => [
|
||||||
|
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
||||||
|
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
||||||
|
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
||||||
|
],
|
||||||
|
'categories' => array_values(array_map(
|
||||||
|
fn (array $category): array => [
|
||||||
|
'value' => $category['value'],
|
||||||
|
'label' => $category['label'],
|
||||||
|
'products' => array_values(array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => array_values($product['types']),
|
||||||
|
...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []),
|
||||||
|
],
|
||||||
|
$category['products'],
|
||||||
|
)),
|
||||||
|
],
|
||||||
|
$categories,
|
||||||
|
)),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
private function relations(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category',
|
||||||
|
'itemAttributes.attribute.options',
|
||||||
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
|
'variants.eventDates',
|
||||||
|
'variants.eventDate',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* value: string,
|
||||||
|
* label: string,
|
||||||
|
* types: list<array{value: string, label: string}>,
|
||||||
|
* sizes: list<array{value: string, label: string}>
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array
|
||||||
|
{
|
||||||
|
if ($productCode === self::PRODUCT) {
|
||||||
|
return [[
|
||||||
|
'value' => $item->slug,
|
||||||
|
'label' => $item->nombre,
|
||||||
|
'types' => $this->types($item, $typeCode, $sizeCode),
|
||||||
|
'sizes' => $this->types($item, $sizeCode),
|
||||||
|
]];
|
||||||
|
}
|
||||||
|
|
||||||
|
$products = [];
|
||||||
|
|
||||||
|
foreach ($item->variants as $variant) {
|
||||||
|
foreach ($this->variantOptions($variant, $productCode) as $productOption) {
|
||||||
|
$productValue = $productOption['value'];
|
||||||
|
$products[$productValue] ??= [
|
||||||
|
'value' => $productValue,
|
||||||
|
'label' => $this->optionLabel($productOption['label'], $productCode),
|
||||||
|
'types' => [],
|
||||||
|
'sizes' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
|
||||||
|
$this->mergeTypeOption(
|
||||||
|
$products[$productValue]['types'],
|
||||||
|
$typeOption,
|
||||||
|
$variant,
|
||||||
|
$sizeCode,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
|
||||||
|
$products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(
|
||||||
|
fn (array $product): array => [
|
||||||
|
'value' => $product['value'],
|
||||||
|
'label' => $product['label'],
|
||||||
|
'types' => array_values($product['types']),
|
||||||
|
'sizes' => array_values($product['sizes']),
|
||||||
|
],
|
||||||
|
$products,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array<string, mixed>> */
|
||||||
|
private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array
|
||||||
|
{
|
||||||
|
$types = [];
|
||||||
|
|
||||||
|
foreach ($item->variants as $variant) {
|
||||||
|
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
|
||||||
|
$this->mergeTypeOption($types, $typeOption, $variant, $sizeCode);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_values(array_map(function (array $type) use ($sizeCode): array {
|
||||||
|
if ($sizeCode !== null) {
|
||||||
|
$type['sizes'] = array_values($type['sizes']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $type;
|
||||||
|
}, $types));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, array<string, mixed>> $types
|
||||||
|
* @param array{value: string, label: string} $typeOption
|
||||||
|
*/
|
||||||
|
private function mergeTypeOption(
|
||||||
|
array &$types,
|
||||||
|
array $typeOption,
|
||||||
|
Variant $variant,
|
||||||
|
?string $sizeCode,
|
||||||
|
): void {
|
||||||
|
$typeValue = $typeOption['value'];
|
||||||
|
$types[$typeValue] ??= [
|
||||||
|
...$typeOption,
|
||||||
|
...($sizeCode !== null ? ['sizes' => []] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
|
||||||
|
$types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{value: string, label: string}> */
|
||||||
|
private function variantOptions(Variant $variant, ?string $attributeCode): array
|
||||||
|
{
|
||||||
|
if ($attributeCode === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$selection = $variant->selectionOptions($variant->catalogItem->itemAttributes)
|
||||||
|
->get($attributeCode);
|
||||||
|
|
||||||
|
if ($selection === null) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_is_list($selection) ? $selection : [$selection];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function optionLabel(string $label, string $attributeCode): string
|
||||||
|
{
|
||||||
|
if ($attributeCode !== 'event_date') {
|
||||||
|
return $label;
|
||||||
|
}
|
||||||
|
|
||||||
|
[$day, $month] = array_pad(explode('/', $label), 2, null);
|
||||||
|
|
||||||
|
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm
|
|||||||
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
|
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
|
||||||
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
|
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
|
||||||
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
|
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
|
||||||
|
- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil.
|
||||||
|
|
||||||
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
|
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`:
|
|||||||
- `GET /event`.
|
- `GET /event`.
|
||||||
- `GET /sale`.
|
- `GET /sale`.
|
||||||
- `GET /staff`.
|
- `GET /staff`.
|
||||||
|
- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets.
|
||||||
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
|
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
|
||||||
|
|
||||||
## Dependencias
|
## Dependencias
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
|
|||||||
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\TicketFilterFormController;
|
||||||
|
use App\Domains\Forms\Controllers\AdminApp\TicketFormController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/forms')
|
Route::prefix('v1/adminapp/forms')
|
||||||
@@ -13,6 +15,13 @@ Route::prefix('v1/adminapp/forms')
|
|||||||
Route::get('event', EventFormController::class);
|
Route::get('event', EventFormController::class);
|
||||||
Route::get('sale', SaleFormController::class);
|
Route::get('sale', SaleFormController::class);
|
||||||
Route::get('staff', StaffFormController::class);
|
Route::get('staff', StaffFormController::class);
|
||||||
|
Route::get('tickets-filter', TicketFilterFormController::class)
|
||||||
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
|
->name('adminapp.forms.tickets-filter');
|
||||||
|
Route::get(
|
||||||
|
'fiesta-futbol-infantil/ticket',
|
||||||
|
TicketFormController::class
|
||||||
|
);
|
||||||
Route::get(
|
Route::get(
|
||||||
'fiesta-futbol-infantil/merchandise',
|
'fiesta-futbol-infantil/merchandise',
|
||||||
MerchandiseFormController::class
|
MerchandiseFormController::class
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||||
|
use App\Domains\Integration\Services\ClientIntegrationService;
|
||||||
|
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class ClientIntegrationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ClientIntegrationService $clientIntegrationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Client $client): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($this->clientIntegrationService->getAllForClient($client));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Client $client, string $integrationCode): JsonResponse
|
||||||
|
{
|
||||||
|
$integration = $this->clientIntegrationService->getClientIntegration($client, $integrationCode);
|
||||||
|
|
||||||
|
if (! $integration) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'integration.not_configured',
|
||||||
|
'message' => __('api.integration.not_configured'),
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json($integration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(
|
||||||
|
ConfigureIntegrationRequest $request,
|
||||||
|
Client $client,
|
||||||
|
string $integrationCode,
|
||||||
|
): JsonResponse {
|
||||||
|
$integration = Integration::query()
|
||||||
|
->where('integration_code', $integrationCode)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->clientIntegrationService->updateOrCreateIntegration(
|
||||||
|
$client,
|
||||||
|
$integration,
|
||||||
|
$request->input('integration_data', []),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'integration.configured',
|
||||||
|
'message' => __('api.integration.configured'),
|
||||||
|
]);
|
||||||
|
} catch (\Exception $exception) {
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'integration.validation_failed',
|
||||||
|
'message' => __('api.integration.validation_failed', ['error' => $exception->getMessage()]),
|
||||||
|
], 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Client $client, string $integrationCode, IntegrationAssociationService $service)
|
||||||
|
{
|
||||||
|
$service->detach($client, $integrationCode);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ class IntegrationController extends Controller
|
|||||||
|
|
||||||
public function destroy(Integration $integration)
|
public function destroy(Integration $integration)
|
||||||
{
|
{
|
||||||
|
abort_if($integration->instances()->exists(), 409, 'Delete the integration instances first.');
|
||||||
$integration->delete();
|
$integration->delete();
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Integration\Controllers;
|
namespace App\Domains\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
@@ -12,12 +13,12 @@ class TelepagosWebhookController extends Controller
|
|||||||
/**
|
/**
|
||||||
* Handle the incoming Telepagos webhook.
|
* Handle the incoming Telepagos webhook.
|
||||||
*/
|
*/
|
||||||
public function handle(TelepagosWebhookRequest $request, string $tenantCodigo, TelepagosWebhookService $service): JsonResponse
|
public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
|
||||||
{
|
{
|
||||||
try {
|
try {
|
||||||
$cashinId = $request->validated('id');
|
$cashinId = $request->validated('id');
|
||||||
|
|
||||||
$service->handleWebhook($tenantCodigo, $cashinId);
|
$service->handleWebhook($client, $cashinId);
|
||||||
|
|
||||||
return response()->json(['status' => 'success']);
|
return response()->json(['status' => 'success']);
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Integration\Models\Integration;
|
|
||||||
use App\Domains\Integration\Requests\StoreTenantIntegrationRequest;
|
|
||||||
use App\Domains\Integration\Services\TenantIntegrationService;
|
|
||||||
use Illuminate\Routing\Controller;
|
|
||||||
|
|
||||||
class TenantIntegrationController extends Controller
|
|
||||||
{
|
|
||||||
protected TenantIntegrationService $tenantIntegrationService;
|
|
||||||
|
|
||||||
public function __construct(TenantIntegrationService $tenantIntegrationService)
|
|
||||||
{
|
|
||||||
$this->tenantIntegrationService = $tenantIntegrationService;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function index(string $tenantCode)
|
|
||||||
{
|
|
||||||
return response()->json($this->tenantIntegrationService->getAllForTenant($tenantCode));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(string $tenantCode, string $integrationCode)
|
|
||||||
{
|
|
||||||
$integration = $this->tenantIntegrationService->getTenantIntegration($tenantCode, $integrationCode);
|
|
||||||
|
|
||||||
if (! $integration) {
|
|
||||||
return response()->json([
|
|
||||||
'code' => 'integration.not_configured',
|
|
||||||
'message' => __('api.integration.not_configured'),
|
|
||||||
], 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return response()->json($integration);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreTenantIntegrationRequest $request, string $tenantCode, string $integrationCode)
|
|
||||||
{
|
|
||||||
$integration = Integration::where('integration_code', $integrationCode)->firstOrFail();
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->tenantIntegrationService->updateOrCreateIntegration(
|
|
||||||
$tenantCode,
|
|
||||||
$integration,
|
|
||||||
$request->input('integration_data', [])
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'code' => 'integration.configured',
|
|
||||||
'message' => __('api.integration.configured'),
|
|
||||||
]);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return response()->json([
|
|
||||||
'code' => 'integration.validation_failed',
|
|
||||||
'message' => __('api.integration.validation_failed', ['error' => $e->getMessage()]),
|
|
||||||
], 400);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Requests\ConfigureIntegrationRequest;
|
||||||
|
use App\Domains\Integration\Resources\IntegrationAssociationResource;
|
||||||
|
use App\Domains\Integration\Services\IntegrationAssociationService;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
|
||||||
|
class WebsiteTypeIntegrationController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly IntegrationAssociationService $service) {}
|
||||||
|
|
||||||
|
public function index(WebsiteType $websiteType)
|
||||||
|
{
|
||||||
|
return IntegrationAssociationResource::collection($websiteType->integrations()->with('integrationInstance')->get());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
return new IntegrationAssociationResource($websiteType->integrations()->with('integrationInstance')->where('integration_code', $integrationCode)->firstOrFail());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(ConfigureIntegrationRequest $request, WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
$integration = Integration::query()->where('integration_code', $integrationCode)->firstOrFail();
|
||||||
|
|
||||||
|
return new IntegrationAssociationResource($this->service->configure(
|
||||||
|
$websiteType,
|
||||||
|
$integration,
|
||||||
|
$request->validated('integration_data'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(WebsiteType $websiteType, string $integrationCode)
|
||||||
|
{
|
||||||
|
$this->service->detach($websiteType, $integrationCode);
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/Domains/Integration/Models/ClientIntegration.php
Normal file
34
app/Domains/Integration/Models/ClientIntegration.php
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class ClientIntegration extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'client_id',
|
||||||
|
'integration_code',
|
||||||
|
'integration_instance_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Client, $this> */
|
||||||
|
public function client(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Client::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||||
|
public function integrationInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IntegrationInstance::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Integration\Models;
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
class Integration extends Model
|
class Integration extends Model
|
||||||
{
|
{
|
||||||
@@ -13,16 +14,29 @@ class Integration extends Model
|
|||||||
'name',
|
'name',
|
||||||
'url',
|
'url',
|
||||||
'integration_data_schema',
|
'integration_data_schema',
|
||||||
'requires_tenant_configuration',
|
'requires_configuration',
|
||||||
];
|
];
|
||||||
|
|
||||||
protected $casts = [
|
protected $casts = [
|
||||||
'integration_data_schema' => 'array',
|
'integration_data_schema' => 'array',
|
||||||
'requires_tenant_configuration' => 'boolean',
|
'requires_configuration' => 'boolean',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function tenantIntegrations()
|
/** @return HasMany<IntegrationInstance, $this> */
|
||||||
|
public function instances(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(TenantIntegration::class, 'integration_code', 'integration_code');
|
return $this->hasMany(IntegrationInstance::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function clientIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ClientIntegration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function websiteTypeIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class, 'integration_code', 'integration_code');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
app/Domains/Integration/Models/IntegrationInstance.php
Normal file
41
app/Domains/Integration/Models/IntegrationInstance.php
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
|
class IntegrationInstance extends Model
|
||||||
|
{
|
||||||
|
// The ciphertext changes whenever credentials are saved, so old tokens cannot be reused.
|
||||||
|
public function tokenCacheKey(): string
|
||||||
|
{
|
||||||
|
return 'integration_token:instance:'.$this->id.':'.hash('sha256', (string) $this->getRawOriginal('integration_data'));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected $fillable = ['integration_code', 'name', 'integration_data'];
|
||||||
|
|
||||||
|
protected $hidden = ['integration_data'];
|
||||||
|
|
||||||
|
protected $casts = ['integration_data' => EncryptedIntegrationData::class];
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<ClientIntegration, $this> */
|
||||||
|
public function clientIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(ClientIntegration::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<WebsiteTypeIntegration, $this> */
|
||||||
|
public function websiteTypeIntegrations(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(WebsiteTypeIntegration::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Models;
|
|
||||||
|
|
||||||
use App\Domains\Integration\Casts\EncryptedIntegrationData;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
|
|
||||||
class TenantIntegration extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'tenant_integration';
|
|
||||||
|
|
||||||
protected $fillable = [
|
|
||||||
'integration_code',
|
|
||||||
'tenant_code',
|
|
||||||
'integration_data',
|
|
||||||
];
|
|
||||||
|
|
||||||
protected $casts = [
|
|
||||||
'integration_data' => EncryptedIntegrationData::class,
|
|
||||||
];
|
|
||||||
|
|
||||||
public function integration()
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
30
app/Domains/Integration/Models/WebsiteTypeIntegration.php
Normal file
30
app/Domains/Integration/Models/WebsiteTypeIntegration.php
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Models;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
class WebsiteTypeIntegration extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = ['website_type_code', 'integration_code', 'integration_instance_id'];
|
||||||
|
|
||||||
|
/** @return BelongsTo<WebsiteType, $this> */
|
||||||
|
public function websiteType(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(WebsiteType::class, 'website_type_code', 'codigo');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Integration, $this> */
|
||||||
|
public function integration(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Integration::class, 'integration_code', 'integration_code');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<IntegrationInstance, $this> */
|
||||||
|
public function integrationInstance(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(IntegrationInstance::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
14
app/Domains/Integration/Policies/IntegrationPolicy.php
Normal file
14
app/Domains/Integration/Policies/IntegrationPolicy.php
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Policies;
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Authorization\Enums\RoleCode;
|
||||||
|
|
||||||
|
class IntegrationPolicy
|
||||||
|
{
|
||||||
|
public function manage(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->rol_codigo === RoleCode::Admin->value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ConfigureIntegrationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
protected ?Integration $integrationModel = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return $this->user()?->can('manage', Integration::class) ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->integrationModel = Integration::query()
|
||||||
|
->where('integration_code', $this->route('integration_code'))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $this->integrationModel) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'integration_code' => __('api.integration.not_configured'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$rules = ['integration_data' => ['present', 'array']];
|
||||||
|
|
||||||
|
foreach ($this->integrationModel?->integration_data_schema ?? [] as $field => $rule) {
|
||||||
|
$rules['integration_data.'.$field] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ class StoreIntegrationRequest extends FormRequest
|
|||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
'requires_tenant_configuration' => ['sometimes', 'boolean'],
|
'requires_configuration' => ['sometimes', 'boolean'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Integration\Models\Integration;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class StoreTenantIntegrationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
protected ?Integration $integrationModel = null;
|
|
||||||
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function prepareForValidation()
|
|
||||||
{
|
|
||||||
$integrationCode = $this->route('integration_code');
|
|
||||||
$this->integrationModel = Integration::where('integration_code', $integrationCode)->first();
|
|
||||||
|
|
||||||
if (! $this->integrationModel) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'integration_code' => __('api.integration.not_configured'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
$rules = [];
|
|
||||||
|
|
||||||
// Dynamic validation rules based on the integration data schema
|
|
||||||
if ($this->integrationModel && $this->integrationModel->integration_data_schema) {
|
|
||||||
foreach ($this->integrationModel->integration_data_schema as $field => $rule) {
|
|
||||||
$rules['integration_data.'.$field] = $rule;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rules;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Integration\Requests;
|
namespace App\Domains\Integration\Requests;
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
class UpdateIntegrationRequest extends FormRequest
|
class UpdateIntegrationRequest extends FormRequest
|
||||||
{
|
{
|
||||||
@@ -14,14 +15,13 @@ class UpdateIntegrationRequest extends FormRequest
|
|||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
$integration = $this->route('integration');
|
$integration = $this->route('integration');
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
'requires_tenant_configuration' => ['sometimes', 'boolean'],
|
'requires_configuration' => ['sometimes', 'boolean'],
|
||||||
// the code shouldn't ideally be updatable, but if it is:
|
'integration_code' => ['sometimes', 'required', 'string', Rule::in([$integration->integration_code])],
|
||||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,' . ($integration->id ?? '')],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IntegrationAssociationResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'client_id' => $this->when(isset($this->client_id), $this->client_id),
|
||||||
|
'website_type_code' => $this->when(isset($this->website_type_code), $this->website_type_code),
|
||||||
|
'integration_code' => $this->integration_code,
|
||||||
|
'integration_instance_id' => $this->integration_instance_id,
|
||||||
|
'integration_instance' => new IntegrationInstanceResource($this->whenLoaded('integrationInstance')),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Resources;
|
||||||
|
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class IntegrationInstanceResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'integration_code' => $this->integration_code,
|
||||||
|
'name' => $this->name,
|
||||||
|
'created_at' => $this->created_at,
|
||||||
|
'updated_at' => $this->updated_at,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,58 +2,56 @@
|
|||||||
|
|
||||||
namespace App\Domains\Integration\Services;
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
use App\Domains\Integration\Models\Integration;
|
use App\Domains\Integration\Models\Integration;
|
||||||
use App\Domains\Integration\Models\TenantIntegration;
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
use Illuminate\Support\Facades\Http;
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
use Illuminate\Http\Client\PendingRequest;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Http\Client\PendingRequest;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
|
||||||
abstract class BaseIntegrationService
|
abstract class BaseIntegrationService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* The unique code of the integration.
|
* The unique code of the integration.
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
*/
|
||||||
protected string $integrationCode;
|
protected string $integrationCode;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The current tenant code.
|
* The current tenant code.
|
||||||
*
|
|
||||||
* @var string
|
|
||||||
*/
|
*/
|
||||||
protected string $tenantCode;
|
protected string $tenantCode;
|
||||||
|
|
||||||
|
protected ?Tenant $tenant = null;
|
||||||
|
|
||||||
|
protected ?Client $clientContext = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The integration model instance.
|
* The integration model instance.
|
||||||
*
|
|
||||||
* @var Integration|null
|
|
||||||
*/
|
*/
|
||||||
protected ?Integration $integration = null;
|
protected ?Integration $integration = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The tenant-specific integration model instance.
|
* The effective integration instance.
|
||||||
*
|
|
||||||
* @var TenantIntegration|null
|
|
||||||
*/
|
*/
|
||||||
protected ?TenantIntegration $tenantIntegration = null;
|
protected ?IntegrationInstance $integrationInstance = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the integration code.
|
* Set the integration code.
|
||||||
*
|
*
|
||||||
* @param string $integrationCode
|
|
||||||
* @return $this
|
* @return $this
|
||||||
*/
|
*/
|
||||||
public function setIntegrationCode(string $integrationCode): self
|
public function setIntegrationCode(string $integrationCode): self
|
||||||
{
|
{
|
||||||
$this->integrationCode = $integrationCode;
|
$this->integrationCode = $integrationCode;
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the integration code.
|
* Get the integration code.
|
||||||
*
|
|
||||||
* @return string
|
|
||||||
*/
|
*/
|
||||||
public function getIntegrationCode(): string
|
public function getIntegrationCode(): string
|
||||||
{
|
{
|
||||||
@@ -63,53 +61,77 @@ abstract class BaseIntegrationService
|
|||||||
/**
|
/**
|
||||||
* Set the tenant code and load the integration models.
|
* Set the tenant code and load the integration models.
|
||||||
*
|
*
|
||||||
* @param string $tenantCode
|
|
||||||
* @return $this
|
* @return $this
|
||||||
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function forTenant(string $tenantCode): self
|
public function forTenant(string $tenantCode): self
|
||||||
{
|
{
|
||||||
$this->tenantCode = $tenantCode;
|
$this->tenantCode = $tenantCode;
|
||||||
|
$this->tenant = Tenant::query()->with('client')->where('codigo', $tenantCode)->firstOrFail();
|
||||||
|
$this->clientContext = $this->tenant->client;
|
||||||
$this->loadIntegration();
|
$this->loadIntegration();
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forClient(Client|string $client): self
|
||||||
|
{
|
||||||
|
$this->clientContext = $client instanceof Client
|
||||||
|
? $client
|
||||||
|
: Client::query()->where('code', $client)->firstOrFail();
|
||||||
|
$this->tenant = null;
|
||||||
|
$this->loadIntegration();
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load the Integration and TenantIntegration models.
|
* Load the integration definition and its effective instance configuration.
|
||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
protected function loadIntegration(): void
|
protected function loadIntegration(): void
|
||||||
{
|
{
|
||||||
if (empty($this->integrationCode)) {
|
if (empty($this->integrationCode)) {
|
||||||
throw new Exception("Integration code is not set.");
|
throw new Exception('Integration code is not set.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->integrationInstance = null;
|
||||||
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
$this->integration = Integration::where('integration_code', $this->integrationCode)->first();
|
||||||
if (!$this->integration) {
|
if (! $this->integration) {
|
||||||
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
throw new Exception("Integration with code '{$this->integrationCode}' not found.");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tenantIntegration = TenantIntegration::where('tenant_code', $this->tenantCode)
|
if (! $this->clientContext) {
|
||||||
->where('integration_code', $this->integrationCode)
|
throw new Exception('Client context is not set.');
|
||||||
->first();
|
}
|
||||||
|
|
||||||
if (!$this->tenantIntegration && $this->integration->requires_tenant_configuration) {
|
$this->integrationInstance = ClientIntegration::with('integrationInstance')->where('client_id', $this->clientContext->id)
|
||||||
throw new Exception("Tenant '{$this->tenantCode}' does not have integration '{$this->integrationCode}' configured.");
|
->where('integration_code', $this->integrationCode)
|
||||||
|
->first()?->integrationInstance;
|
||||||
|
|
||||||
|
if (! $this->integrationInstance && $this->tenant?->website_type_code) {
|
||||||
|
$this->integrationInstance = WebsiteTypeIntegration::with('integrationInstance')
|
||||||
|
->where('website_type_code', $this->tenant->website_type_code)
|
||||||
|
->where('integration_code', $this->integrationCode)
|
||||||
|
->first()?->integrationInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->integrationInstance && $this->integration->requires_configuration) {
|
||||||
|
throw new Exception("Client '{$this->clientContext->code}' does not have integration '{$this->integrationCode}' configured.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the request URL.
|
* Build the request URL.
|
||||||
*
|
*
|
||||||
* @param string $path
|
|
||||||
* @return string
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function getUrl(string $path = ''): string
|
public function getUrl(string $path = ''): string
|
||||||
{
|
{
|
||||||
if (!$this->integration) {
|
if (! $this->integration) {
|
||||||
throw new Exception("Integration is not loaded. Call forTenant() first.");
|
throw new Exception('Integration is not loaded. Call forTenant() or forClient() first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$baseUrl = rtrim($this->integration->url, '/');
|
$baseUrl = rtrim($this->integration->url, '/');
|
||||||
@@ -119,25 +141,20 @@ abstract class BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get integration setting by key from tenant's integration data.
|
* Get an integration setting from the effective instance configuration.
|
||||||
*
|
|
||||||
* @param string $key
|
|
||||||
* @param mixed $default
|
|
||||||
* @return mixed
|
|
||||||
*/
|
*/
|
||||||
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
protected function getIntegrationSetting(string $key, mixed $default = null): mixed
|
||||||
{
|
{
|
||||||
if (!$this->tenantIntegration || !$this->tenantIntegration->integration_data) {
|
if (! $this->integrationInstance || ! $this->integrationInstance->integration_data) {
|
||||||
return $default;
|
return $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->tenantIntegration->integration_data[$key] ?? $default;
|
return $this->integrationInstance->integration_data[$key] ?? $default;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get a pre-configured HTTP client builder.
|
* Get a pre-configured HTTP client builder.
|
||||||
*
|
*
|
||||||
* @return PendingRequest
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function client(): PendingRequest
|
public function client(): PendingRequest
|
||||||
@@ -148,17 +165,13 @@ abstract class BaseIntegrationService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the headers for the integration.
|
* Get the headers for the integration.
|
||||||
*
|
|
||||||
* @return array
|
|
||||||
*/
|
*/
|
||||||
abstract public function getHeaders(): array;
|
abstract public function getHeaders(): array;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook called after the integration is configured for the tenant.
|
* Hook called after the integration is configured for the client.
|
||||||
* Can be used to validate credentials or perform initial setups.
|
* Can be used to validate credentials or perform initial setups.
|
||||||
* Throw an Exception on failure.
|
* Throw an Exception on failure.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function onSetup(): void
|
public function onSetup(): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class ClientIntegrationService
|
||||||
|
{
|
||||||
|
public function getClientIntegration(Client $client, string $integrationCode): ?ClientIntegration
|
||||||
|
{
|
||||||
|
return $client->integrations()
|
||||||
|
->with(['integration', 'integrationInstance'])
|
||||||
|
->where('integration_code', $integrationCode)
|
||||||
|
->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, ClientIntegration> */
|
||||||
|
public function getAllForClient(Client $client): Collection
|
||||||
|
{
|
||||||
|
return $client->integrations()->with(['integration', 'integrationInstance'])->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateOrCreateIntegration(
|
||||||
|
Client $client,
|
||||||
|
Integration $integration,
|
||||||
|
array $data,
|
||||||
|
): ClientIntegration {
|
||||||
|
return DB::transaction(function () use ($client, $integration, $data): ClientIntegration {
|
||||||
|
$clientIntegration = app(IntegrationAssociationService::class)->configure($client, $integration, $data);
|
||||||
|
|
||||||
|
$service = $this->resolveService($integration->integration_code);
|
||||||
|
$service?->forClient($client)->onSetup();
|
||||||
|
|
||||||
|
return $clientIntegration;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function resolveService(string $integrationCode): ?BaseIntegrationService
|
||||||
|
{
|
||||||
|
return match ($integrationCode) {
|
||||||
|
'email' => new MailService,
|
||||||
|
'telepagos', 'telepagos_homo' => new TelepagosIntegrationService($integrationCode),
|
||||||
|
default => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Integration\Models\ClientIntegration;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use App\Domains\Integration\Models\WebsiteTypeIntegration;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IntegrationAssociationService
|
||||||
|
{
|
||||||
|
public function configure(Client|WebsiteType $owner, Integration $integration, array $integrationData): ClientIntegration|WebsiteTypeIntegration
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($owner, $integration, $integrationData): ClientIntegration|WebsiteTypeIntegration {
|
||||||
|
$instance = IntegrationInstance::create([
|
||||||
|
'integration_code' => $integration->integration_code,
|
||||||
|
'name' => $integration->name.' / '.$this->ownerName($owner),
|
||||||
|
'integration_data' => $integrationData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->associate($owner, $integration->integration_code, $instance);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function associate(Client|WebsiteType $owner, string $code, IntegrationInstance $instance): ClientIntegration|WebsiteTypeIntegration
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($owner, $code, $instance): ClientIntegration|WebsiteTypeIntegration {
|
||||||
|
$association = $owner->integrations()
|
||||||
|
->where('integration_code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
$previousInstanceId = $association?->integration_instance_id;
|
||||||
|
|
||||||
|
$instanceIds = array_values(array_unique(array_filter([
|
||||||
|
$previousInstanceId,
|
||||||
|
$instance->id,
|
||||||
|
])));
|
||||||
|
sort($instanceIds);
|
||||||
|
|
||||||
|
$instances = IntegrationInstance::query()
|
||||||
|
->whereKey($instanceIds)
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
$instance = $instances->get($instance->id) ?? IntegrationInstance::query()->findOrFail($instance->id);
|
||||||
|
abort_unless($instance->integration_code === $code, 422, 'The instance belongs to another integration.');
|
||||||
|
|
||||||
|
$association = $owner->integrations()->updateOrCreate(
|
||||||
|
['integration_code' => $code],
|
||||||
|
['integration_instance_id' => $instance->id],
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($previousInstanceId && $previousInstanceId !== $instance->id) {
|
||||||
|
$this->deleteIfUnused($previousInstanceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $association->load('integrationInstance');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function detach(Client|WebsiteType $owner, string $code): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($owner, $code): void {
|
||||||
|
$association = $owner->integrations()
|
||||||
|
->where('integration_code', $code)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $association) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$instanceId = $association->integration_instance_id;
|
||||||
|
$association->delete();
|
||||||
|
$this->deleteIfUnused($instanceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function deleteIfUnused(int $instanceId): void
|
||||||
|
{
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->find($instanceId);
|
||||||
|
|
||||||
|
if ($instance
|
||||||
|
&& ! $instance->clientIntegrations()->exists()
|
||||||
|
&& ! $instance->websiteTypeIntegrations()->exists()) {
|
||||||
|
$instance->delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownerName(Client|WebsiteType $owner): string
|
||||||
|
{
|
||||||
|
return $owner instanceof Client ? $owner->name : $owner->nombre;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\IntegrationInstance;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class IntegrationInstanceService
|
||||||
|
{
|
||||||
|
public function create(array $data): IntegrationInstance
|
||||||
|
{
|
||||||
|
return IntegrationInstance::create($data);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(IntegrationInstance $instance, array $data): IntegrationInstance
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($instance, $data): IntegrationInstance {
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||||
|
$cacheKey = $instance->tokenCacheKey();
|
||||||
|
$instance->update($data);
|
||||||
|
if (array_key_exists('integration_data', $data)) {
|
||||||
|
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $instance;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function delete(IntegrationInstance $instance): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($instance): void {
|
||||||
|
$instance = IntegrationInstance::query()->lockForUpdate()->findOrFail($instance->id);
|
||||||
|
abort_if($instance->clientIntegrations()->exists() || $instance->websiteTypeIntegrations()->exists(), 409, 'Unlink the instance before deleting it.');
|
||||||
|
$cacheKey = $instance->tokenCacheKey();
|
||||||
|
$instance->delete();
|
||||||
|
DB::afterCommit(fn () => Cache::forget($cacheKey));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
namespace App\Domains\Integration\Services;
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
||||||
use Illuminate\Contracts\Mail\Mailer;
|
use Illuminate\Contracts\Mail\Mailer;
|
||||||
@@ -27,9 +29,7 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
private ?Mailer $mailer = null;
|
private ?Mailer $mailer = null;
|
||||||
|
|
||||||
private ?Tenant $tenant = null;
|
private bool $usesInstanceMailer = false;
|
||||||
|
|
||||||
private bool $usesTenantMailer = false;
|
|
||||||
|
|
||||||
public function __construct(?MailFactory $mailFactory = null)
|
public function __construct(?MailFactory $mailFactory = null)
|
||||||
{
|
{
|
||||||
@@ -40,16 +40,28 @@ class MailService extends BaseIntegrationService
|
|||||||
{
|
{
|
||||||
parent::forTenant($tenantCode);
|
parent::forTenant($tenantCode);
|
||||||
|
|
||||||
$this->tenant = Tenant::query()
|
if ($this->integrationInstance) {
|
||||||
->where('codigo', $tenantCode)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
if ($this->tenantIntegration) {
|
|
||||||
$this->mailer = $this->resolveMailer();
|
$this->mailer = $this->resolveMailer();
|
||||||
$this->usesTenantMailer = true;
|
$this->usesInstanceMailer = true;
|
||||||
} else {
|
} else {
|
||||||
$this->mailer = $this->mailFactory->mailer();
|
$this->mailer = $this->mailFactory->mailer();
|
||||||
$this->usesTenantMailer = false;
|
$this->usesInstanceMailer = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function forClient(Client|string $client): self
|
||||||
|
{
|
||||||
|
parent::forClient($client);
|
||||||
|
$this->tenant = $this->clientContext?->tenants()->first();
|
||||||
|
|
||||||
|
if ($this->integrationInstance) {
|
||||||
|
$this->mailer = $this->resolveMailer();
|
||||||
|
$this->usesInstanceMailer = true;
|
||||||
|
} else {
|
||||||
|
$this->mailer = $this->mailFactory->mailer();
|
||||||
|
$this->usesInstanceMailer = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
@@ -60,24 +72,35 @@ class MailService extends BaseIntegrationService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send(string|array $recipient, string $subject, string $content): void
|
/**
|
||||||
{
|
* @param array<int, array{data: string, name: string, mime: string}> $attachments
|
||||||
|
*/
|
||||||
|
public function send(
|
||||||
|
string|array $recipient,
|
||||||
|
string $subject,
|
||||||
|
string $content,
|
||||||
|
Tenant|WebsiteType|null $brand = null,
|
||||||
|
array $attachments = [],
|
||||||
|
): void {
|
||||||
if (! $this->mailer || ! $this->tenant) {
|
if (! $this->mailer || ! $this->tenant) {
|
||||||
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.');
|
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
$brand ??= $this->tenant;
|
||||||
|
$branding = $this->brandingFor($brand);
|
||||||
|
|
||||||
$html = Blade::render(
|
$html = Blade::render(
|
||||||
<<<'BLADE'
|
<<<'BLADE'
|
||||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||||
{!! $content !!}
|
{!! $content !!}
|
||||||
</x-mail.branded-layout>
|
</x-mail.branded-layout>
|
||||||
BLADE,
|
BLADE,
|
||||||
[
|
[
|
||||||
'tenant' => $this->tenant,
|
'branding' => $branding,
|
||||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
'headerLogoUrl' => $brand instanceof WebsiteType
|
||||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
? $brand->siteLogo?->getTemporaryUrl(1440)
|
||||||
|
: $brand->headerLogo?->getTemporaryUrl(1440),
|
||||||
|
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -86,48 +109,93 @@ class MailService extends BaseIntegrationService
|
|||||||
->subject($subject)
|
->subject($subject)
|
||||||
->html($html);
|
->html($html);
|
||||||
|
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
$mail->attachData(
|
||||||
|
$attachment['data'],
|
||||||
|
$attachment['name'],
|
||||||
|
['mime' => $attachment['mime']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->mailer->to($recipient)->send($mail);
|
$this->mailer->to($recipient)->send($mail);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function mailerName(): string
|
public function mailerName(): string
|
||||||
{
|
{
|
||||||
return $this->usesTenantMailer
|
return $this->usesInstanceMailer
|
||||||
? 'tenant-smtp'
|
? 'integration-smtp'
|
||||||
: (string) config('mail.default');
|
: (string) config('mail.default');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
|
||||||
|
private function brandingFor(Tenant|WebsiteType $brand): array
|
||||||
|
{
|
||||||
|
if ($brand instanceof WebsiteType) {
|
||||||
|
$brand->loadMissing(['siteLogo', 'footerLogo']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $brand->nombre,
|
||||||
|
'primary_color' => $brand->primary_color ?? '#FF7006',
|
||||||
|
'body_color' => $brand->body_color ?? '#666666',
|
||||||
|
'background_color' => $brand->background_color ?? '#f8f8f8',
|
||||||
|
'surface_color' => $brand->surface_color ?? '#ffffff',
|
||||||
|
'header_bg_color' => $brand->surface_color ?? '#ffffff',
|
||||||
|
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$brand->loadMissing(['headerLogo', 'footerLogo']);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => $brand->nombre,
|
||||||
|
'primary_color' => $brand->primary_color ?? '#6376f3',
|
||||||
|
'body_color' => '#334155',
|
||||||
|
'background_color' => '#f1f5f9',
|
||||||
|
'surface_color' => '#ffffff',
|
||||||
|
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
|
||||||
|
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function onSetup(): void
|
public function onSetup(): void
|
||||||
{
|
{
|
||||||
if (! $this->tenant) {
|
if (! $this->mailer || ! $this->clientContext) {
|
||||||
throw new Exception('MailService no está configurado. Llamá a forTenant() primero.');
|
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
|
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');
|
||||||
|
|
||||||
if (! is_string($recipient) || $recipient === '') {
|
if (! is_string($recipient) || $recipient === '') {
|
||||||
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del tenant.');
|
throw new InvalidArgumentException('Falta MAIL_FROM_ADDRESS en la configuración SMTP del cliente.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->send(
|
$subject = 'Configuración de correo validada';
|
||||||
$recipient,
|
$content = '<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
|
||||||
'Configuración de correo validada',
|
.'<p>La integración SMTP de '.e($this->clientContext->name).' fue configurada correctamente.</p>'
|
||||||
'<h1 style="margin: 0 0 20px;">Configuración de correo validada</h1>'
|
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>';
|
||||||
.'<p>La integración SMTP de '.e($this->tenant->nombre).' fue configurada correctamente.</p>'
|
|
||||||
.'<p style="color: #64748b; font-size: 13px;">Este mensaje fue enviado automáticamente para validar las credenciales de correo.</p>',
|
if ($this->tenant) {
|
||||||
|
$this->send($recipient, $subject, $content);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->mailer->to($recipient)->send(
|
||||||
|
(new Mailable)->subject($subject)->html($content)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function resolveMailer(): Mailer
|
private function resolveMailer(): Mailer
|
||||||
{
|
{
|
||||||
$data = $this->tenantIntegration?->integration_data;
|
$data = $this->integrationInstance?->integration_data;
|
||||||
|
|
||||||
if (! is_array($data)) {
|
if (! is_array($data)) {
|
||||||
throw new InvalidArgumentException('La configuración SMTP del tenant no es válida.');
|
throw new InvalidArgumentException('La configuración SMTP del cliente no es válida.');
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach (self::REQUIRED_SMTP_FIELDS as $field) {
|
foreach (self::REQUIRED_SMTP_FIELDS as $field) {
|
||||||
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
|
if (! array_key_exists($field, $data) || $data[$field] === null || $data[$field] === '') {
|
||||||
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del tenant.");
|
throw new InvalidArgumentException("Falta {$field} en la configuración SMTP del cliente.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,7 +205,7 @@ class MailService extends BaseIntegrationService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$mailer = $this->mailFactory->build([
|
$mailer = $this->mailFactory->build([
|
||||||
'name' => "tenant-smtp-{$this->tenantCode}",
|
'name' => 'integration-smtp-'.$this->integrationInstance?->id,
|
||||||
'transport' => 'smtp',
|
'transport' => 'smtp',
|
||||||
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
'scheme' => $data['MAIL_SCHEME'] ?? null,
|
||||||
'host' => $data['MAIL_HOST'],
|
'host' => $data['MAIL_HOST'],
|
||||||
@@ -150,7 +218,7 @@ class MailService extends BaseIntegrationService
|
|||||||
|
|
||||||
$mailer->alwaysFrom(
|
$mailer->alwaysFrom(
|
||||||
$data['MAIL_FROM_ADDRESS'],
|
$data['MAIL_FROM_ADDRESS'],
|
||||||
$data['MAIL_FROM_NAME'] ?? $this->tenant?->nombre,
|
$data['MAIL_FROM_NAME'] ?? $this->clientContext?->name,
|
||||||
);
|
);
|
||||||
|
|
||||||
return $mailer;
|
return $mailer;
|
||||||
|
|||||||
@@ -2,39 +2,37 @@
|
|||||||
|
|
||||||
namespace App\Domains\Integration\Services;
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use Carbon\Carbon;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Http\Client\Response;
|
||||||
use Illuminate\Support\Facades\Cache;
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Illuminate\Support\Facades\Http;
|
use Illuminate\Support\Facades\Http;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Carbon\Carbon;
|
|
||||||
|
|
||||||
class TelepagosIntegrationService extends BaseIntegrationService
|
class TelepagosIntegrationService extends BaseIntegrationService
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* TelepagosIntegrationService constructor.
|
* TelepagosIntegrationService constructor.
|
||||||
*
|
|
||||||
* @param string $integrationCode
|
|
||||||
*/
|
*/
|
||||||
public function __construct(string $integrationCode = 'telepagos')
|
public function __construct(string $integrationCode = 'telepagos')
|
||||||
{
|
{
|
||||||
// Force homologation code if not in production and using default
|
// Force homologation code if not in production and using default
|
||||||
if ($integrationCode === 'telepagos' && !app()->environment('production')) {
|
if ($integrationCode === 'telepagos' && ! app()->environment('production')) {
|
||||||
$integrationCode = 'telepagos_homo';
|
$integrationCode = 'telepagos_homo';
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->integrationCode = $integrationCode;
|
$this->integrationCode = $integrationCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the headers for Telepagos integration.
|
* Get the headers for Telepagos integration.
|
||||||
*
|
*
|
||||||
* @return array
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function getHeaders(): array
|
public function getHeaders(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'Authorization' => 'Bearer ' . $this->getToken(),
|
'Authorization' => 'Bearer '.$this->getToken(),
|
||||||
'Content-Type' => 'application/json',
|
'Content-Type' => 'application/json',
|
||||||
'Accept' => 'application/json',
|
'Accept' => 'application/json',
|
||||||
];
|
];
|
||||||
@@ -43,16 +41,15 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
/**
|
/**
|
||||||
* Get a valid token, either from cache or by performing a login.
|
* Get a valid token, either from cache or by performing a login.
|
||||||
*
|
*
|
||||||
* @return string
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function getToken(): string
|
public function getToken(): string
|
||||||
{
|
{
|
||||||
if (!$this->tenantIntegration) {
|
if (! $this->integrationInstance) {
|
||||||
throw new Exception("Tenant integration is not loaded. Call forTenant() first.");
|
throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
|
|
||||||
$token = Cache::get($cacheKey);
|
$token = Cache::get($cacheKey);
|
||||||
|
|
||||||
@@ -66,7 +63,6 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
/**
|
/**
|
||||||
* Authenticate with Telepagos and cache the returned token.
|
* Authenticate with Telepagos and cache the returned token.
|
||||||
*
|
*
|
||||||
* @return string
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function login(): string
|
public function login(): string
|
||||||
@@ -75,7 +71,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
$password = $this->getIntegrationSetting('password');
|
$password = $this->getIntegrationSetting('password');
|
||||||
|
|
||||||
if (empty($username) || empty($password)) {
|
if (empty($username) || empty($password)) {
|
||||||
throw new Exception("Missing username or password in Telepagos integration settings.");
|
throw new Exception('Missing username or password in Telepagos integration settings.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = $this->getUrl('/v2/auth/token');
|
$url = $this->getUrl('/v2/auth/token');
|
||||||
@@ -85,22 +81,20 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
'password' => $password,
|
'password' => $password,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$data = $this->handleResponse($response, 'authentication', [
|
$data = $this->handleResponse($response, 'authentication');
|
||||||
'username' => $username,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$token = $data['token'] ?? null;
|
$token = $data['token'] ?? null;
|
||||||
$expiresAtStr = $data['expires_at'] ?? null;
|
$expiresAtStr = $data['expires_at'] ?? null;
|
||||||
|
|
||||||
if (!$token || !$expiresAtStr) {
|
if (! $token || ! $expiresAtStr) {
|
||||||
throw new Exception("Telepagos authentication response is missing token or expires_at.");
|
throw new Exception('Telepagos authentication response is missing token or expires_at.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$expiresAt = Carbon::parse($expiresAtStr);
|
$expiresAt = Carbon::parse($expiresAtStr);
|
||||||
// Calculate TTL and subtract a buffer of 60 seconds
|
// Calculate TTL and subtract a buffer of 60 seconds
|
||||||
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
$ttlSeconds = max(1, $expiresAt->diffInSeconds(now()) - 60);
|
||||||
|
|
||||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
Cache::put($cacheKey, $token, $ttlSeconds);
|
Cache::put($cacheKey, $token, $ttlSeconds);
|
||||||
|
|
||||||
return $token;
|
return $token;
|
||||||
@@ -108,21 +102,21 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a request to Telepagos, handling 401 Unauthorized for token refresh.
|
* Send a request to Telepagos, handling 401 Unauthorized for token refresh.
|
||||||
*
|
|
||||||
* @param string $method
|
|
||||||
* @param string $endpoint
|
|
||||||
* @param array $data
|
|
||||||
* @return \Illuminate\Http\Client\Response
|
|
||||||
*/
|
*/
|
||||||
protected function sendRequest(string $method, string $endpoint, array $data = []): \Illuminate\Http\Client\Response
|
protected function sendRequest(string $method, string $endpoint, array $data = []): Response
|
||||||
{
|
{
|
||||||
$response = $this->client()->$method($endpoint, $data);
|
$response = $this->client()->$method($endpoint, $data);
|
||||||
|
|
||||||
if ($response->status() === 401) {
|
if ($response->status() === 401) {
|
||||||
Log::info("Telepagos 401 Unauthorized. Refreshing token and retrying...");
|
Log::channel('telepagos')->info('Telepagos request returned 401; refreshing token and retrying.', [
|
||||||
|
'method' => strtoupper($method),
|
||||||
|
'endpoint' => $endpoint,
|
||||||
|
'client_id' => $this->clientContext?->id,
|
||||||
|
'integration_code' => $this->integrationCode,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->clearToken();
|
$this->clearToken();
|
||||||
|
|
||||||
$response = $this->client()->$method($endpoint, $data);
|
$response = $this->client()->$method($endpoint, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,10 +126,6 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
/**
|
/**
|
||||||
* Generate a QR code for cash-in.
|
* Generate a QR code for cash-in.
|
||||||
*
|
*
|
||||||
* @param float $amount
|
|
||||||
* @param string $concept
|
|
||||||
* @param string $description
|
|
||||||
* @return array
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function generateQr(float $amount, string $concept, string $description): array
|
public function generateQr(float $amount, string $concept, string $description): array
|
||||||
@@ -154,8 +144,8 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
/**
|
/**
|
||||||
* Get the details of a cash-in payment.
|
* Get the details of a cash-in payment.
|
||||||
*
|
*
|
||||||
* @param int $cashinId
|
* @param int $cashinId
|
||||||
* @return array
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function getCashinDetails(string $cashinId): array
|
public function getCashinDetails(string $cashinId): array
|
||||||
@@ -170,31 +160,29 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
/**
|
/**
|
||||||
* Get the account info.
|
* Get the account info.
|
||||||
*
|
*
|
||||||
* @return array
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function getAccountInfo(): array
|
public function getAccountInfo(): array
|
||||||
{
|
{
|
||||||
$response = $this->sendRequest('get', '/v2/account/info');
|
$response = $this->sendRequest('get', '/v2/account/info');
|
||||||
|
|
||||||
return $this->handleResponse($response, 'get account info');
|
return $this->handleResponse($response, 'get account info');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handle the Telepagos API response, logging any failures and throwing Exceptions.
|
* Handle the Telepagos API response, logging any failures and throwing Exceptions.
|
||||||
*
|
*
|
||||||
* @param \Illuminate\Http\Client\Response $response
|
|
||||||
* @param string $actionDescription
|
|
||||||
* @param array $context
|
|
||||||
* @return array
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
protected function handleResponse(\Illuminate\Http\Client\Response $response, string $actionDescription, array $context = []): array
|
protected function handleResponse(Response $response, string $actionDescription, array $context = []): array
|
||||||
{
|
{
|
||||||
if ($response->failed() || $response->json('status') !== 'ok') {
|
if ($response->failed() || $response->json('status') !== 'ok') {
|
||||||
$errorMessage = $response->json('message') ?? $response->body();
|
$errorMessage = $response->json('message') ?? $response->body();
|
||||||
Log::error("Telepagos {$actionDescription} failed: {$errorMessage}", array_merge([
|
Log::channel('telepagos')->error("Telepagos {$actionDescription} failed: {$errorMessage}", array_merge([
|
||||||
'response_status' => $response->status(),
|
'response_status' => $response->status(),
|
||||||
'response_body' => $response->json() ?? $response->body(),
|
'response_body' => $this->sanitizeForLog($response->json() ?? $response->body()),
|
||||||
|
'client_id' => $this->clientContext?->id,
|
||||||
|
'integration_code' => $this->integrationCode,
|
||||||
], $context));
|
], $context));
|
||||||
|
|
||||||
throw new Exception("Telepagos {$actionDescription} failed: {$errorMessage}");
|
throw new Exception("Telepagos {$actionDescription} failed: {$errorMessage}");
|
||||||
@@ -203,21 +191,46 @@ class TelepagosIntegrationService extends BaseIntegrationService
|
|||||||
return $response->json() ?? [];
|
return $response->json() ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove credentials and tokens before serializing provider responses.
|
||||||
|
*/
|
||||||
|
protected function sanitizeForLog(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (! is_array($value)) {
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
$sensitiveKeys = ['authorization', 'password', 'token', 'access_token', 'refresh_token'];
|
||||||
|
|
||||||
|
foreach ($value as $key => $item) {
|
||||||
|
if (in_array(strtolower((string) $key), $sensitiveKeys, true)) {
|
||||||
|
$value[$key] = '[REDACTED]';
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$value[$key] = $this->sanitizeForLog($item);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear the cached token.
|
* Clear the cached token.
|
||||||
*
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function clearToken(): void
|
public function clearToken(): void
|
||||||
{
|
{
|
||||||
$cacheKey = "integration_token:{$this->tenantCode}:{$this->integrationCode}";
|
if (! $this->integrationInstance) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cacheKey = $this->integrationInstance->tokenCacheKey();
|
||||||
Cache::forget($cacheKey);
|
Cache::forget($cacheKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform initial setup validation for Telepagos.
|
* Perform initial setup validation for Telepagos.
|
||||||
*
|
*
|
||||||
* @return void
|
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function onSetup(): void
|
public function onSetup(): void
|
||||||
|
|||||||
@@ -2,12 +2,15 @@
|
|||||||
|
|
||||||
namespace App\Domains\Integration\Services;
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||||
use App\Domains\Purchase\Models\TelepagosQr;
|
use App\Domains\Purchase\Models\TelepagosQr;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Purchase\Services\DniDistanceService;
|
||||||
use Exception;
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
@@ -15,6 +18,7 @@ class TelepagosWebhookService
|
|||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutService $checkoutService,
|
private readonly CheckoutService $checkoutService,
|
||||||
|
private readonly DniDistanceService $dniDistance,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,12 +26,15 @@ class TelepagosWebhookService
|
|||||||
*
|
*
|
||||||
* @throws Exception
|
* @throws Exception
|
||||||
*/
|
*/
|
||||||
public function handleWebhook(string $tenantCodigo, string $cashinId): void
|
public function handleWebhook(Client $client, string $cashinId): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::where('codigo', $tenantCodigo)->firstOrFail();
|
Log::channel('telepagos')->info('Telepagos webhook received.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
]);
|
||||||
|
|
||||||
$telepagosService = new TelepagosIntegrationService;
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
$telepagosService->forTenant($tenant->codigo);
|
$telepagosService->forClient($client);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$details = $telepagosService->getCashinDetails($cashinId);
|
$details = $telepagosService->getCashinDetails($cashinId);
|
||||||
@@ -36,84 +43,8 @@ class TelepagosWebhookService
|
|||||||
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
||||||
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
||||||
|
|
||||||
$transferenciaOperationIds = [1, 3, 11];
|
|
||||||
$qrOperationIds = [31, 37, 47];
|
|
||||||
|
|
||||||
$compra = null;
|
|
||||||
|
|
||||||
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
|
|
||||||
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
|
|
||||||
|
|
||||||
if (! $cuit) {
|
|
||||||
Log::warning("Telepagos webhook: CUIT not found for Transferencia cashin {$cashinId}");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$dni = substr($cuit, 2, -1);
|
|
||||||
|
|
||||||
$compra = Purchase::where('tenant_codigo', $tenantCodigo)
|
|
||||||
->where('transfer_payer_dni', $dni)
|
|
||||||
->whereIn('status', [
|
|
||||||
Purchase::STATUS_CREATED,
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
|
||||||
])
|
|
||||||
->where('payment_method', 'transfer')
|
|
||||||
->where('total', $amount)
|
|
||||||
->latest()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $compra) {
|
|
||||||
Log::warning("Telepagos webhook: No matching purchase found for DNI {$dni} and amount {$amount} for cashin {$cashinId}");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
|
||||||
if (! $qrOrderId) {
|
|
||||||
Log::warning("Telepagos webhook: qr_order_id not found for QR cashin {$cashinId}");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
|
||||||
|
|
||||||
if (! $telepagosQr) {
|
|
||||||
Log::warning("Telepagos webhook: QR {$qrOrderId} not found in database for cashin {$cashinId}");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$compra = $telepagosQr->compra;
|
|
||||||
|
|
||||||
if (! $compra) {
|
|
||||||
Log::warning("Telepagos webhook: Purchase not found for QR {$qrOrderId}");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($compra->status, [
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
|
||||||
], true)) {
|
|
||||||
Log::warning("Telepagos webhook: Purchase {$compra->id} is not awaiting payment confirmation");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
|
|
||||||
|
|
||||||
if ($amount !== $totalAmount) {
|
|
||||||
Log::warning("Telepagos webhook: Amount mismatch. Cashin amount: {$amount}, Purchase amount: {$totalAmount}");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log::warning("Telepagos webhook: Unknown operation_id {$operationId} for cashin {$cashinId}");
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$paymentData = [
|
$paymentData = [
|
||||||
'compra_id' => $compra->id,
|
'compra_id' => null,
|
||||||
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||||
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||||
'amount' => $amount,
|
'amount' => $amount,
|
||||||
@@ -125,14 +56,185 @@ class TelepagosWebhookService
|
|||||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
$transferenciaOperationIds = [1, 3, 11];
|
||||||
|
$qrOperationIds = [31, 37, 47];
|
||||||
|
|
||||||
|
$compra = null;
|
||||||
|
|
||||||
|
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
|
||||||
|
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
|
||||||
|
|
||||||
|
if (! $cuit) {
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: CUIT not found for transfer.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$dni = substr($cuit, 2, -1);
|
||||||
|
|
||||||
|
$tenantCodes = $client->tenants()->pluck('codigo');
|
||||||
|
$eligiblePurchases = Purchase::query()
|
||||||
|
->whereIn('tenant_codigo', $tenantCodes)
|
||||||
|
->whereIn('status', [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
])
|
||||||
|
->where('payment_method', 'transfer');
|
||||||
|
|
||||||
|
$purchases = (clone $eligiblePurchases)
|
||||||
|
->where('total', $amount)
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
->filter(fn (Purchase $purchase): bool => $purchase->transfer_payer_dni !== null
|
||||||
|
&& $this->dniDistance->distance($dni, $purchase->transfer_payer_dni) === 0)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
$compra = $purchases->count() === 1 ? $purchases->first() : null;
|
||||||
|
|
||||||
|
if (! $compra) {
|
||||||
|
$candidatePurchases = $this->findTransferCandidates(
|
||||||
|
$eligiblePurchases,
|
||||||
|
$dni,
|
||||||
|
$amount,
|
||||||
|
);
|
||||||
|
if ($candidatePurchases->isNotEmpty()) {
|
||||||
|
$payment = $this->storeTransferCandidates(
|
||||||
|
$paymentData,
|
||||||
|
$candidatePurchases,
|
||||||
|
$dni,
|
||||||
|
$amount,
|
||||||
|
);
|
||||||
|
|
||||||
|
Log::channel('telepagos')->info('Telepagos webhook: Transfer payment candidates found.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'telepagos_payment_id' => $payment->id,
|
||||||
|
'amount' => $amount,
|
||||||
|
'candidate_count' => $payment->candidates->count(),
|
||||||
|
'candidates' => $payment->candidates
|
||||||
|
->map(fn ($candidate): array => [
|
||||||
|
'purchase_id' => $candidate->compra_id,
|
||||||
|
'match_reason' => $candidate->match_reason,
|
||||||
|
'dni_distance' => $candidate->dni_distance,
|
||||||
|
'amount_difference' => $candidate->amount_difference,
|
||||||
|
'confidence' => $candidate->confidence,
|
||||||
|
])
|
||||||
|
->all(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'amount' => $amount,
|
||||||
|
'matches' => $purchases->count(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
||||||
|
if (! $qrOrderId) {
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: qr_order_id not present in provider response.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
||||||
|
|
||||||
|
if (! $telepagosQr) {
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: QR not found in database.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'qr_order_id' => $qrOrderId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$compra = $telepagosQr->compra;
|
||||||
|
|
||||||
|
if (! $compra) {
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: Purchase not found for QR.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'qr_order_id' => $qrOrderId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'purchase_id' => $compra->id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($compra->status, [
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
], true)) {
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: Purchase is not awaiting payment confirmation.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'purchase_id' => $compra->id,
|
||||||
|
'purchase_status' => $compra->status,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
|
||||||
|
|
||||||
|
if ($amount !== $totalAmount) {
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: Amount mismatch.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'purchase_id' => $compra->id,
|
||||||
|
'cashin_amount' => $amount,
|
||||||
|
'purchase_amount' => $totalAmount,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log::channel('telepagos')->warning('Telepagos webhook: Unknown operation_id.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'operation_id' => $operationId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$paymentData['compra_id'] = $compra->id;
|
||||||
|
|
||||||
DB::transaction(function () use ($compra, $paymentData) {
|
DB::transaction(function () use ($compra, $paymentData) {
|
||||||
TelepagosPayment::create($paymentData);
|
TelepagosPayment::create($paymentData);
|
||||||
$this->checkoutService->confirmPaidPurchase($compra);
|
$this->checkoutService->confirmPaidPurchase($compra);
|
||||||
});
|
});
|
||||||
|
|
||||||
Log::info("Telepagos webhook: Successfully processed cashin {$cashinId} for purchase {$compra->id}");
|
Log::channel('telepagos')->info('Telepagos webhook processed successfully.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'purchase_id' => $compra->id,
|
||||||
|
'transaction_id' => $paymentData['transaction_id'],
|
||||||
|
]);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
Log::error('Telepagos webhook error: '.$e->getMessage());
|
Log::channel('telepagos')->error('Telepagos webhook processing failed.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
throw $e;
|
throw $e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -141,4 +243,91 @@ class TelepagosWebhookService
|
|||||||
{
|
{
|
||||||
return number_format((float) $amount, 2, '.', '');
|
return number_format((float) $amount, 2, '.', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<Purchase> $eligiblePurchases
|
||||||
|
* @return Collection<int, Purchase>
|
||||||
|
*/
|
||||||
|
private function findTransferCandidates(Builder $eligiblePurchases, string $dni, string $amount): Collection
|
||||||
|
{
|
||||||
|
$tolerancePercentage = max(
|
||||||
|
0,
|
||||||
|
(float) config('purchase.transfer_candidate_amount_tolerance_percentage', 5),
|
||||||
|
);
|
||||||
|
$numericAmount = (float) $amount;
|
||||||
|
$tolerance = $numericAmount * ($tolerancePercentage / 100);
|
||||||
|
$minimumAmount = $this->normalizeAmount(max(0, $numericAmount - $tolerance));
|
||||||
|
$maximumAmount = $this->normalizeAmount($numericAmount + $tolerance);
|
||||||
|
|
||||||
|
return (clone $eligiblePurchases)
|
||||||
|
->whereBetween('total', [$minimumAmount, $maximumAmount])
|
||||||
|
->latest()
|
||||||
|
->get()
|
||||||
|
->filter(function (Purchase $purchase) use ($dni, $amount): bool {
|
||||||
|
if ($purchase->transfer_payer_dni === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
||||||
|
$distance = $this->dniDistance->distance(
|
||||||
|
$dni,
|
||||||
|
(string) $purchase->transfer_payer_dni,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $distance === 0
|
||||||
|
|| ($purchaseAmount === $amount && $distance <= 2);
|
||||||
|
})
|
||||||
|
->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $paymentData
|
||||||
|
* @param Collection<int, Purchase> $candidatePurchases
|
||||||
|
*/
|
||||||
|
private function storeTransferCandidates(
|
||||||
|
array $paymentData,
|
||||||
|
Collection $candidatePurchases,
|
||||||
|
string $dni,
|
||||||
|
string $amount,
|
||||||
|
): TelepagosPayment {
|
||||||
|
return DB::transaction(function () use ($paymentData, $candidatePurchases, $dni, $amount): TelepagosPayment {
|
||||||
|
$payment = TelepagosPayment::create($paymentData);
|
||||||
|
|
||||||
|
$payment->candidates()->createMany(
|
||||||
|
$candidatePurchases
|
||||||
|
->map(function (Purchase $purchase) use ($dni, $amount): array {
|
||||||
|
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
||||||
|
$dniDistance = $this->dniDistance->distance(
|
||||||
|
$dni,
|
||||||
|
(string) $purchase->transfer_payer_dni,
|
||||||
|
);
|
||||||
|
$dniMatches = $dniDistance === 0;
|
||||||
|
$amountMatches = $purchaseAmount === $amount;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'compra_id' => $purchase->id,
|
||||||
|
'dni_matches' => $dniMatches,
|
||||||
|
'dni_distance' => $dniDistance,
|
||||||
|
'payment_dni' => $dni,
|
||||||
|
'purchase_dni' => $purchase->transfer_payer_dni,
|
||||||
|
'amount_matches' => $amountMatches,
|
||||||
|
'payment_amount' => $amount,
|
||||||
|
'purchase_amount' => $purchaseAmount,
|
||||||
|
'amount_difference' => $this->normalizeAmount(
|
||||||
|
abs((float) $purchaseAmount - (float) $amount),
|
||||||
|
),
|
||||||
|
'match_reason' => $amountMatches
|
||||||
|
? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni')
|
||||||
|
: 'exact_dni_near_amount',
|
||||||
|
'confidence' => $amountMatches
|
||||||
|
? ($dniMatches ? 'exact' : 'medium')
|
||||||
|
: 'high',
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->all(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return $payment->load('candidates');
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Services;
|
|
||||||
|
|
||||||
use App\Domains\Integration\Models\Integration;
|
|
||||||
use App\Domains\Integration\Models\TenantIntegration;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class TenantIntegrationService
|
|
||||||
{
|
|
||||||
public function getTenantIntegration(string $tenantCode, string $integrationCode): ?TenantIntegration
|
|
||||||
{
|
|
||||||
return TenantIntegration::where('tenant_code', $tenantCode)
|
|
||||||
->where('integration_code', $integrationCode)
|
|
||||||
->first();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getAllForTenant(string $tenantCode)
|
|
||||||
{
|
|
||||||
return TenantIntegration::with('integration')
|
|
||||||
->where('tenant_code', $tenantCode)
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateOrCreateIntegration(string $tenantCode, Integration $integration, array $data): TenantIntegration
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($tenantCode, $integration, $data) {
|
|
||||||
$tenantIntegration = TenantIntegration::updateOrCreate(
|
|
||||||
[
|
|
||||||
'tenant_code' => $tenantCode,
|
|
||||||
'integration_code' => $integration->integration_code,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'integration_data' => $data,
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
$service = $this->resolveService($integration->integration_code);
|
|
||||||
if ($service) {
|
|
||||||
$service->forTenant($tenantCode)->onSetup();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $tenantIntegration;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function resolveService(string $integrationCode): ?BaseIntegrationService
|
|
||||||
{
|
|
||||||
switch ($integrationCode) {
|
|
||||||
case 'email':
|
|
||||||
return new MailService;
|
|
||||||
case 'telepagos':
|
|
||||||
case 'telepagos_homo':
|
|
||||||
return new TelepagosIntegrationService($integrationCode);
|
|
||||||
default:
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,48 @@
|
|||||||
# Dominio Integration
|
# Dominio Integration
|
||||||
|
|
||||||
## Propósito
|
## Modelo
|
||||||
|
|
||||||
Gestiona integraciones externas disponibles y su configuración por tenant. Incluye correo y pagos mediante Telepagos.
|
- `Integration`: catálogo, URL base, `integration_data_schema` y `requires_configuration`.
|
||||||
|
- `IntegrationInstance`: configuración interna concreta con nombre. `integration_data` se cifra con `EncryptedIntegrationData`, se almacena en `longText` y nunca se devuelve en la API.
|
||||||
|
- `ClientIntegration` y `WebsiteTypeIntegration`: asociaciones a instancias. La clave compuesta verifica el código de la instancia y la unicidad permite una instancia por integración y propietario.
|
||||||
|
|
||||||
## Modelo y seguridad
|
Las instancias no se administran directamente por HTTP. Cada configuración enviada desde un cliente o tipo de sitio crea una instancia interna nueva y reemplaza únicamente la asociación de ese propietario. Al reemplazar o desvincular una instancia, esta se elimina si ya no tiene asociaciones con ningún cliente ni tipo de sitio; las instancias compartidas se conservan mientras tengan al menos una asociación.
|
||||||
|
|
||||||
- `Integration`: definición global de una integración.
|
## Resolución
|
||||||
- `TenantIntegration`: configuración y credenciales de una integración para un tenant.
|
|
||||||
- `EncryptedIntegrationData`: cast que protege los datos sensibles persistidos.
|
|
||||||
- `TenantIntegrationService`: consulta y configura integraciones del tenant.
|
|
||||||
|
|
||||||
## Servicios externos
|
`BaseIntegrationService::forTenant()` busca primero la asociación del cliente y después la del tipo de sitio del tenant. Selecciona una configuración completa, sin mezclar credenciales entre niveles. Si una configuración está presente pero es inválida, produce un error en vez de recurrir a otra instancia.
|
||||||
|
|
||||||
- `BaseIntegrationService`: base para resolver configuración, URL y cliente del tenant.
|
`forClient()` usa únicamente la asociación del cliente: sin un tenant concreto no se elige un tipo de sitio. Si no existe una instancia y `requires_configuration` es verdadero, se genera un error. Para correo opcional, `MailService` usa el mailer global si no encuentra una instancia; cuando la encuentra, construye un transporte SMTP aislado identificado como `integration-smtp`.
|
||||||
- `MailService`: envío de correo usando la integración configurada.
|
|
||||||
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
|
||||||
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
|
|
||||||
|
|
||||||
## Endpoints
|
Telepagos utiliza una clave de caché basada en el ID de instancia y una huella del texto cifrado. Volver a configurar el servicio con `forClient()` o `forTenant()` carga la configuración actual.
|
||||||
|
|
||||||
- CRUD global bajo `/integrations`.
|
## Administración
|
||||||
- Consulta y configuración por tenant bajo `/{tenant_code}/integrations`.
|
|
||||||
- `POST /webhooks/telepagos/{tenant_codigo}` para notificaciones del proveedor.
|
|
||||||
|
|
||||||
## Dependencias y reglas
|
Todas estas rutas llevan el prefijo `/api`, requieren `auth:sanctum` y el rol global `admin` mediante `IntegrationPolicy`. Los roles `adminapp`, `scanner` y `user` no administran configuraciones.
|
||||||
|
|
||||||
Se integra con `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. Las credenciales no deben exponerse en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.
|
| Método | Ruta | Operación |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/clients/{client}/integrations[/{integration_code}]` | Consultar asociaciones directas. |
|
||||||
|
| PUT | `/clients/{client}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del cliente. Ejecuta el hook de configuración existente. |
|
||||||
|
| DELETE | `/clients/{client}/integrations/{integration_code}` | Desvincular. |
|
||||||
|
| GET | `/website-types/{codigo}/integrations[/{integration_code}]` | Consultar asociaciones del tipo de sitio. |
|
||||||
|
| PUT | `/website-types/{codigo}/integrations/{integration_code}` | Configurar: crea una instancia interna nueva y reemplaza solo la asociación del tipo de sitio. |
|
||||||
|
| DELETE | `/website-types/{codigo}/integrations/{integration_code}` | Desvincular. |
|
||||||
|
|
||||||
|
Los dos `PUT` reciben `integration_data`, un objeto completo validado según el esquema de la integración. La integración debe existir previamente en el catálogo interno. No hay endpoints públicos para administrar el catálogo ni las instancias directamente.
|
||||||
|
|
||||||
|
Las respuestas y consultas incluyen metadatos de `integration_instance`, pero nunca sus credenciales. La configuración del cliente conserva su mensaje de respuesta histórico; la del tipo de sitio usa un resource con envoltorio `data`.
|
||||||
|
|
||||||
|
## Webhooks y contexto operativo
|
||||||
|
|
||||||
|
`POST /webhooks/telepagos/{client}` conserva su contrato público con el proveedor y la validación de pertenencia de las compras al cliente. Como no recibe un tenant, requiere una asociación directa al cliente. Para usar una instancia compartida en ese flujo, asociarla también al cliente; la herencia por tipo de sitio no se aplica a esa URL.
|
||||||
|
|
||||||
|
`Notification` consume `MailService`; `Purchase` consume Telepagos. Los tenants mantienen el contexto operativo y el branding.
|
||||||
|
|
||||||
|
## Despliegue
|
||||||
|
|
||||||
|
Ejecutar `php artisan migrate` junto con este código. La migración `2026_09_04_000003` renombra `requires_client_configuration` a `requires_configuration` conservando sus valores. Los payloads del catálogo deben usar el nuevo nombre. Las migraciones previas trasladan el texto cifrado sin descifrarlo y no comparten instancias automáticamente.
|
||||||
|
|
||||||
|
## Logging
|
||||||
|
|
||||||
|
Telepagos registra eventos en el canal diario `telepagos`. El nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Se eliminan tokens y credenciales de las estructuras registradas.
|
||||||
|
|||||||
@@ -1,21 +1,25 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Integration\Controllers\IntegrationController;
|
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||||
use App\Domains\Integration\Controllers\TenantIntegrationController;
|
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||||
|
use App\Domains\Integration\Controllers\WebsiteTypeIntegrationController;
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::group(['prefix' => 'integrations'], function () {
|
Route::middleware(['auth:sanctum', 'can:manage,'.Integration::class])->group(function (): void {
|
||||||
Route::get('/', [IntegrationController::class, 'index']);
|
Route::prefix('website-types/{websiteType:codigo}/integrations')->group(function (): void {
|
||||||
Route::post('/', [IntegrationController::class, 'store']);
|
Route::get('/', [WebsiteTypeIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [WebsiteTypeIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
Route::put('/{integration_code}', [WebsiteTypeIntegrationController::class, 'store']);
|
||||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
Route::delete('/{integration_code}', [WebsiteTypeIntegrationController::class, 'destroy']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||||
|
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||||
|
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||||
|
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||||
|
Route::delete('/{integration_code}', [ClientIntegrationController::class, 'destroy']);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => '{tenant_code}/integrations'], function () {
|
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||||
Route::get('/', [TenantIntegrationController::class, 'index']);
|
|
||||||
Route::get('/{integration_code}', [TenantIntegrationController::class, 'show']);
|
|
||||||
Route::post('/{integration_code}', [TenantIntegrationController::class, 'store']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Route::post('webhooks/telepagos/{tenant_codigo}', [\App\Domains\Integration\Controllers\TelepagosWebhookController::class, 'handle']);
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user