Compare commits
84 Commits
refactor/s
...
1fdcfc1547
| Author | SHA1 | Date | |
|---|---|---|---|
| 1fdcfc1547 | |||
| ca4ea0aa6c | |||
| a4b5c2eb19 | |||
| 2feca2bed5 | |||
| a35ff69140 | |||
| 75152b53a4 | |||
| 99594b17e7 | |||
| 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 | |||
| 1f7bb35f63 |
@@ -5,6 +5,10 @@ APP_DEBUG=false
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||||
|
PURCHASE_QR_EXPIRATION_MINUTES=15
|
||||||
|
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
||||||
|
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
||||||
|
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
|
||||||
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||||
FRONTEND_URLS=http://localhost:4200
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
|
|||||||
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;
|
||||||
@@ -25,6 +26,7 @@ class ValidateResetPasswordAttemptController extends Controller
|
|||||||
$result = $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) {
|
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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()],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminCredentialVerifier
|
|||||||
public function verify(string $email, string $password): bool
|
public function verify(string $email, string $password): bool
|
||||||
{
|
{
|
||||||
$admin = User::query()
|
$admin = User::query()
|
||||||
->where('email', mb_strtolower(trim($email)))
|
->where('active_email', mb_strtolower(trim($email)))
|
||||||
->where('rol_codigo', RoleCode::Admin->value)
|
->where('rol_codigo', RoleCode::Admin->value)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -28,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();
|
||||||
|
|
||||||
@@ -65,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()
|
||||||
@@ -113,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()
|
||||||
@@ -152,14 +153,15 @@ class ResetPasswordAttemptService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateCode(string $email, string $code): string
|
public function validateCode(string $email, string $code, RoleCode $role = RoleCode::User): string
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
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();
|
||||||
|
|
||||||
@@ -207,14 +209,15 @@ class ResetPasswordAttemptService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function resetPassword(string $email, string $code, string $password): bool
|
public function resetPassword(string $email, string $code, string $password, RoleCode $role = RoleCode::User): bool
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$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();
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
|
||||||
|
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
|
||||||
|
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly OnTicketFeaturedGroupService $featuredGroupService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return OnTicketFeaturedGroupResource::collection(
|
||||||
|
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$featuredGroup = $this->featuredGroupService->create(
|
||||||
|
$this->onTicketTenant($request),
|
||||||
|
$request->validated(),
|
||||||
|
);
|
||||||
|
|
||||||
|
return OnTicketFeaturedGroupResource::make($featuredGroup)
|
||||||
|
->response()
|
||||||
|
->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(
|
||||||
|
UpsertOnTicketFeaturedGroupRequest $request,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
): OnTicketFeaturedGroupResource {
|
||||||
|
$tenant = $this->onTicketTenant($request);
|
||||||
|
|
||||||
|
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
||||||
|
|
||||||
|
return OnTicketFeaturedGroupResource::make(
|
||||||
|
$this->featuredGroupService->update(
|
||||||
|
$tenant,
|
||||||
|
$featuredGroup,
|
||||||
|
$request->validated(),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function onTicketTenant(Request $request): Tenant
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
|
abort_unless($tenant->website_type_code === 'onticket', 404);
|
||||||
|
|
||||||
|
return $tenant;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Requests\AdminApp;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'category_name' => ['required', 'string', 'max:255'],
|
||||||
|
'is_featured' => ['required', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin FeaturedGroup */
|
||||||
|
class OnTicketFeaturedGroupResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'code' => $this->code,
|
||||||
|
'category_id' => $this->category_id,
|
||||||
|
'category_name' => $this->category->nombre,
|
||||||
|
'group_name' => $this->group_name,
|
||||||
|
'is_featured' => $this->product_layout === ProductLayout::Row,
|
||||||
|
'type' => $this->source_type->value,
|
||||||
|
'product_layout' => $this->product_layout->value,
|
||||||
|
'group_layout' => $this->group_layout->value,
|
||||||
|
'order' => $this->group_order,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||||
|
use App\Domains\Catalog\Enums\GroupLayout;
|
||||||
|
use App\Domains\Catalog\Enums\ProductLayout;
|
||||||
|
use App\Domains\Catalog\Models\Category;
|
||||||
|
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class OnTicketFeaturedGroupService
|
||||||
|
{
|
||||||
|
/** @return Collection<int, FeaturedGroup> */
|
||||||
|
public function forTenant(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
return FeaturedGroup::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('source_type', FeaturedGroupSource::Category)
|
||||||
|
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
|
||||||
|
->with('category')
|
||||||
|
->orderBy('group_order')
|
||||||
|
->orderBy('id')
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{category_name: string, is_featured: bool} $data */
|
||||||
|
public function create(Tenant $tenant, array $data): FeaturedGroup
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
|
||||||
|
$category = $tenant->categories()->create([
|
||||||
|
'nombre' => $data['category_name'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$featuredGroup = FeaturedGroup::query()->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'source_type' => FeaturedGroupSource::Category,
|
||||||
|
'category_id' => $category->id,
|
||||||
|
'product_layout' => $this->productLayout($data['is_featured']),
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
'group_name' => $data['category_name'],
|
||||||
|
'group_order' => $this->nextOrder($tenant),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $featuredGroup->setRelation('category', $category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array{category_name: string, is_featured: bool} $data */
|
||||||
|
public function update(
|
||||||
|
Tenant $tenant,
|
||||||
|
FeaturedGroup $featuredGroup,
|
||||||
|
array $data,
|
||||||
|
): FeaturedGroup {
|
||||||
|
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
|
||||||
|
$featuredGroup = FeaturedGroup::query()
|
||||||
|
->whereKey($featuredGroup->getKey())
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->where('source_type', FeaturedGroupSource::Category)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$category = Category::query()
|
||||||
|
->whereKey($featuredGroup->category_id)
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->lockForUpdate()
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
$category->update(['nombre' => $data['category_name']]);
|
||||||
|
$featuredGroup->update([
|
||||||
|
'group_name' => $data['category_name'],
|
||||||
|
'product_layout' => $this->productLayout($data['is_featured']),
|
||||||
|
'group_layout' => GroupLayout::Paginated,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $featuredGroup->setRelation('category', $category);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function productLayout(bool $isFeatured): ProductLayout
|
||||||
|
{
|
||||||
|
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function nextOrder(Tenant $tenant): int
|
||||||
|
{
|
||||||
|
$maximumOrder = FeaturedGroup::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->max('group_order');
|
||||||
|
|
||||||
|
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,11 +21,16 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
- `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.
|
- `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.
|
||||||
|
|
||||||
## Endpoints de tienda
|
## Endpoints de tienda
|
||||||
|
|
||||||
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
|
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
|
||||||
|
|
||||||
|
## Endpoints administrativos
|
||||||
|
|
||||||
|
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
|
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
|
||||||
|
|||||||
15
app/Domains/Catalog/routes/adminapp.php
Normal file
15
app/Domains/Catalog/routes/adminapp.php
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('v1/adminapp/tenant')
|
||||||
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
|
->group(function (): void {
|
||||||
|
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
||||||
|
->name('adminapp.featured-groups.index');
|
||||||
|
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
||||||
|
->name('adminapp.featured-groups.store');
|
||||||
|
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
|
||||||
|
->name('adminapp.featured-groups.update');
|
||||||
|
});
|
||||||
@@ -15,3 +15,5 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
|||||||
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
|
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
|
||||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
require __DIR__.'/adminapp.php';
|
||||||
|
|||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
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);
|
||||||
@@ -10,6 +10,7 @@ use App\Domains\Desfile\Services\EntryService;
|
|||||||
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\Request;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
class EntryController extends Controller
|
class EntryController extends Controller
|
||||||
{
|
{
|
||||||
@@ -47,4 +48,10 @@ class EntryController extends Controller
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function destroyImage(Request $request): Response
|
||||||
|
{
|
||||||
|
$this->entryService->deleteImage($request->user()->tenant()->firstOrFail());
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,6 +166,19 @@ class EntryService
|
|||||||
return $this->current($tenant);
|
return $this->current($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function deleteImage(Tenant $tenant): void
|
||||||
|
{
|
||||||
|
$attachment = DB::transaction(function () use ($tenant): Attachment {
|
||||||
|
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
|
||||||
|
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
|
||||||
|
$entry->allAttachments()->detach($attachment->id);
|
||||||
|
|
||||||
|
return $attachment;
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->deleteIfUnused($attachment);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return Collection<string, ItemAttribute> */
|
/** @return Collection<string, ItemAttribute> */
|
||||||
private function itemAttributes(CatalogItem $entry): Collection
|
private function itemAttributes(CatalogItem $entry): Collection
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Desfile\Services;
|
|||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ class InvitationPurchaseProvisioner
|
|||||||
private const ALLOCATIONS = [
|
private const ALLOCATIONS = [
|
||||||
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
|
['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' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
|
||||||
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'VIP + LUNCH'],
|
['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'],
|
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ class InvitationPurchaseProvisioner
|
|||||||
$allocation['type'],
|
$allocation['type'],
|
||||||
);
|
);
|
||||||
|
|
||||||
$this->createPurchaseItem(
|
$purchaseItemId = $this->createPurchaseItem(
|
||||||
$purchaseId,
|
$purchaseId,
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$variant,
|
$variant,
|
||||||
@@ -70,6 +71,7 @@ class InvitationPurchaseProvisioner
|
|||||||
);
|
);
|
||||||
$this->createTicketAndCommitStock(
|
$this->createTicketAndCommitStock(
|
||||||
$purchaseId,
|
$purchaseId,
|
||||||
|
$purchaseItemId,
|
||||||
$userId,
|
$userId,
|
||||||
(int) $catalogItem->id,
|
(int) $catalogItem->id,
|
||||||
$variant,
|
$variant,
|
||||||
@@ -102,7 +104,11 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
private function userId(DateTimeInterface $now): int
|
private function userId(DateTimeInterface $now): int
|
||||||
{
|
{
|
||||||
$user = DB::table('users')->where('email', self::USER_EMAIL)->first();
|
$user = DB::table('users')
|
||||||
|
->where('active_email', self::USER_EMAIL)
|
||||||
|
->where('rol_codigo', 'user')
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->first();
|
||||||
|
|
||||||
if ($user !== null) {
|
if ($user !== null) {
|
||||||
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
if ($user->tenant_codigo !== self::TENANT_CODE) {
|
||||||
@@ -326,12 +332,13 @@ class InvitationPurchaseProvisioner
|
|||||||
int $seat,
|
int $seat,
|
||||||
string $type,
|
string $type,
|
||||||
DateTimeInterface $now,
|
DateTimeInterface $now,
|
||||||
): void {
|
): int {
|
||||||
if (DB::table('compra_items')
|
$existingId = DB::table('compra_items')
|
||||||
->where('compra_id', $purchaseId)
|
->where('compra_id', $purchaseId)
|
||||||
->where('source_variant_id', $variant->id)
|
->where('source_variant_id', $variant->id)
|
||||||
->exists()) {
|
->value('id');
|
||||||
return;
|
if ($existingId !== null) {
|
||||||
|
return (int) $existingId;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes = [
|
$attributes = [
|
||||||
@@ -341,7 +348,7 @@ class InvitationPurchaseProvisioner
|
|||||||
['name' => 'Asiento', 'value' => (string) $seat],
|
['name' => 'Asiento', 'value' => (string) $seat],
|
||||||
];
|
];
|
||||||
|
|
||||||
DB::table('compra_items')->insert([
|
return DB::table('compra_items')->insertGetId([
|
||||||
'compra_id' => $purchaseId,
|
'compra_id' => $purchaseId,
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
@@ -367,13 +374,18 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
private function createTicketAndCommitStock(
|
private function createTicketAndCommitStock(
|
||||||
int $purchaseId,
|
int $purchaseId,
|
||||||
|
int $purchaseItemId,
|
||||||
int $userId,
|
int $userId,
|
||||||
int $catalogItemId,
|
int $catalogItemId,
|
||||||
object $variant,
|
object $variant,
|
||||||
DateTimeInterface $now,
|
DateTimeInterface $now,
|
||||||
): void {
|
): void {
|
||||||
|
$purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id')
|
||||||
|
? ['source_purchase_item_id' => $purchaseItemId]
|
||||||
|
: ['source_purchase_id' => $purchaseId];
|
||||||
|
|
||||||
if (DB::table('tickets')
|
if (DB::table('tickets')
|
||||||
->where('source_purchase_id', $purchaseId)
|
->where($purchaseReference)
|
||||||
->where('source_variant_id', $variant->id)
|
->where('source_variant_id', $variant->id)
|
||||||
->exists()) {
|
->exists()) {
|
||||||
return;
|
return;
|
||||||
@@ -420,7 +432,7 @@ class InvitationPurchaseProvisioner
|
|||||||
'ticket' => (string) Str::uuid(),
|
'ticket' => (string) Str::uuid(),
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
'source_purchase_id' => $purchaseId,
|
...$purchaseReference,
|
||||||
'source_catalog_item_id' => $catalogItemId,
|
'source_catalog_item_id' => $catalogItemId,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
|
|||||||
@@ -14,4 +14,6 @@ Route::prefix('v1/adminapp/tenant/desfile')
|
|||||||
->name('adminapp.desfile.entries.image.replace');
|
->name('adminapp.desfile.entries.image.replace');
|
||||||
Route::patch('entries/image', [EntryController::class, 'updateImage'])
|
Route::patch('entries/image', [EntryController::class, 'updateImage'])
|
||||||
->name('adminapp.desfile.entries.image.update');
|
->name('adminapp.desfile.entries.image.update');
|
||||||
|
Route::delete('entries/image', [EntryController::class, 'destroyImage'])
|
||||||
|
->name('adminapp.desfile.entries.image.destroy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,27 +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_IN_REVIEW => 'En revisión',
|
|
||||||
Purchase::STATUS_PAID => 'Confirmada',
|
|
||||||
Purchase::STATUS_CANCELLED => 'Cancelada',
|
|
||||||
Purchase::STATUS_REJECTED => 'Rechazada',
|
|
||||||
Purchase::STATUS_EXPIRED => 'Vencida',
|
|
||||||
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
|
|
||||||
];
|
|
||||||
|
|
||||||
return [
|
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,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Models\Integration;
|
||||||
|
use App\Domains\Integration\Requests\StoreIntegrationRequest;
|
||||||
|
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
|
||||||
|
use Illuminate\Routing\Controller;
|
||||||
|
|
||||||
|
class IntegrationController extends Controller
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
return response()->json(Integration::all());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreIntegrationRequest $request)
|
||||||
|
{
|
||||||
|
$integration = Integration::create($request->validated());
|
||||||
|
|
||||||
|
return response()->json($integration, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Integration $integration)
|
||||||
|
{
|
||||||
|
return response()->json($integration);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateIntegrationRequest $request, Integration $integration)
|
||||||
|
{
|
||||||
|
$integration->update($request->validated());
|
||||||
|
|
||||||
|
return response()->json($integration->fresh());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Integration $integration)
|
||||||
|
{
|
||||||
|
$integration->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
||||||
|
use App\Domains\Integration\Services\TelepagosWebhookService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class TelepagosWebhookController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Handle the incoming Telepagos webhook.
|
||||||
|
*/
|
||||||
|
public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$cashinId = $request->validated('id');
|
||||||
|
|
||||||
|
$service->handleWebhook($client, $cashinId);
|
||||||
|
|
||||||
|
return response()->json(['status' => 'success']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return response()->json([
|
||||||
|
'status' => 'error',
|
||||||
|
'code' => 'integration.webhook_failed',
|
||||||
|
'message' => __('api.integration.webhook_failed'),
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Domains/Integration/Requests/StoreIntegrationRequest.php
Normal file
24
app/Domains/Integration/Requests/StoreIntegrationRequest.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreIntegrationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
|
||||||
|
'name' => ['required', 'string', 'max:255'],
|
||||||
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
|
'requires_client_configuration' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Domains/Integration/Requests/TelepagosWebhookRequest.php
Normal file
28
app/Domains/Integration/Requests/TelepagosWebhookRequest.php
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class TelepagosWebhookRequest extends FormRequest
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Determine if the user is authorized to make this request.
|
||||||
|
*/
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the validation rules that apply to the request.
|
||||||
|
*
|
||||||
|
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => ['required', 'string'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class UpdateIntegrationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$integration = $this->route('integration');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||||
|
'url' => ['nullable', 'url', 'max:255'],
|
||||||
|
'integration_data_schema' => ['nullable', 'array'],
|
||||||
|
'requires_client_configuration' => ['sometimes', 'boolean'],
|
||||||
|
// the code shouldn't ideally be updatable, but if it is:
|
||||||
|
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
333
app/Domains/Integration/Services/TelepagosWebhookService.php
Normal file
333
app/Domains/Integration/Services/TelepagosWebhookService.php
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Models\TelepagosPayment;
|
||||||
|
use App\Domains\Purchase\Models\TelepagosQr;
|
||||||
|
use App\Domains\Purchase\Services\CheckoutService;
|
||||||
|
use App\Domains\Purchase\Services\DniDistanceService;
|
||||||
|
use Exception;
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
|
||||||
|
class TelepagosWebhookService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly CheckoutService $checkoutService,
|
||||||
|
private readonly DniDistanceService $dniDistance,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle the Telepagos webhook notification.
|
||||||
|
*
|
||||||
|
* @throws Exception
|
||||||
|
*/
|
||||||
|
public function handleWebhook(Client $client, string $cashinId): void
|
||||||
|
{
|
||||||
|
Log::channel('telepagos')->info('Telepagos webhook received.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
|
$telepagosService->forClient($client);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$details = $telepagosService->getCashinDetails($cashinId);
|
||||||
|
|
||||||
|
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
|
||||||
|
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
||||||
|
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
||||||
|
|
||||||
|
$paymentData = [
|
||||||
|
'compra_id' => null,
|
||||||
|
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
||||||
|
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
||||||
|
'amount' => $amount,
|
||||||
|
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
|
||||||
|
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
|
||||||
|
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
|
||||||
|
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
|
||||||
|
'qr_order_id' => $qrOrderId,
|
||||||
|
'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) {
|
||||||
|
TelepagosPayment::create($paymentData);
|
||||||
|
$this->checkoutService->confirmPaidPurchase($compra);
|
||||||
|
});
|
||||||
|
|
||||||
|
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) {
|
||||||
|
Log::channel('telepagos')->error('Telepagos webhook processing failed.', [
|
||||||
|
'client_code' => $client->code,
|
||||||
|
'cashin_id' => $cashinId,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeAmount(mixed $amount): string
|
||||||
|
{
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,15 +16,18 @@ Gestiona integraciones externas disponibles y su configuración por cliente. Un
|
|||||||
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
|
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
|
||||||
- `MailService`: envío de correo usando la integración configurada.
|
- `MailService`: envío de correo usando la integración configurada.
|
||||||
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
||||||
|
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
- CRUD global bajo `/integrations`.
|
||||||
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
||||||
|
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
|
||||||
|
|
||||||
## Logging de Telepagos
|
## Logging de Telepagos
|
||||||
|
|
||||||
Los eventos de autenticación, QR y consultas de cuenta se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs.
|
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.
|
||||||
|
|||||||
@@ -1,10 +1,22 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||||
|
use App\Domains\Integration\Controllers\IntegrationController;
|
||||||
|
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::group(['prefix' => 'integrations'], function () {
|
||||||
|
Route::get('/', [IntegrationController::class, 'index']);
|
||||||
|
Route::post('/', [IntegrationController::class, 'store']);
|
||||||
|
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
||||||
|
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
||||||
|
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
||||||
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
||||||
|
|||||||
32
app/Domains/MailTest/Controllers/MailTestController.php
Normal file
32
app/Domains/MailTest/Controllers/MailTestController.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\MailTest\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\MailTest\Requests\SendTestMailRequest;
|
||||||
|
use App\Domains\MailTest\Services\MailTestService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class MailTestController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected MailTestService $mailTestService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(SendTestMailRequest $request, string $tenantCode): JsonResponse
|
||||||
|
{
|
||||||
|
$tenant = Tenant::query()
|
||||||
|
->where('codigo', $tenantCode)
|
||||||
|
->firstOrFail();
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
$this->mailTestService->send(
|
||||||
|
$tenant,
|
||||||
|
$request->validated('to'),
|
||||||
|
$request->validated('subject'),
|
||||||
|
$request->validated('message'),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
51
app/Domains/MailTest/Mailables/TestMail.php
Normal file
51
app/Domains/MailTest/Mailables/TestMail.php
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\MailTest\Mailables;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Bus\Queueable;
|
||||||
|
use Illuminate\Mail\Mailable;
|
||||||
|
use Illuminate\Mail\Mailables\Content;
|
||||||
|
use Illuminate\Mail\Mailables\Envelope;
|
||||||
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
|
class TestMail extends Mailable
|
||||||
|
{
|
||||||
|
use Queueable, SerializesModels;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
public readonly string $mailSubject,
|
||||||
|
public readonly string $mailMessage,
|
||||||
|
public readonly Tenant $tenant,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function envelope(): Envelope
|
||||||
|
{
|
||||||
|
return new Envelope(subject: $this->mailSubject);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function content(): Content
|
||||||
|
{
|
||||||
|
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||||
|
|
||||||
|
$branding = [
|
||||||
|
'name' => $this->tenant->nombre,
|
||||||
|
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
|
||||||
|
'body_color' => '#334155',
|
||||||
|
'background_color' => '#f1f5f9',
|
||||||
|
'surface_color' => '#ffffff',
|
||||||
|
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
|
||||||
|
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
|
||||||
|
];
|
||||||
|
|
||||||
|
return new Content(
|
||||||
|
view: 'mail.test',
|
||||||
|
with: [
|
||||||
|
'tenant' => $this->tenant,
|
||||||
|
'branding' => $branding,
|
||||||
|
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||||
|
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
25
app/Domains/MailTest/Requests/SendTestMailRequest.php
Normal file
25
app/Domains/MailTest/Requests/SendTestMailRequest.php
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\MailTest\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class SendTestMailRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'to' => ['required', 'string', 'email', 'max:255'],
|
||||||
|
'subject' => ['nullable', 'string', 'max:255'],
|
||||||
|
'message' => ['nullable', 'string', 'max:5000'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
35
app/Domains/MailTest/Services/MailTestService.php
Normal file
35
app/Domains/MailTest/Services/MailTestService.php
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\MailTest\Services;
|
||||||
|
|
||||||
|
use App\Domains\Integration\Services\MailService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
|
||||||
|
class MailTestService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function send(Tenant $tenant, string $recipient, ?string $subject = null, ?string $message = null): array
|
||||||
|
{
|
||||||
|
$subject ??= 'Prueba de correo de Shopit';
|
||||||
|
$message ??= 'Este es un correo de prueba enviado desde Shopit.';
|
||||||
|
|
||||||
|
$mailService = (new MailService)->forTenant($tenant->codigo);
|
||||||
|
$mailService->send(
|
||||||
|
$recipient,
|
||||||
|
$subject,
|
||||||
|
'<h1 style="margin: 0 0 20px;">'.e($subject).'</h1>'
|
||||||
|
.'<p>'.nl2br(e($message)).'</p>',
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'code' => 'mail.test_sent',
|
||||||
|
'message' => __('api.mail.test_sent'),
|
||||||
|
'recipient' => $recipient,
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'mailer' => $mailService->mailerName(),
|
||||||
|
'sent_at' => now()->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Domains/MailTest/documentacion/README.md
Normal file
24
app/Domains/MailTest/documentacion/README.md
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Dominio MailTest
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Ofrece una operación técnica para verificar la configuración de correo de un tenant sin ejecutar un flujo funcional real.
|
||||||
|
|
||||||
|
## Componentes
|
||||||
|
|
||||||
|
- `MailTestController`: endpoint invocable de envío.
|
||||||
|
- `SendTestMailRequest`: valida destinatario y contenido requerido.
|
||||||
|
- `MailTestService`: coordina el envío de prueba.
|
||||||
|
- `TestMail`: mailable utilizado para construir el mensaje.
|
||||||
|
|
||||||
|
## Endpoint
|
||||||
|
|
||||||
|
- `POST /{tenant_code}/mail-test/send`.
|
||||||
|
|
||||||
|
## Dependencias
|
||||||
|
|
||||||
|
Usa la configuración de correo del dominio `Integration` y resuelve el tenant indicado.
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
Es una herramienta de diagnóstico. Debe restringirse o deshabilitarse en entornos donde no corresponda exponer envíos de prueba, y nunca debe registrar credenciales.
|
||||||
6
app/Domains/MailTest/routes/api.php
Normal file
6
app/Domains/MailTest/routes/api.php
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\MailTest\Controllers\MailTestController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::post('{tenant_code}/mail-test/send', MailTestController::class);
|
||||||
79
app/Domains/Menu/Controllers/MenuController.php
Normal file
79
app/Domains/Menu/Controllers/MenuController.php
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Menu\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class MenuController extends Controller
|
||||||
|
{
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$menues = Menu::all();
|
||||||
|
|
||||||
|
return response()->json($menues);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(Request $request): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'code' => 'required|string|unique:menues,code',
|
||||||
|
'label' => 'required|string|max:255',
|
||||||
|
'parent_menu_code' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::exists('menues', 'code'),
|
||||||
|
'different:code',
|
||||||
|
],
|
||||||
|
'content_type' => [
|
||||||
|
'sometimes',
|
||||||
|
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
|
||||||
|
],
|
||||||
|
'static_content_schema' => 'required_if:content_type,static|nullable|array',
|
||||||
|
'route' => 'required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$menu = Menu::create($validated);
|
||||||
|
|
||||||
|
return response()->json($menu, 201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Menu $menu): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json($menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Request $request, Menu $menu): JsonResponse
|
||||||
|
{
|
||||||
|
$validated = $request->validate([
|
||||||
|
'code' => 'sometimes|required|string|unique:menues,code,'.$menu->id,
|
||||||
|
'label' => 'sometimes|required|string|max:255',
|
||||||
|
'parent_menu_code' => [
|
||||||
|
'nullable',
|
||||||
|
'string',
|
||||||
|
Rule::exists('menues', 'code'),
|
||||||
|
Rule::notIn([$menu->code]),
|
||||||
|
],
|
||||||
|
'content_type' => [
|
||||||
|
'sometimes',
|
||||||
|
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
|
||||||
|
],
|
||||||
|
'static_content_schema' => 'required_if:content_type,static|nullable|array',
|
||||||
|
'route' => 'sometimes|required|string',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$menu->update($validated);
|
||||||
|
|
||||||
|
return response()->json($menu);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Menu $menu): JsonResponse
|
||||||
|
{
|
||||||
|
$menu->delete();
|
||||||
|
|
||||||
|
return response()->json(null, 204);
|
||||||
|
}
|
||||||
|
}
|
||||||
32
app/Domains/Menu/Controllers/TenantMenuController.php
Normal file
32
app/Domains/Menu/Controllers/TenantMenuController.php
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Menu\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use App\Domains\Menu\Requests\StoreTenantMenuRequest;
|
||||||
|
use App\Domains\Menu\Services\TenantMenuService;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class TenantMenuController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TenantMenuService $tenantMenuService) {}
|
||||||
|
|
||||||
|
public function store(
|
||||||
|
StoreTenantMenuRequest $request,
|
||||||
|
string $tenantCode,
|
||||||
|
string $menuCode,
|
||||||
|
): JsonResponse {
|
||||||
|
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||||
|
$menu = Menu::query()->where('code', $menuCode)->firstOrFail();
|
||||||
|
|
||||||
|
$tenantMenu = $this->tenantMenuService->configure(
|
||||||
|
$tenant,
|
||||||
|
$menu,
|
||||||
|
$request->validated('static_content'),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json($tenantMenu);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
app/Domains/Menu/Requests/StoreTenantMenuRequest.php
Normal file
49
app/Domains/Menu/Requests/StoreTenantMenuRequest.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Menu\Requests;
|
||||||
|
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class StoreTenantMenuRequest extends FormRequest
|
||||||
|
{
|
||||||
|
private ?Menu $menuModel = null;
|
||||||
|
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareForValidation(): void
|
||||||
|
{
|
||||||
|
$this->menuModel = Menu::query()
|
||||||
|
->where('code', $this->route('menu_code'))
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (! $this->menuModel) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'menu_code' => __('api.menu.not_found'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
if ($this->menuModel?->content_type !== Menu::CONTENT_TYPE_STATIC) {
|
||||||
|
return [
|
||||||
|
'static_content' => ['nullable', 'array'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$rules = [
|
||||||
|
'static_content' => ['required', 'array'],
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($this->menuModel->static_content_schema as $field => $rule) {
|
||||||
|
$rules["static_content.{$field}"] = $rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/Menu/Services/TenantMenuService.php
Normal file
26
app/Domains/Menu/Services/TenantMenuService.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Menu\Services;
|
||||||
|
|
||||||
|
use App\Domains\Menu\Models\Menu;
|
||||||
|
use App\Domains\Menu\Models\TenantMenu;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class TenantMenuService
|
||||||
|
{
|
||||||
|
public function configure(Tenant $tenant, Menu $menu, ?array $staticContent): TenantMenu
|
||||||
|
{
|
||||||
|
return DB::transaction(fn () => TenantMenu::query()->updateOrCreate(
|
||||||
|
[
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'menu_code' => $menu->code,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'static_content' => $menu->content_type === Menu::CONTENT_TYPE_STATIC
|
||||||
|
? $staticContent
|
||||||
|
: null,
|
||||||
|
],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,15 @@ Define menús disponibles y permite configurar su contenido para cada tenant y r
|
|||||||
- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde.
|
- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde.
|
||||||
- `MenuRole`: asociación entre menú y rol autorizado.
|
- `MenuRole`: asociación entre menú y rol autorizado.
|
||||||
|
|
||||||
|
## Servicios
|
||||||
|
|
||||||
|
`TenantMenuService::configure()` crea o actualiza atómicamente la configuración de un menú para un tenant. Solo conserva `static_content` cuando el menú fue definido como contenido estático.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
- Recurso REST `/menues` mediante `MenuController`.
|
||||||
|
- `POST /{tenant_code}/menues/{menu_code}` para configurar un menú del tenant.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Depende de `Tenant` y `Authorization`. Los menús se configuran mediante seeders y relaciones internas; no se exponen endpoints públicos de administración.
|
Depende de `Tenant` y `Authorization`. Los códigos de menú y tenant forman la identidad lógica de la configuración; el contenido enviado debe respetar el tipo definido por `Menu`.
|
||||||
|
|||||||
12
app/Domains/Menu/routes/api.php
Normal file
12
app/Domains/Menu/routes/api.php
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Menu\Controllers\MenuController;
|
||||||
|
use App\Domains\Menu\Controllers\TenantMenuController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::apiResource('menues', MenuController::class);
|
||||||
|
|
||||||
|
Route::post(
|
||||||
|
'{tenant_code}/menues/{menu_code}',
|
||||||
|
[TenantMenuController::class, 'store']
|
||||||
|
);
|
||||||
@@ -32,13 +32,14 @@ class NotificationMailService
|
|||||||
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
||||||
$user = User::query()->findOrFail($userId);
|
$user = User::query()->findOrFail($userId);
|
||||||
$brand = $tenant->websiteType ?? $tenant;
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
$tenantUrl = 'https://'.$tenant->dominio.$tenant->base_path;
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
->send(
|
->send(
|
||||||
$user->email,
|
$user->email,
|
||||||
"Bienvenido a {$brand->nombre}",
|
"Bienvenido a {$brand->nombre}",
|
||||||
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
|
view('mail.notifications.welcome', compact('brand', 'user', 'tenantUrl'))->render(),
|
||||||
$brand,
|
$brand,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -99,12 +100,25 @@ class NotificationMailService
|
|||||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||||
$brand = $tenant->websiteType ?? $tenant;
|
$brand = $tenant->websiteType ?? $tenant;
|
||||||
|
|
||||||
|
[$subject, $template] = match ($attempt->reason) {
|
||||||
|
ResetPasswordAttempt::REASON_STAFF_CREATED => [
|
||||||
|
'Tu cuenta de escáner está lista', 'scanner-created',
|
||||||
|
],
|
||||||
|
ResetPasswordAttempt::REASON_ADMINISTRATOR_CREATED => [
|
||||||
|
'Tu cuenta de administrador está lista', 'administrator-created',
|
||||||
|
],
|
||||||
|
ResetPasswordAttempt::REASON_ACCOUNT_LOCKED => [
|
||||||
|
'Desbloqueá tu cuenta', 'account-locked',
|
||||||
|
],
|
||||||
|
default => ['Código para recuperar tu contraseña', 'password-reset'],
|
||||||
|
};
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
->send(
|
->send(
|
||||||
$attempt->user->email,
|
$attempt->user->email,
|
||||||
"Código para recuperar tu contraseña - {$brand->nombre}",
|
"{$subject} - {$brand->nombre}",
|
||||||
view('mail.notifications.password-reset', [
|
view("mail.notifications.{$template}", [
|
||||||
'attempt' => $attempt,
|
'attempt' => $attempt,
|
||||||
'recoveryUrl' => $recoveryUrl,
|
'recoveryUrl' => $recoveryUrl,
|
||||||
'brand' => $brand,
|
'brand' => $brand,
|
||||||
@@ -138,8 +152,7 @@ class NotificationMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<int, Ticket> $tickets */
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
$tickets = Ticket::query()
|
$tickets = $purchase->tickets()
|
||||||
->where('source_purchase_id', $purchase->getKey())
|
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
->with(TicketPresentationResolver::RELATIONS)
|
->with(TicketPresentationResolver::RELATIONS)
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -243,6 +243,15 @@ class PurchaseController extends Controller
|
|||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function complete(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
|
||||||
|
{
|
||||||
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
|
|
||||||
|
return PurchaseResource::make(
|
||||||
|
$checkoutService->completePurchase($compra)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function submitForReview(
|
public function submitForReview(
|
||||||
Request $request,
|
Request $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ 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\Database\Eloquent\Relations\HasManyThrough;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@@ -51,6 +52,14 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public const STATUS_SUPERSEDED = 'superseded';
|
public const STATUS_SUPERSEDED = 'superseded';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_INCOMPLETE = 'incomplete';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_AWAITING_PAYMENT = 'awaiting_payment';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_CONFIRMED = 'confirmed';
|
||||||
|
|
||||||
|
public const ADMIN_STATUS_CANCELLED = 'cancelled';
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public static function statuses(): array
|
public static function statuses(): array
|
||||||
{
|
{
|
||||||
@@ -66,6 +75,68 @@ class Purchase extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array{name: string, statuses: list<string>}>
|
||||||
|
*/
|
||||||
|
public static function adminStatuses(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
self::ADMIN_STATUS_INCOMPLETE => [
|
||||||
|
'name' => 'Por completar datos',
|
||||||
|
'statuses' => [self::STATUS_CREATED],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_AWAITING_PAYMENT => [
|
||||||
|
'name' => 'Esperando pago',
|
||||||
|
'statuses' => [self::STATUS_PENDING_PAYMENT, self::STATUS_IN_REVIEW],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_CONFIRMED => [
|
||||||
|
'name' => 'Confirmado',
|
||||||
|
'statuses' => [self::STATUS_PAID],
|
||||||
|
],
|
||||||
|
self::ADMIN_STATUS_CANCELLED => [
|
||||||
|
'name' => 'Anulado',
|
||||||
|
'statuses' => [
|
||||||
|
self::STATUS_CANCELLED,
|
||||||
|
self::STATUS_REJECTED,
|
||||||
|
self::STATUS_EXPIRED,
|
||||||
|
self::STATUS_SUPERSEDED,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function adminStatusCodes(): array
|
||||||
|
{
|
||||||
|
return array_keys(self::adminStatuses());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public static function realStatusesForAdminStatus(string $adminStatus): array
|
||||||
|
{
|
||||||
|
return self::adminStatuses()[$adminStatus]['statuses'] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adminStatusFor(string $realStatus): ?string
|
||||||
|
{
|
||||||
|
foreach (self::adminStatuses() as $adminStatus => $definition) {
|
||||||
|
if (in_array($realStatus, $definition['statuses'], true)) {
|
||||||
|
return $adminStatus;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function adminStatusNameFor(string $realStatus): ?string
|
||||||
|
{
|
||||||
|
$adminStatus = self::adminStatusFor($realStatus);
|
||||||
|
|
||||||
|
return $adminStatus === null
|
||||||
|
? null
|
||||||
|
: self::adminStatuses()[$adminStatus]['name'];
|
||||||
|
}
|
||||||
|
|
||||||
protected $table = 'compras';
|
protected $table = 'compras';
|
||||||
|
|
||||||
/** @var array<int, string> */
|
/** @var array<int, string> */
|
||||||
@@ -115,12 +186,15 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return HasManyThrough<Ticket, PurchaseItem, $this> */
|
||||||
* @return HasMany<Ticket, $this>
|
public function tickets(): HasManyThrough
|
||||||
*/
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
{
|
||||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
return $this->hasManyThrough(
|
||||||
|
Ticket::class,
|
||||||
|
PurchaseItem::class,
|
||||||
|
'compra_id',
|
||||||
|
'source_purchase_item_id',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<StockReservation, $this> */
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
@@ -145,6 +219,12 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
||||||
|
public function telepagosPaymentCandidates(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id');
|
||||||
|
}
|
||||||
|
|
||||||
public function getTotalAmount(): float
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
if ($this->total !== null) {
|
if ($this->total !== null) {
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ namespace App\Domains\Purchase\Models;
|
|||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'compra_id',
|
'compra_id',
|
||||||
@@ -56,6 +58,12 @@ class PurchaseItem extends Model
|
|||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<Ticket, $this> */
|
||||||
|
public function tickets(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(Ticket::class, 'source_purchase_item_id');
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Attachment, $this> */
|
/** @return BelongsTo<Attachment, $this> */
|
||||||
public function imageAttachment(): BelongsTo
|
public function imageAttachment(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'compra_id',
|
'compra_id',
|
||||||
'matched_purchase_ids',
|
|
||||||
'cuit_buyer',
|
'cuit_buyer',
|
||||||
'cvu_buyer',
|
'cvu_buyer',
|
||||||
'amount',
|
'amount',
|
||||||
@@ -30,7 +30,6 @@ class TelepagosPayment extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'compra_id' => 'integer',
|
'compra_id' => 'integer',
|
||||||
'matched_purchase_ids' => 'array',
|
|
||||||
'amount' => 'decimal:2',
|
'amount' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -42,4 +41,10 @@ class TelepagosPayment extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
||||||
|
public function candidates(): HasMany
|
||||||
|
{
|
||||||
|
return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
50
app/Domains/Purchase/Models/TelepagosPaymentCandidate.php
Normal file
50
app/Domains/Purchase/Models/TelepagosPaymentCandidate.php
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'telepagos_payment_id',
|
||||||
|
'compra_id',
|
||||||
|
'dni_matches',
|
||||||
|
'dni_distance',
|
||||||
|
'payment_dni',
|
||||||
|
'purchase_dni',
|
||||||
|
'amount_matches',
|
||||||
|
'payment_amount',
|
||||||
|
'purchase_amount',
|
||||||
|
'amount_difference',
|
||||||
|
'match_reason',
|
||||||
|
'confidence',
|
||||||
|
])]
|
||||||
|
class TelepagosPaymentCandidate extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'telepagos_payment_candidates';
|
||||||
|
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'telepagos_payment_id' => 'integer',
|
||||||
|
'compra_id' => 'integer',
|
||||||
|
'dni_matches' => 'boolean',
|
||||||
|
'dni_distance' => 'integer',
|
||||||
|
'amount_matches' => 'boolean',
|
||||||
|
'payment_amount' => 'decimal:2',
|
||||||
|
'purchase_amount' => 'decimal:2',
|
||||||
|
'amount_difference' => 'decimal:2',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function payment(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(TelepagosPayment::class, 'telepagos_payment_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function purchase(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Resources;
|
|||||||
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
|
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class PurchaseResource extends JsonResource
|
|||||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||||
? (int) $this->resource->getAttribute('tickets_count')
|
? (int) $this->resource->getAttribute('tickets_count')
|
||||||
: null;
|
: null;
|
||||||
|
$paymentVerification = $this->resolvePaymentVerification();
|
||||||
|
|
||||||
$subtotal = $items->isNotEmpty()
|
$subtotal = $items->isNotEmpty()
|
||||||
? $items->reduce(
|
? $items->reduce(
|
||||||
@@ -64,6 +66,7 @@ class PurchaseResource extends JsonResource
|
|||||||
'items' => PurchaseItemResource::collection($items),
|
'items' => PurchaseItemResource::collection($items),
|
||||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||||
|
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
|
||||||
'subtotal' => $this->formatMoney($subtotal),
|
'subtotal' => $this->formatMoney($subtotal),
|
||||||
'total' => $this->formatMoney($total),
|
'total' => $this->formatMoney($total),
|
||||||
];
|
];
|
||||||
@@ -83,4 +86,75 @@ class PurchaseResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed>|null */
|
||||||
|
private function resolvePaymentVerification(): ?array
|
||||||
|
{
|
||||||
|
if (
|
||||||
|
$this->status !== Purchase::STATUS_IN_REVIEW
|
||||||
|
|| $this->payment_method !== 'transfer'
|
||||||
|
|| ! $this->resource->relationLoaded('telepagosPaymentCandidates')
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$candidates = $this->resource
|
||||||
|
->getRelation('telepagosPaymentCandidates')
|
||||||
|
->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right))
|
||||||
|
->values();
|
||||||
|
/** @var TelepagosPaymentCandidate|null $primary */
|
||||||
|
$primary = $candidates->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'status' => $primary === null ? 'pending' : 'candidate',
|
||||||
|
'candidate_count' => $candidates->count(),
|
||||||
|
'primary' => $primary === null ? null : [
|
||||||
|
'reason' => $primary->match_reason,
|
||||||
|
'dni_distance' => $primary->dni_distance,
|
||||||
|
'payment_amount' => $this->formatMoney($primary->payment_amount),
|
||||||
|
'purchase_amount' => $this->formatMoney($primary->purchase_amount),
|
||||||
|
'amount_difference' => $this->formatMoney($primary->amount_difference),
|
||||||
|
'confidence' => $primary->confidence,
|
||||||
|
'detected_at' => $primary->payment?->created_at?->toIso8601String(),
|
||||||
|
],
|
||||||
|
'reasons' => $candidates
|
||||||
|
->pluck('match_reason')
|
||||||
|
->unique()
|
||||||
|
->values()
|
||||||
|
->all(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function comparePaymentCandidates(
|
||||||
|
TelepagosPaymentCandidate $left,
|
||||||
|
TelepagosPaymentCandidate $right,
|
||||||
|
): int {
|
||||||
|
$reasonComparison = $this->paymentCandidateRank($left->match_reason)
|
||||||
|
<=> $this->paymentCandidateRank($right->match_reason);
|
||||||
|
|
||||||
|
if ($reasonComparison !== 0) {
|
||||||
|
return $reasonComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
$differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference;
|
||||||
|
|
||||||
|
if ($differenceComparison !== 0) {
|
||||||
|
return $differenceComparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
$leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0;
|
||||||
|
$rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0;
|
||||||
|
|
||||||
|
return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function paymentCandidateRank(string $reason): int
|
||||||
|
{
|
||||||
|
return match ($reason) {
|
||||||
|
'ambiguous_exact_match' => 0,
|
||||||
|
'exact_dni_near_amount' => 1,
|
||||||
|
'exact_amount_near_dni' => 2,
|
||||||
|
default => 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,33 @@ class CompleteCheckoutService
|
|||||||
private readonly PurchaseStateGuard $purchaseState,
|
private readonly PurchaseStateGuard $purchaseState,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
public function complete(Purchase $purchase): Purchase
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($purchase): Purchase {
|
||||||
|
$purchase = $this->lockPurchase($purchase);
|
||||||
|
$this->purchaseState->assertNotExpired($purchase);
|
||||||
|
|
||||||
|
if ($purchase->payment_method === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'payment_method' => __('api.purchase.payment_method_required'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($this->isTerminal($purchase)) {
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
|
$purchase->update([
|
||||||
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
public function submitForReview(Purchase $purchase): Purchase
|
public function submitForReview(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($purchase): Purchase {
|
return DB::transaction(function () use ($purchase): Purchase {
|
||||||
@@ -144,6 +171,18 @@ class CompleteCheckoutService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function isTerminal(Purchase $purchase): bool
|
||||||
|
{
|
||||||
|
return in_array($purchase->status, [
|
||||||
|
Purchase::STATUS_PAID,
|
||||||
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
Purchase::STATUS_CANCELLED,
|
||||||
|
Purchase::STATUS_REJECTED,
|
||||||
|
Purchase::STATUS_EXPIRED,
|
||||||
|
Purchase::STATUS_SUPERSEDED,
|
||||||
|
], true);
|
||||||
|
}
|
||||||
|
|
||||||
private function lockPurchase(Purchase $purchase): Purchase
|
private function lockPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
/** @var Purchase */
|
/** @var Purchase */
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ class PurchaseResponseLoader
|
|||||||
{
|
{
|
||||||
public function load(Purchase $purchase): Purchase
|
public function load(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']);
|
$relations = ['tenant', 'items.imageAttachment', 'stockReservation'];
|
||||||
|
|
||||||
|
if (
|
||||||
|
$purchase->status === Purchase::STATUS_IN_REVIEW
|
||||||
|
&& $purchase->payment_method === 'transfer'
|
||||||
|
) {
|
||||||
|
$relations[] = 'telepagosPaymentCandidates.payment';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $purchase->load($relations);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,11 @@ class ReleaseCheckoutService
|
|||||||
): Purchase {
|
): Purchase {
|
||||||
$purchase = $this->lockPurchase($purchase);
|
$purchase = $this->lockPurchase($purchase);
|
||||||
|
|
||||||
|
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||||
|
&& $targetStatus === Purchase::STATUS_CANCELLED) {
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
if ($purchase->status === Purchase::STATUS_EXPIRED
|
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||||
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||||
throw new PurchaseExpiredException;
|
throw new PurchaseExpiredException;
|
||||||
@@ -72,6 +77,14 @@ class ReleaseCheckoutService
|
|||||||
|
|
||||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||||
|
|
||||||
|
// Leaving checkout is allowed at the exact instant the reservation
|
||||||
|
// expires. Finish the expiration while holding the purchase lock so
|
||||||
|
// the request is idempotent with the scheduled expiration job.
|
||||||
|
if ($targetStatus === Purchase::STATUS_CANCELLED
|
||||||
|
&& $this->hasOverdueActiveReservation($purchase)) {
|
||||||
|
$targetStatus = Purchase::STATUS_EXPIRED;
|
||||||
|
}
|
||||||
|
|
||||||
if ($targetStatus === Purchase::STATUS_CANCELLED
|
if ($targetStatus === Purchase::STATUS_CANCELLED
|
||||||
&& $cart?->status === 'active'
|
&& $cart?->status === 'active'
|
||||||
&& in_array($purchase->status, [
|
&& in_array($purchase->status, [
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ class CheckoutService
|
|||||||
return $this->starter->start($tenant, $userId, $purchaseData);
|
return $this->starter->start($tenant, $userId, $purchaseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function completePurchase(Purchase $purchase): Purchase
|
||||||
|
{
|
||||||
|
return $this->completer->complete($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
public function submitForReview(Purchase $purchase): Purchase
|
public function submitForReview(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $this->completer->submitForReview($purchase);
|
return $this->completer->submitForReview($purchase);
|
||||||
|
|||||||
82
app/Domains/Purchase/Services/DniDistanceService.php
Normal file
82
app/Domains/Purchase/Services/DniDistanceService.php
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Measures likely DNI typing errors using the optimal-string-alignment
|
||||||
|
* variant of the Damerau-Levenshtein distance.
|
||||||
|
*
|
||||||
|
* The returned value is the minimum number of single-character edits needed
|
||||||
|
* to transform one DNI into the other. Supported edits are insertion,
|
||||||
|
* deletion, substitution and transposition of two adjacent digits.
|
||||||
|
*/
|
||||||
|
class DniDistanceService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Calculate the edit distance between two normalized DNI strings.
|
||||||
|
*
|
||||||
|
* Each matrix cell [row][column] stores the minimum edits required to
|
||||||
|
* transform the first $row digits of $left into the first $column digits
|
||||||
|
* of $right. The bottom-right cell therefore contains the final distance.
|
||||||
|
*/
|
||||||
|
public function distance(string $left, string $right): int
|
||||||
|
{
|
||||||
|
$left = $this->normalize($left);
|
||||||
|
$right = $this->normalize($right);
|
||||||
|
$leftLength = strlen($left);
|
||||||
|
$rightLength = strlen($right);
|
||||||
|
$matrix = [];
|
||||||
|
|
||||||
|
// Transforming a prefix into an empty string requires deleting every digit.
|
||||||
|
for ($row = 0; $row <= $leftLength; $row++) {
|
||||||
|
$matrix[$row] = [$row];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transforming an empty string into a prefix requires inserting every digit.
|
||||||
|
for ($column = 0; $column <= $rightLength; $column++) {
|
||||||
|
$matrix[0][$column] = $column;
|
||||||
|
}
|
||||||
|
|
||||||
|
for ($row = 1; $row <= $leftLength; $row++) {
|
||||||
|
for ($column = 1; $column <= $rightLength; $column++) {
|
||||||
|
$substitutionCost = $left[$row - 1] === $right[$column - 1] ? 0 : 1;
|
||||||
|
$deletionDistance = $matrix[$row - 1][$column] + 1;
|
||||||
|
$insertionDistance = $matrix[$row][$column - 1] + 1;
|
||||||
|
$substitutionDistance = $matrix[$row - 1][$column - 1] + $substitutionCost;
|
||||||
|
|
||||||
|
// Keep the cheapest way to align the two prefixes at this position.
|
||||||
|
$matrix[$row][$column] = min(
|
||||||
|
$deletionDistance,
|
||||||
|
$insertionDistance,
|
||||||
|
$substitutionDistance,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Count two adjacent inverted digits as one edit instead of two substitutions.
|
||||||
|
if (
|
||||||
|
$row > 1
|
||||||
|
&& $column > 1
|
||||||
|
&& $left[$row - 1] === $right[$column - 2]
|
||||||
|
&& $left[$row - 2] === $right[$column - 1]
|
||||||
|
) {
|
||||||
|
$matrix[$row][$column] = min(
|
||||||
|
$matrix[$row][$column],
|
||||||
|
$matrix[$row - 2][$column - 2] + 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $matrix[$leftLength][$rightLength];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep only digits and left-pad seven-digit DNIs so comparisons preserve
|
||||||
|
* the leading zero that is present when the DNI is extracted from a CUIT.
|
||||||
|
*/
|
||||||
|
public function normalize(string $dni): string
|
||||||
|
{
|
||||||
|
$digits = preg_replace('/\D+/', '', $dni) ?? '';
|
||||||
|
|
||||||
|
return str_pad($digits, 8, '0', STR_PAD_LEFT);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
|||||||
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
||||||
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
||||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||||
|
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
||||||
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||||
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Sale\Controllers\AdminApp;
|
namespace App\Domains\Sale\Controllers\AdminApp;
|
||||||
|
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
||||||
|
use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest;
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
|
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
|
||||||
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
|
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
|
||||||
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
||||||
@@ -67,11 +68,13 @@ class SaleController extends Controller
|
|||||||
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function modifications(Request $request): AnonymousResourceCollection
|
public function modifications(
|
||||||
{
|
AdminAppSaleModificationIndexRequest $request,
|
||||||
|
): AnonymousResourceCollection {
|
||||||
return SaleModificationResource::collection(
|
return SaleModificationResource::collection(
|
||||||
$this->saleService->modifications(
|
$this->saleService->modifications(
|
||||||
$request->user()->tenant()->firstOrFail()
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated(),
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -93,7 +96,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->salePdfService->downloadModifications(
|
return $this->salePdfService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant),
|
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -116,7 +119,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->saleExcelService->downloadModifications(
|
return $this->saleExcelService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant),
|
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class AdminAppSaleIndexRequest extends FormRequest
|
|||||||
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||||
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
|
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::adminStatusCodes())],
|
||||||
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
|
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
|
||||||
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
|
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
|
||||||
'page' => ['sometimes', 'integer', 'min:1'],
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
|
class AdminAppSaleModificationIndexRequest extends AdminAppSaleIndexRequest
|
||||||
|
{
|
||||||
|
/** @return array<string, list<string>> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$rules = parent::rules();
|
||||||
|
|
||||||
|
unset($rules['sort_by'], $rules['sort_direction']);
|
||||||
|
|
||||||
|
return $rules;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,19 +3,14 @@
|
|||||||
namespace App\Domains\Sale\Requests;
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
use App\Domains\Shared\Rules\ValidTimezone;
|
use App\Domains\Shared\Rules\ValidTimezone;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class AdminAppSaleModificationPdfRequest extends FormRequest
|
class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, list<string>> */
|
/** @return array<string, list<string>> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
...parent::rules(),
|
||||||
'timezone' => ['required', 'string', new ValidTimezone],
|
'timezone' => ['required', 'string', new ValidTimezone],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ class SaleModificationResource extends JsonResource
|
|||||||
'attribute' => $this->attribute,
|
'attribute' => $this->attribute,
|
||||||
'old_value' => $this->old_value,
|
'old_value' => $this->old_value,
|
||||||
'new_value' => $this->new_value,
|
'new_value' => $this->new_value,
|
||||||
|
'admin_status' => is_string($this->new_value)
|
||||||
|
? Purchase::adminStatusFor($this->new_value)
|
||||||
|
: null,
|
||||||
|
'status_label' => is_string($this->new_value)
|
||||||
|
? Purchase::adminStatusNameFor($this->new_value)
|
||||||
|
: null,
|
||||||
'changed_at' => $this->changed_at->utc()->toIso8601String(),
|
'changed_at' => $this->changed_at->utc()->toIso8601String(),
|
||||||
'date' => $this->changed_at->format('Y-m-d'),
|
'date' => $this->changed_at->format('Y-m-d'),
|
||||||
'time' => $this->changed_at->format('H:i:s'),
|
'time' => $this->changed_at->format('H:i:s'),
|
||||||
@@ -31,6 +37,8 @@ class SaleModificationResource extends JsonResource
|
|||||||
'id' => $sale->id,
|
'id' => $sale->id,
|
||||||
'customer_name' => $sale->nombre_apellido,
|
'customer_name' => $sale->nombre_apellido,
|
||||||
'status' => $sale->status,
|
'status' => $sale->status,
|
||||||
|
'admin_status' => Purchase::adminStatusFor($sale->status),
|
||||||
|
'status_label' => Purchase::adminStatusNameFor($sale->status),
|
||||||
] : null,
|
] : null,
|
||||||
'modified_by' => $user ? [
|
'modified_by' => $user ? [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ class SaleResource extends JsonResource
|
|||||||
'customer_name' => $this->nombre_apellido,
|
'customer_name' => $this->nombre_apellido,
|
||||||
'quantity' => (int) ($this->quantity ?? 0),
|
'quantity' => (int) ($this->quantity ?? 0),
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
|
'admin_status' => Purchase::adminStatusFor($this->status),
|
||||||
|
'status_label' => Purchase::adminStatusNameFor($this->status),
|
||||||
'total' => number_format((float) $this->total, 2, '.', ''),
|
'total' => number_format((float) $this->total, 2, '.', ''),
|
||||||
'tickets_count' => $ticketsCount,
|
'tickets_count' => $ticketsCount,
|
||||||
'has_generated_tickets' => $ticketsCount > 0,
|
'has_generated_tickets' => $ticketsCount > 0,
|
||||||
|
|||||||
@@ -201,11 +201,6 @@ class AdminAppSaleExcelService
|
|||||||
|
|
||||||
private function saleStatus(string $status): string
|
private function saleStatus(string $status): string
|
||||||
{
|
{
|
||||||
return match ($status) {
|
return Purchase::adminStatusNameFor($status) ?? $status;
|
||||||
Purchase::STATUS_PAID => 'Confirmado',
|
|
||||||
Purchase::STATUS_CREATED => 'Por completar datos',
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW => 'Esperando pago',
|
|
||||||
default => 'Anulado',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,18 +92,24 @@ class AdminAppSaleService
|
|||||||
return $this->salesQuery($tenant, $filters)->get();
|
return $this->salesQuery($tenant, $filters)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return LengthAwarePaginator<ValueChange> */
|
/**
|
||||||
public function modifications(Tenant $tenant): LengthAwarePaginator
|
* @param array<string, mixed> $filters
|
||||||
|
* @return LengthAwarePaginator<ValueChange>
|
||||||
|
*/
|
||||||
|
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant)
|
return $this->modificationsQuery($tenant, $filters)
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Collection<int, ValueChange> */
|
/**
|
||||||
public function modificationsForExport(Tenant $tenant): Collection
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Collection<int, ValueChange>
|
||||||
|
*/
|
||||||
|
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant)->get();
|
return $this->modificationsQuery($tenant, $filters)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $filters */
|
/** @param array<string, mixed> $filters */
|
||||||
@@ -126,7 +132,6 @@ class AdminAppSaleService
|
|||||||
|
|
||||||
return Purchase::query()
|
return Purchase::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('status', '!=', Purchase::STATUS_SUPERSEDED)
|
|
||||||
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||||
$term = trim($search);
|
$term = trim($search);
|
||||||
|
|
||||||
@@ -144,12 +149,10 @@ class AdminAppSaleService
|
|||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
$filters['status'] ?? null,
|
$filters['status'] ?? null,
|
||||||
fn (Builder $query, string $status): Builder => $status === Purchase::STATUS_PENDING_PAYMENT
|
fn (Builder $query, string $status): Builder => $query->whereIn(
|
||||||
? $query->whereIn('status', [
|
'status',
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::realStatusesForAdminStatus($status),
|
||||||
Purchase::STATUS_IN_REVIEW,
|
)
|
||||||
])
|
|
||||||
: $query->where('status', $status)
|
|
||||||
)
|
)
|
||||||
->select('compras.*')
|
->select('compras.*')
|
||||||
->selectRaw(
|
->selectRaw(
|
||||||
@@ -162,12 +165,50 @@ class AdminAppSaleService
|
|||||||
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Builder<ValueChange> */
|
/**
|
||||||
protected function modificationsQuery(Tenant $tenant): Builder
|
* @param array<string, mixed> $filters
|
||||||
|
* @return Builder<ValueChange>
|
||||||
|
*/
|
||||||
|
protected function modificationsQuery(Tenant $tenant, array $filters): Builder
|
||||||
{
|
{
|
||||||
return ValueChange::query()
|
return ValueChange::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('trackable_type', (new Purchase)->getMorphClass())
|
->where('trackable_type', (new Purchase)->getMorphClass())
|
||||||
|
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||||
|
$term = trim($search);
|
||||||
|
|
||||||
|
$query->where(function (Builder $query) use ($term): void {
|
||||||
|
$query
|
||||||
|
->where('trackable_id', 'like', "%{$term}%")
|
||||||
|
->orWhereHasMorph(
|
||||||
|
'trackable',
|
||||||
|
[Purchase::class],
|
||||||
|
function (Builder $sales) use ($term): void {
|
||||||
|
$sales
|
||||||
|
->where('nombre_apellido', 'like', "%{$term}%")
|
||||||
|
->orWhere('created_at', 'like', "%{$term}%");
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
->when(
|
||||||
|
$filters['id'] ?? null,
|
||||||
|
fn (Builder $query, int $id): Builder => $query->where('trackable_id', $id)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$filters['sale_date'] ?? null,
|
||||||
|
fn (Builder $query, string $date): Builder => $query->whereHasMorph(
|
||||||
|
'trackable',
|
||||||
|
[Purchase::class],
|
||||||
|
fn (Builder $sales): Builder => $sales->whereDate('created_at', $date),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
->when(
|
||||||
|
$filters['status'] ?? null,
|
||||||
|
fn (Builder $query, string $status): Builder => $query
|
||||||
|
->where('attribute', 'status')
|
||||||
|
->whereIn('new_value', Purchase::realStatusesForAdminStatus($status))
|
||||||
|
)
|
||||||
->with(['trackable', 'user'])
|
->with(['trackable', 'user'])
|
||||||
->orderByDesc('changed_at')
|
->orderByDesc('changed_at')
|
||||||
->orderByDesc('id');
|
->orderByDesc('id');
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
|||||||
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
||||||
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
||||||
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
|
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
|
||||||
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
|
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación de ventas.
|
||||||
|
- `AdminAppSaleModificationIndexRequest`: valida los filtros compartidos por el historial y sus exportaciones.
|
||||||
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
||||||
- `SaleController`: entrada HTTP del panel.
|
- `SaleController`: entrada HTTP del panel.
|
||||||
|
|
||||||
@@ -27,3 +28,5 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
|||||||
## Consideraciones
|
## Consideraciones
|
||||||
|
|
||||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
||||||
|
|
||||||
|
El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta.
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Staff\Requests;
|
namespace App\Domains\Staff\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;
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ class StoreStaffRequest 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')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -23,7 +31,12 @@ class StoreStaffRequest extends FormRequest
|
|||||||
return [
|
return [
|
||||||
'nombre_apellido' => ['required', 'string', 'max:255'],
|
'nombre_apellido' => ['required', 'string', 'max:255'],
|
||||||
'dni' => ['required', 'string', 'max:50'],
|
'dni' => ['required', 'string', 'max:50'],
|
||||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
'email' => [
|
||||||
|
'required',
|
||||||
|
'email',
|
||||||
|
'max:255',
|
||||||
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)->whereNull('deleted_at'),
|
||||||
|
],
|
||||||
'category_ids' => $categoryRules,
|
'category_ids' => $categoryRules,
|
||||||
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace App\Domains\Staff\Requests;
|
namespace App\Domains\Staff\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;
|
||||||
|
|
||||||
@@ -12,6 +13,13 @@ class UpdateStaffRequest 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')))]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -28,7 +36,9 @@ class UpdateStaffRequest extends FormRequest
|
|||||||
'required',
|
'required',
|
||||||
'email',
|
'email',
|
||||||
'max:255',
|
'max:255',
|
||||||
Rule::unique('users', 'email')->ignore($staffId),
|
Rule::unique('users', 'active_email')->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
|
->whereNull('deleted_at')
|
||||||
|
->ignore($staffId),
|
||||||
],
|
],
|
||||||
'category_ids' => $categoryRules,
|
'category_ids' => $categoryRules,
|
||||||
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
'category_ids.*' => ['integer', 'distinct', Rule::exists('categorias', 'id')],
|
||||||
|
|||||||
@@ -94,7 +94,12 @@ class StaffService
|
|||||||
|
|
||||||
public function delete(Tenant $tenant, int $staffId): void
|
public function delete(Tenant $tenant, int $staffId): void
|
||||||
{
|
{
|
||||||
$this->find($tenant, $staffId)->delete();
|
$staff = $this->find($tenant, $staffId);
|
||||||
|
|
||||||
|
DB::transaction(function () use ($staff): void {
|
||||||
|
$staff->tokens()->delete();
|
||||||
|
$staff->delete();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find(Tenant $tenant, int $staffId): User
|
public function find(Tenant $tenant, int $staffId): User
|
||||||
|
|||||||
@@ -6,8 +6,5 @@ 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 {
|
||||||
Route::get('staff', [AdminAppStaffController::class, 'index']);
|
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
|
||||||
Route::post('staff', [AdminAppStaffController::class, 'store']);
|
|
||||||
Route::put('staff/{staff}', [AdminAppStaffController::class, 'update']);
|
|
||||||
Route::delete('staff/{staff}', [AdminAppStaffController::class, 'destroy']);
|
|
||||||
});
|
});
|
||||||
|
|||||||
58
app/Domains/StorageTest/Controllers/S3TestController.php
Normal file
58
app/Domains/StorageTest/Controllers/S3TestController.php
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\StorageTest\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Attachable\Services\AttachmentService;
|
||||||
|
use App\Domains\StorageTest\Requests\GenerateS3TemporaryUrlRequest;
|
||||||
|
use App\Domains\StorageTest\Requests\StoreS3TestFileRequest;
|
||||||
|
use App\Domains\StorageTest\Services\S3TestService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
|
||||||
|
class S3TestController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected AttachmentService $attachmentService,
|
||||||
|
protected S3TestService $s3TestService,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreS3TestFileRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$attachment = $this->attachmentService->store(
|
||||||
|
$request->file('file') ?? (string) $request->validated('file_base64'),
|
||||||
|
$request->validated('path'),
|
||||||
|
);
|
||||||
|
|
||||||
|
$temporaryUrl = $this->s3TestService->generateTemporaryUrl(
|
||||||
|
$attachment->path,
|
||||||
|
(int) $request->validated('expires_in_minutes', 10),
|
||||||
|
);
|
||||||
|
|
||||||
|
return response()->json(
|
||||||
|
[
|
||||||
|
'id' => $attachment->id,
|
||||||
|
'key' => $attachment->key,
|
||||||
|
'path' => $attachment->path,
|
||||||
|
'filename' => $attachment->filename,
|
||||||
|
'type' => $attachment->type->value,
|
||||||
|
'mime_type' => $attachment->mime_type,
|
||||||
|
'extension' => $attachment->extension,
|
||||||
|
'size' => $attachment->size,
|
||||||
|
'temporary_url' => $temporaryUrl['temporary_url'],
|
||||||
|
'temporary_url_expires_at' => $temporaryUrl['temporary_url_expires_at'],
|
||||||
|
],
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function temporaryUrl(GenerateS3TemporaryUrlRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
return response()->json(
|
||||||
|
$this->s3TestService->generateTemporaryUrl(
|
||||||
|
$request->validated('path'),
|
||||||
|
(int) $request->validated('expires_in_minutes', 10),
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\StorageTest\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class GenerateS3TemporaryUrlRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'path' => ['required', 'string', 'max:2048'],
|
||||||
|
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
26
app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php
Normal file
26
app/Domains/StorageTest/Requests/StoreS3TestFileRequest.php
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\StorageTest\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
|
class StoreS3TestFileRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, array<int, string>>
|
||||||
|
*/
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'],
|
||||||
|
'file_base64' => ['nullable', 'string', 'required_without:file'],
|
||||||
|
'path' => ['required', 'string', 'max:2048'],
|
||||||
|
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
81
app/Domains/StorageTest/Services/S3TestService.php
Normal file
81
app/Domains/StorageTest/Services/S3TestService.php
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\StorageTest\Services;
|
||||||
|
|
||||||
|
use Illuminate\Http\UploadedFile;
|
||||||
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class S3TestService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array<string, int|string|null>
|
||||||
|
*/
|
||||||
|
public function storeTestFile(
|
||||||
|
UploadedFile $file,
|
||||||
|
?string $directory = null,
|
||||||
|
int $expiresInMinutes = 10,
|
||||||
|
): array {
|
||||||
|
$directory = $this->normalizeDirectory($directory);
|
||||||
|
$disk = Storage::disk('s3');
|
||||||
|
$path = $disk->putFile($directory, $file);
|
||||||
|
|
||||||
|
if (! is_string($path) || $path === '') {
|
||||||
|
Log::error('S3 upload returned an empty path.', [
|
||||||
|
'disk' => 's3',
|
||||||
|
'directory' => $directory,
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $file->getClientMimeType(),
|
||||||
|
'size' => $file->getSize(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
throw new RuntimeException('No se pudo subir el archivo al disco s3.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'disk' => 's3',
|
||||||
|
'directory' => $directory,
|
||||||
|
'key' => $path,
|
||||||
|
'path' => $path,
|
||||||
|
'filename' => basename($path),
|
||||||
|
'original_name' => $file->getClientOriginalName(),
|
||||||
|
'mime_type' => $file->getClientMimeType(),
|
||||||
|
'extension' => $file->extension(),
|
||||||
|
'size' => $file->getSize(),
|
||||||
|
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
||||||
|
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function generateTemporaryUrl(string $path, int $expiresInMinutes = 10): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'disk' => 's3',
|
||||||
|
'key' => $path,
|
||||||
|
'path' => $path,
|
||||||
|
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
||||||
|
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
||||||
|
{
|
||||||
|
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function normalizeDirectory(?string $directory): string
|
||||||
|
{
|
||||||
|
$directory = trim((string) $directory, '/');
|
||||||
|
|
||||||
|
if ($directory !== '') {
|
||||||
|
return $directory;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'testing/attachments/'.now()->format('Y/m/d').'/'.Str::uuid();
|
||||||
|
}
|
||||||
|
}
|
||||||
23
app/Domains/StorageTest/documentacion/README.md
Normal file
23
app/Domains/StorageTest/documentacion/README.md
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
# Dominio StorageTest
|
||||||
|
|
||||||
|
## Propósito
|
||||||
|
|
||||||
|
Expone operaciones técnicas para comprobar la escritura en S3 y la generación de URL temporales.
|
||||||
|
|
||||||
|
## Componentes
|
||||||
|
|
||||||
|
- `S3TestController`: recibe solicitudes de carga y URL temporal.
|
||||||
|
- `S3TestService`: almacena un archivo de prueba y genera el enlace firmado.
|
||||||
|
- `StoreS3TestFileRequest`: valida la carga.
|
||||||
|
- `GenerateS3TemporaryUrlRequest`: valida ruta y tiempo de expiración.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
Bajo `/storage-test/s3`:
|
||||||
|
|
||||||
|
- `POST /upload`.
|
||||||
|
- `GET /temporary-url`.
|
||||||
|
|
||||||
|
## Consideraciones
|
||||||
|
|
||||||
|
Es infraestructura de diagnóstico, no una API funcional de archivos. Debe restringirse por entorno o autorización. Para adjuntos de negocio se debe usar el dominio `Attachable`.
|
||||||
9
app/Domains/StorageTest/routes/api.php
Normal file
9
app/Domains/StorageTest/routes/api.php
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\StorageTest\Controllers\S3TestController;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
|
Route::prefix('storage-test/s3')->group(function (): void {
|
||||||
|
Route::post('upload', [S3TestController::class, 'store']);
|
||||||
|
Route::get('temporary-url', [S3TestController::class, 'temporaryUrl']);
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Tenant\Controllers\AdminApp;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtraRequest;
|
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtraRequest;
|
||||||
|
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtraResource;
|
||||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
|
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
|
||||||
use App\Domains\Tenant\Services\TenantInformationService;
|
use App\Domains\Tenant\Services\TenantInformationService;
|
||||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||||
@@ -25,6 +26,20 @@ class WebsiteExtraController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function showExtra(Request $request, string $websiteExtraCode): WebsiteExtraResource
|
||||||
|
{
|
||||||
|
$tenant = $this->loadTenant($request->user());
|
||||||
|
$definition = $this->websiteExtraService->definitionForTenant($tenant, $websiteExtraCode);
|
||||||
|
$websiteExtra = $tenant->websiteExtras
|
||||||
|
->firstWhere('website_type_extra_id', $definition->id);
|
||||||
|
|
||||||
|
if (! $websiteExtra) {
|
||||||
|
abort(404);
|
||||||
|
}
|
||||||
|
|
||||||
|
return WebsiteExtraResource::make($websiteExtra);
|
||||||
|
}
|
||||||
|
|
||||||
public function update(
|
public function update(
|
||||||
UpdateWebsiteExtraRequest $request,
|
UpdateWebsiteExtraRequest $request,
|
||||||
string $websiteExtraCode
|
string $websiteExtraCode
|
||||||
|
|||||||
64
app/Domains/Tenant/Controllers/TenantController.php
Normal file
64
app/Domains/Tenant/Controllers/TenantController.php
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Tenant\Controllers;
|
||||||
|
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
||||||
|
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
||||||
|
use App\Domains\Tenant\Resources\TenantResource;
|
||||||
|
use App\Domains\Tenant\Services\TenantInformationService;
|
||||||
|
use App\Domains\Tenant\Services\TenantService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\JsonResponse;
|
||||||
|
use Illuminate\Http\Response;
|
||||||
|
|
||||||
|
class TenantController extends Controller
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
protected TenantService $tenantService,
|
||||||
|
protected TenantInformationService $tenantInformationService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function index(): JsonResponse
|
||||||
|
{
|
||||||
|
$tenants = Tenant::query()
|
||||||
|
->latest()
|
||||||
|
->paginateFromRequest();
|
||||||
|
|
||||||
|
$this->tenantInformationService->loadMany($tenants->getCollection());
|
||||||
|
|
||||||
|
return TenantResource::collection($tenants)->response();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function store(StoreTenantRequest $request): JsonResponse
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantService->create($request->validated());
|
||||||
|
|
||||||
|
return TenantResource::make(
|
||||||
|
$this->tenantInformationService->load($tenant)
|
||||||
|
)->response()->setStatusCode(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function show(Tenant $tenant): TenantResource
|
||||||
|
{
|
||||||
|
return TenantResource::make(
|
||||||
|
$this->tenantInformationService->load($tenant)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
||||||
|
{
|
||||||
|
$tenant = $this->tenantService->update($tenant, $request->validated());
|
||||||
|
|
||||||
|
return TenantResource::make(
|
||||||
|
$this->tenantInformationService->load($tenant)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function destroy(Tenant $tenant): Response
|
||||||
|
{
|
||||||
|
$tenant->delete();
|
||||||
|
|
||||||
|
return response()->noContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user