Compare commits
46 Commits
917f2dff09
...
feature/sh
| Author | SHA1 | Date | |
|---|---|---|---|
| 09827e55aa | |||
| 007ba9d572 | |||
| 5ddc304435 | |||
| 675d76fcb0 | |||
| 35fd1277cb | |||
| 1986961942 | |||
| 3f76af8ec8 | |||
| 0ca2bc8d1b | |||
| c40f145b60 | |||
| 0693e52321 | |||
| cfa7acabe0 | |||
| 1633c30a22 | |||
| 7279f9c6e8 | |||
| 878382e867 | |||
| f47212fd2c | |||
| 3472fb2e5b | |||
| cb3da583be | |||
| 53acfda166 | |||
| 85c79514a3 | |||
| 1aaf05c3b0 | |||
| f7d66001a8 | |||
| 1d58e0ad38 | |||
| 856b9a6706 | |||
| e7bece5bfd | |||
| e2cf53eacc | |||
| c7fc867905 | |||
| 3ddff7e0ee | |||
| 22de61c8c0 | |||
| 2d11263adc | |||
| c975b5d51d | |||
| c7afb70196 | |||
| 31955f862d | |||
| bb4495c6cc | |||
| f3e226130f | |||
| 77dd0e2715 | |||
| 6e1f0d731c | |||
| fb5221854c | |||
| c5aeb19735 | |||
| 9db955bdcf | |||
| e988e55b77 | |||
| bc60f1caa1 | |||
| 068d5d7f4f | |||
| 82c18e9e91 | |||
| dfad6fb4b0 | |||
| 3f9b773367 | |||
| 7996d53e12 |
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Commerce\Catalog\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Requests\AdminApp\UpdateCategoryConfigurationRequest;
|
||||||
|
use App\Domains\Commerce\Catalog\Resources\AdminApp\CategoryConfigurationResource;
|
||||||
|
use App\Domains\Commerce\Catalog\Services\CategoryConfigurationService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
|
|
||||||
|
class CategoryConfigurationController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request, CategoryConfigurationService $service): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
return CategoryConfigurationResource::collection(
|
||||||
|
$service->forTenant($request->user()->tenant()->firstOrFail())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateCategoryConfigurationRequest $request, CategoryConfigurationService $service): AnonymousResourceCollection
|
||||||
|
{
|
||||||
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
$service->update($tenant, $request->validated());
|
||||||
|
|
||||||
|
return CategoryConfigurationResource::collection($service->forTenant($tenant));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedGroupResource;
|
|||||||
use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource;
|
use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource;
|
||||||
use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource;
|
use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource;
|
||||||
use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource;
|
use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource;
|
||||||
|
use App\Domains\Commerce\Catalog\Resources\CategoryResource;
|
||||||
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
|
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
|
||||||
use App\Domains\Commerce\Catalog\Services\CatalogService;
|
use App\Domains\Commerce\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Commerce\Catalog\Services\FeaturedGroupService;
|
use App\Domains\Commerce\Catalog\Services\FeaturedGroupService;
|
||||||
@@ -78,11 +79,7 @@ class CatalogController extends Controller
|
|||||||
$allowances->attach($items->getCollection(), $this->userId($request));
|
$allowances->attach($items->getCollection(), $this->userId($request));
|
||||||
|
|
||||||
return CatalogSearchItemResource::collection($items)->additional([
|
return CatalogSearchItemResource::collection($items)->additional([
|
||||||
'category' => [
|
'category' => CategoryResource::make($category)->resolve($request),
|
||||||
'id' => $category->id,
|
|
||||||
'nombre' => $category->nombre,
|
|
||||||
'categoria_id' => $category->categoria_id,
|
|
||||||
],
|
|
||||||
'layout' => $tenant->search_product_layout->value,
|
'layout' => $tenant->search_product_layout->value,
|
||||||
'group_layout' => $tenant->search_group_layout->value,
|
'group_layout' => $tenant->search_group_layout->value,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Commerce\Catalog\Models;
|
|||||||
|
|
||||||
use App\Domains\Core\Auth\Models\User;
|
use App\Domains\Core\Auth\Models\User;
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Shared\Attachable\Models\Attachment;
|
||||||
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;
|
||||||
@@ -16,6 +17,9 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
|||||||
'categoria_id',
|
'categoria_id',
|
||||||
'nombre',
|
'nombre',
|
||||||
'is_enabled',
|
'is_enabled',
|
||||||
|
'banner_id',
|
||||||
|
'banner_enabled',
|
||||||
|
'sort_order',
|
||||||
])]
|
])]
|
||||||
class Category extends Model
|
class Category extends Model
|
||||||
{
|
{
|
||||||
@@ -25,6 +29,7 @@ class Category extends Model
|
|||||||
|
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'is_enabled' => true,
|
'is_enabled' => true,
|
||||||
|
'banner_enabled' => false,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -35,6 +40,9 @@ class Category extends Model
|
|||||||
return [
|
return [
|
||||||
'categoria_id' => 'integer',
|
'categoria_id' => 'integer',
|
||||||
'is_enabled' => 'boolean',
|
'is_enabled' => 'boolean',
|
||||||
|
'banner_id' => 'integer',
|
||||||
|
'banner_enabled' => 'boolean',
|
||||||
|
'sort_order' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,6 +67,12 @@ class Category extends Model
|
|||||||
return $this->belongsTo(self::class, 'categoria_id');
|
return $this->belongsTo(self::class, 'categoria_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Attachment, $this> */
|
||||||
|
public function banner(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Attachment::class, 'banner_id');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return HasMany<Category, $this>
|
* @return HasMany<Category, $this>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Commerce\Catalog\Requests\AdminApp;
|
||||||
|
|
||||||
|
use App\Shared\Rules\ImageOrBase64Rule;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
use Illuminate\Validation\Validator as ValidationValidator;
|
||||||
|
|
||||||
|
class UpdateCategoryConfigurationRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'categories' => ['present', 'array', 'max:500'],
|
||||||
|
'deleted_ids' => ['present', 'array', 'max:1000'],
|
||||||
|
'deleted_ids.*' => ['required', 'integer', 'min:1', 'distinct'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function withValidator(ValidationValidator $validator): void
|
||||||
|
{
|
||||||
|
$validator->after(function (ValidationValidator $validator): void {
|
||||||
|
if ($validator->errors()->isNotEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$this->validateNodes($validator, $this->input('categories'), 'categories', 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateNodes(ValidationValidator $validator, array $nodes, string $path, int $depth): void
|
||||||
|
{
|
||||||
|
if ($depth > 10) {
|
||||||
|
$validator->errors()->add($path, 'La jerarquía de categorías es demasiado profunda.');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($nodes as $index => $node) {
|
||||||
|
$key = "{$path}.{$index}";
|
||||||
|
if (! is_array($node)) {
|
||||||
|
$validator->errors()->add($key, 'La categoría debe ser un objeto.');
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$nested = Validator::make($node, [
|
||||||
|
'id' => ['present', 'nullable', 'integer', 'min:1'],
|
||||||
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
|
'is_enabled' => ['required', 'boolean'],
|
||||||
|
'banner_enabled' => ['required', 'boolean'],
|
||||||
|
'banner' => ['present', 'nullable', new ImageOrBase64Rule],
|
||||||
|
'subcategories' => ['present', 'array', 'max:500'],
|
||||||
|
]);
|
||||||
|
foreach ($nested->errors()->messages() as $field => $messages) {
|
||||||
|
foreach ($messages as $message) {
|
||||||
|
$validator->errors()->add("{$key}.{$field}", $message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! $nested->fails()) {
|
||||||
|
$this->validateNodes($validator, $node['subcategories'], "{$key}.subcategories", $depth + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Commerce\Catalog\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin Category */
|
||||||
|
class CategoryConfigurationResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'nombre' => $this->nombre,
|
||||||
|
'is_enabled' => $this->is_enabled,
|
||||||
|
'banner_enabled' => $this->banner_enabled,
|
||||||
|
'banner_key' => $this->banner?->key,
|
||||||
|
'banner_name' => $this->banner?->filename,
|
||||||
|
'banner_url' => $this->banner?->getTemporaryUrl(1440),
|
||||||
|
'subcategories' => self::collection($this->subCategories),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
24
app/Domains/Commerce/Catalog/Resources/CategoryResource.php
Normal file
24
app/Domains/Commerce/Catalog/Resources/CategoryResource.php
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Commerce\Catalog\Resources;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
/** @mixin Category */
|
||||||
|
class CategoryResource extends JsonResource
|
||||||
|
{
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'id' => $this->id,
|
||||||
|
'nombre' => $this->nombre,
|
||||||
|
'categoria_id' => $this->categoria_id,
|
||||||
|
'banner_url' => $this->banner_enabled
|
||||||
|
? $this->banner?->getTemporaryUrl(1440)
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Commerce\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
|
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
|
||||||
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Shared\Attachable\Enums\AttachmentType;
|
||||||
|
use App\Shared\Attachable\Models\Attachment;
|
||||||
|
use App\Shared\Attachable\Services\AttachmentService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Illuminate\Support\Str;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
class CategoryConfigurationService
|
||||||
|
{
|
||||||
|
public function __construct(private readonly AttachmentService $attachments) {}
|
||||||
|
|
||||||
|
/** @return Collection<int, Category> */
|
||||||
|
public function forTenant(Tenant $tenant): Collection
|
||||||
|
{
|
||||||
|
$all = $tenant->categories()->with('banner')->orderBy('sort_order')->orderBy('id')->get();
|
||||||
|
$children = $all->groupBy('categoria_id');
|
||||||
|
foreach ($all as $category) {
|
||||||
|
$category->setRelation('subCategories', $children->get($category->id, collect()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $all->filter(fn (Category $category) => $category->categoria_id === null)->values();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, mixed> $data */
|
||||||
|
public function update(Tenant $tenant, array $data): void
|
||||||
|
{
|
||||||
|
$created = [];
|
||||||
|
try {
|
||||||
|
DB::transaction(function () use ($tenant, $data, &$created): void {
|
||||||
|
// Serialize saves for this tenant, including creation of new categories.
|
||||||
|
Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$existing = $tenant->categories()->with('banner')->lockForUpdate()->get()->keyBy('id');
|
||||||
|
$deleted = array_map('intval', $data['deleted_ids']);
|
||||||
|
$seen = [];
|
||||||
|
$allowedBannerKeys = $existing->pluck('banner.key')->filter()->all();
|
||||||
|
$this->validateNodes($data['categories'], $existing, $deleted, $seen, $allowedBannerKeys);
|
||||||
|
|
||||||
|
foreach ($deleted as $id) {
|
||||||
|
$category = $existing->get($id);
|
||||||
|
if ($category === null) {
|
||||||
|
$this->invalid('deleted_ids', 'Una categoría a eliminar no pertenece a esta tienda.');
|
||||||
|
}
|
||||||
|
if ($category->catalogItems()->withTrashed()->exists()
|
||||||
|
|| FeaturedGroup::query()->where('category_id', $id)->exists()) {
|
||||||
|
$this->invalid('deleted_ids', "No se puede eliminar «{$category->nombre}» porque tiene productos o grupos destacados asociados. Podés ocultarla.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->saveNodes($tenant, $data['categories'], null, $existing, $created);
|
||||||
|
$remaining = $tenant->categories()->whereNotIn('id', $deleted)->get();
|
||||||
|
$parents = $remaining->pluck('categoria_id', 'id');
|
||||||
|
foreach ($remaining as $category) {
|
||||||
|
$visited = [$category->id => true];
|
||||||
|
$parentId = $category->categoria_id;
|
||||||
|
while ($parentId !== null) {
|
||||||
|
if (isset($visited[$parentId]) || ! $parents->has($parentId)) {
|
||||||
|
$this->invalid('categories', 'La jerarquía contiene un ciclo o una categoría padre eliminada.');
|
||||||
|
}
|
||||||
|
$visited[$parentId] = true;
|
||||||
|
$parentId = $parents->get($parentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detach only explicitly deleted nodes before removing parents and children together.
|
||||||
|
$tenant->categories()->whereIn('id', $deleted)->update(['categoria_id' => null]);
|
||||||
|
$tenant->categories()->whereIn('id', $deleted)->delete();
|
||||||
|
});
|
||||||
|
} catch (Throwable $error) {
|
||||||
|
foreach ($created as $attachment) {
|
||||||
|
Storage::disk('s3')->delete($attachment->path);
|
||||||
|
}
|
||||||
|
throw $error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function validateNodes(array $nodes, Collection $existing, array $deleted, array &$seen, array $bannerKeys, string $path = 'categories'): void
|
||||||
|
{
|
||||||
|
foreach ($nodes as $index => $node) {
|
||||||
|
$field = "{$path}.{$index}";
|
||||||
|
if ($node['id'] !== null) {
|
||||||
|
$id = (int) $node['id'];
|
||||||
|
if (! $existing->has($id) || isset($seen[$id]) || in_array($id, $deleted, true)) {
|
||||||
|
$this->invalid("{$field}.id", 'La categoría es ajena a la tienda, está repetida o fue marcada para eliminar.');
|
||||||
|
}
|
||||||
|
$seen[$id] = true;
|
||||||
|
}
|
||||||
|
if (trim($node['nombre']) === '') {
|
||||||
|
$this->invalid("{$field}.nombre", 'Completá el nombre de la categoría.');
|
||||||
|
}
|
||||||
|
if (is_string($node['banner']) && Str::isUuid($node['banner'])
|
||||||
|
&& ! in_array($node['banner'], $bannerKeys, true)) {
|
||||||
|
$this->invalid("{$field}.banner", 'El banner no pertenece a las categorías de esta tienda.');
|
||||||
|
}
|
||||||
|
$this->validateNodes($node['subcategories'], $existing, $deleted, $seen, $bannerKeys, "{$field}.subcategories");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function saveNodes(Tenant $tenant, array $nodes, ?int $parentId, Collection $existing, array &$created): void
|
||||||
|
{
|
||||||
|
foreach (array_values($nodes) as $order => $node) {
|
||||||
|
$category = $node['id'] === null ? new Category : $existing->get((int) $node['id']);
|
||||||
|
$banner = null;
|
||||||
|
if ($node['banner'] !== null) {
|
||||||
|
if (is_string($node['banner']) && Str::isUuid($node['banner'])) {
|
||||||
|
$banner = Attachment::query()->where('key', $node['banner'])->firstOrFail();
|
||||||
|
if ($banner->type !== AttachmentType::Image) {
|
||||||
|
$this->invalid('categories', 'El banner debe ser una imagen.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
$banner = $this->attachments->store($node['banner'], "tenants/{$tenant->codigo}/categories");
|
||||||
|
$created[] = $banner;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$category->fill([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'categoria_id' => $parentId,
|
||||||
|
'nombre' => trim($node['nombre']),
|
||||||
|
'is_enabled' => $node['is_enabled'],
|
||||||
|
'banner_id' => $banner?->id,
|
||||||
|
'banner_enabled' => $node['banner_enabled'],
|
||||||
|
'sort_order' => $order,
|
||||||
|
])->save();
|
||||||
|
$this->saveNodes($tenant, $node['subcategories'], $category->id, $existing, $created);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function invalid(string $field, string $message): never
|
||||||
|
{
|
||||||
|
throw ValidationException::withMessages([$field => $message]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,16 @@ Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, de
|
|||||||
|
|
||||||
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
|
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
|
||||||
|
|
||||||
|
### Configuración de categorías de producto
|
||||||
|
|
||||||
|
`GET /v1/adminapp/tenant/categories` devuelve un árbol ordenado de las categorías del tenant autenticado, incluidas las ocultas. Cada nodo contiene `id`, `nombre`, `is_enabled`, `banner_enabled`, `banner_key`, `banner_name`, `banner_url` y `subcategories`. La URL administrativa se devuelve incluso si el banner está deshabilitado.
|
||||||
|
|
||||||
|
`PUT /v1/adminapp/tenant/categories` recibe `categories` y `deleted_ids`. Cada nodo enviado contiene `id` (null para altas), `nombre`, `is_enabled`, `banner_enabled`, `banner` y `subcategories`. `banner` es una clave UUID de un banner ya asociado a las categorías del tenant, una imagen base64/archivo para reemplazarlo o null para quitarlo. Deshabilitar un banner conserva el adjunto. Duplicar una categoría puede compartir su banner; los adjuntos previos no se eliminan al quitar o reemplazar esa referencia.
|
||||||
|
|
||||||
|
La posición de cada nodo en su lista determina `sort_order`; este campo no se expone en las respuestas públicas ni administrativas. La migración inicial conserva el orden alfabético existente. El bootstrap del storefront ordena por `sort_order` e ID y no muestra categorías ocultas ni sus descendientes.
|
||||||
|
|
||||||
|
El guardado es transaccional. Solo se eliminan los IDs explícitos, se rechazan IDs y banners ajenos, categorías repetidas y jerarquías inválidas. No se pueden eliminar categorías con productos (incluidos los archivados) o grupos destacados asociados. Los archivos recién subidos se limpian si falla la transacción. La respuesta devuelve el árbol persistido y los IDs asignados a las altas.
|
||||||
|
|
||||||
## 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.
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Controllers\AdminApp\CategoryConfigurationController;
|
||||||
use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
||||||
use Illuminate\Support\Facades\Route;
|
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('categories', [CategoryConfigurationController::class, 'index']);
|
||||||
|
Route::put('categories', [CategoryConfigurationController::class, 'update']);
|
||||||
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
||||||
->name('adminapp.featured-groups.index');
|
->name('adminapp.featured-groups.index');
|
||||||
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
|
|
||||||
class PurchaseRefundSummaryService
|
class PurchaseRefundSummaryService
|
||||||
{
|
{
|
||||||
public function totalForTenant(Tenant $tenant): string
|
public function totalForTenant(Tenant $tenant, ?int $eventId = null): string
|
||||||
{
|
{
|
||||||
$total = TicketRefund::query()
|
$total = TicketRefund::query()
|
||||||
->whereHas(
|
->whereHas(
|
||||||
'purchaseItem.purchase',
|
'purchaseItem.purchase',
|
||||||
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
|
fn (Builder $query): Builder => $query
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId))
|
||||||
)
|
)
|
||||||
->sum('amount');
|
->sum('amount');
|
||||||
|
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ class SaleController extends Controller
|
|||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return SaleResource::collection(
|
return SaleResource::collection(
|
||||||
$this->saleService->sales($tenant, $request->validated())
|
$this->saleService->sales($tenant, $request->validated(), $request->user()->event_id)
|
||||||
)->additional([
|
)->additional([
|
||||||
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
|
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $request->user()->event_id),
|
||||||
'refunded_total' => $this->saleService->refundedTotal($tenant),
|
'refunded_total' => $this->saleService->refundedTotal($tenant, $request->user()->event_id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ class SaleController extends Controller
|
|||||||
{
|
{
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new SaleDetailResource($this->saleService->detail($tenant, $sale));
|
return new SaleDetailResource($this->saleService->detail($tenant, $sale, $request->user()->event_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tickets(Request $request, int $sale): AnonymousResourceCollection
|
public function tickets(Request $request, int $sale): AnonymousResourceCollection
|
||||||
@@ -51,7 +51,7 @@ class SaleController extends Controller
|
|||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return SaleTicketResource::collection(
|
return SaleTicketResource::collection(
|
||||||
$this->saleService->tickets($tenant, $sale)
|
$this->saleService->tickets($tenant, $sale, $request->user()->event_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,14 +59,14 @@ class SaleController extends Controller
|
|||||||
{
|
{
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new SaleResource($this->saleService->confirm($tenant, $sale));
|
return new SaleResource($this->saleService->confirm($tenant, $sale, $request->user()->event_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(Request $request, int $sale): SaleResource
|
public function cancel(Request $request, int $sale): SaleResource
|
||||||
{
|
{
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
return new SaleResource($this->saleService->cancel($tenant, $sale, $request->user()->event_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function modifications(
|
public function modifications(
|
||||||
@@ -76,6 +76,7 @@ class SaleController extends Controller
|
|||||||
$this->saleService->modifications(
|
$this->saleService->modifications(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->salePdfService->downloadSales(
|
return $this->salePdfService->downloadSales(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->salesForExport($tenant, $request->validated()),
|
$this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->salePdfService->downloadModifications(
|
return $this->salePdfService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
$this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->saleExcelService->downloadSales(
|
return $this->saleExcelService->downloadSales(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->salesForExport($tenant, $request->validated()),
|
$this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,7 +121,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->saleExcelService->downloadModifications(
|
return $this->saleExcelService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
$this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Commerce\Sale\Services;
|
namespace App\Domains\Commerce\Sale\Services;
|
||||||
|
|
||||||
use App\Shared\Logging\Models\ValueChange;
|
|
||||||
use App\Domains\Commerce\Purchase\Models\Purchase;
|
use App\Domains\Commerce\Purchase\Models\Purchase;
|
||||||
use App\Domains\Commerce\Purchase\Services\CheckoutService;
|
use App\Domains\Commerce\Purchase\Services\CheckoutService;
|
||||||
use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService;
|
use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService;
|
||||||
@@ -10,6 +9,7 @@ use App\Domains\Core\Tenant\Models\Tenant;
|
|||||||
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
use App\Domains\Ticketing\Ticket\Models\Ticket;
|
||||||
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
|
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
|
||||||
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
|
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
|
||||||
|
use App\Shared\Logging\Models\ValueChange;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -21,19 +21,18 @@ class AdminAppSaleService
|
|||||||
protected PurchaseRefundSummaryService $refundSummaryService,
|
protected PurchaseRefundSummaryService $refundSummaryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function confirmedSalesTotal(Tenant $tenant): string
|
public function confirmedSalesTotal(Tenant $tenant, ?int $eventId = null): string
|
||||||
{
|
{
|
||||||
$total = Purchase::query()
|
$total = $this->purchasesQuery($tenant, $eventId)
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->where('status', Purchase::STATUS_PAID)
|
->where('status', Purchase::STATUS_PAID)
|
||||||
->sum('total');
|
->sum('total');
|
||||||
|
|
||||||
return number_format((float) $total, 2, '.', '');
|
return number_format((float) $total, 2, '.', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function refundedTotal(Tenant $tenant): string
|
public function refundedTotal(Tenant $tenant, ?int $eventId = null): string
|
||||||
{
|
{
|
||||||
return $this->refundSummaryService->totalForTenant($tenant);
|
return $this->refundSummaryService->totalForTenant($tenant, $eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,43 +46,42 @@ class AdminAppSaleService
|
|||||||
* } $filters
|
* } $filters
|
||||||
* @return LengthAwarePaginator<Purchase>
|
* @return LengthAwarePaginator<Purchase>
|
||||||
*/
|
*/
|
||||||
public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
public function sales(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return $this->salesQuery($tenant, $filters)
|
return $this->salesQuery($tenant, $filters, $eventId)
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function detail(Tenant $tenant, int $saleId): Purchase
|
public function detail(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||||
{
|
{
|
||||||
return Purchase::query()
|
return $this->purchasesQuery($tenant, $eventId)
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->with('items')
|
->with('items')
|
||||||
->findOrFail($saleId);
|
->findOrFail($saleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Collection<int, Ticket> */
|
/** @return Collection<int, Ticket> */
|
||||||
public function tickets(Tenant $tenant, int $saleId): Collection
|
public function tickets(Tenant $tenant, int $saleId, ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->findForTenant($tenant, $saleId)
|
return $this->findForTenant($tenant, $saleId, $eventId)
|
||||||
->tickets()
|
->tickets()
|
||||||
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund'])
|
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund'])
|
||||||
->orderBy('id')
|
->orderBy('id')
|
||||||
->get();
|
->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function confirm(Tenant $tenant, int $saleId): Purchase
|
public function confirm(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||||
{
|
{
|
||||||
$sale = $this->findForTenant($tenant, $saleId);
|
$sale = $this->findForTenant($tenant, $saleId, $eventId);
|
||||||
|
|
||||||
return $this->saleForResponse(
|
return $this->saleForResponse(
|
||||||
$this->checkoutService->confirmPaidPurchase($sale)
|
$this->checkoutService->confirmPaidPurchase($sale)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(Tenant $tenant, int $saleId): Purchase
|
public function cancel(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||||
{
|
{
|
||||||
$sale = $this->findForTenant($tenant, $saleId);
|
$sale = $this->findForTenant($tenant, $saleId, $eventId);
|
||||||
|
|
||||||
return $this->saleForResponse(
|
return $this->saleForResponse(
|
||||||
$this->checkoutService->cancelPurchaseFromAdmin($sale)
|
$this->checkoutService->cancelPurchaseFromAdmin($sale)
|
||||||
@@ -94,18 +92,18 @@ class AdminAppSaleService
|
|||||||
* @param array<string, mixed> $filters
|
* @param array<string, mixed> $filters
|
||||||
* @return Collection<int, Purchase>
|
* @return Collection<int, Purchase>
|
||||||
*/
|
*/
|
||||||
public function salesForExport(Tenant $tenant, array $filters = []): Collection
|
public function salesForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->salesQuery($tenant, $filters)->get();
|
return $this->salesQuery($tenant, $filters, $eventId)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $filters
|
* @param array<string, mixed> $filters
|
||||||
* @return LengthAwarePaginator<ValueChange>
|
* @return LengthAwarePaginator<ValueChange>
|
||||||
*/
|
*/
|
||||||
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
public function modifications(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant, $filters)
|
return $this->modificationsQuery($tenant, $filters, $eventId)
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
@@ -114,13 +112,13 @@ class AdminAppSaleService
|
|||||||
* @param array<string, mixed> $filters
|
* @param array<string, mixed> $filters
|
||||||
* @return Collection<int, ValueChange>
|
* @return Collection<int, ValueChange>
|
||||||
*/
|
*/
|
||||||
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection
|
public function modificationsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant, $filters)->get();
|
return $this->modificationsQuery($tenant, $filters, $eventId)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $filters */
|
/** @param array<string, mixed> $filters */
|
||||||
protected function salesQuery(Tenant $tenant, array $filters): Builder
|
protected function salesQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
|
||||||
{
|
{
|
||||||
$sortColumns = [
|
$sortColumns = [
|
||||||
'id' => 'id',
|
'id' => 'id',
|
||||||
@@ -137,8 +135,7 @@ class AdminAppSaleService
|
|||||||
? $requestedDirection
|
? $requestedDirection
|
||||||
: 'desc';
|
: 'desc';
|
||||||
|
|
||||||
return Purchase::query()
|
return $this->purchasesQuery($tenant, $eventId)
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->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);
|
||||||
|
|
||||||
@@ -176,11 +173,18 @@ class AdminAppSaleService
|
|||||||
* @param array<string, mixed> $filters
|
* @param array<string, mixed> $filters
|
||||||
* @return Builder<ValueChange>
|
* @return Builder<ValueChange>
|
||||||
*/
|
*/
|
||||||
protected function modificationsQuery(Tenant $tenant, array $filters): Builder
|
protected function modificationsQuery(Tenant $tenant, array $filters, ?int $eventId = null): 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($eventId !== null, fn (Builder $query): Builder => $query->whereHasMorph(
|
||||||
|
'trackable',
|
||||||
|
[Purchase::class],
|
||||||
|
fn (Builder $sales): Builder => $sales
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('event_id', $eventId),
|
||||||
|
))
|
||||||
->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);
|
||||||
|
|
||||||
@@ -221,11 +225,18 @@ class AdminAppSaleService
|
|||||||
->orderByDesc('id');
|
->orderByDesc('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function findForTenant(Tenant $tenant, int $saleId): Purchase
|
protected function findForTenant(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||||
|
{
|
||||||
|
return $this->purchasesQuery($tenant, $eventId)
|
||||||
|
->findOrFail($saleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<Purchase> */
|
||||||
|
protected function purchasesQuery(Tenant $tenant, ?int $eventId): Builder
|
||||||
{
|
{
|
||||||
return Purchase::query()
|
return Purchase::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->findOrFail($saleId);
|
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function saleForResponse(Purchase $sale): Purchase
|
protected function saleForResponse(Purchase $sale): Purchase
|
||||||
|
|||||||
@@ -29,4 +29,8 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
|||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
Cuando el usuario autenticado tiene `event_id`, el controlador lo pasa al servicio como alcance obligatorio para ventas, totales, historial y exportaciones. El alcance se combina con el tenant y no se obtiene de los filtros enviados por el cliente. Los administradores sin `event_id` conservan el alcance del tenant.
|
||||||
|
|
||||||
|
El detalle, los tickets de una venta, la confirmación y la cancelación buscan la compra dentro del mismo alcance. Una venta de otro evento o sin evento devuelve 404 para un administrador con `event_id`, antes de ejecutar cualquier acción en `CheckoutService`.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class AdminAppAdministratorController extends Controller
|
|||||||
return AdministratorResource::collection($this->administratorService->list(
|
return AdministratorResource::collection($this->administratorService->list(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$request->string('search')->trim()->toString() ?: null,
|
$request->string('search')->trim()->toString() ?: null,
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class AdminAppAdministratorController extends Controller
|
|||||||
return AdministratorResource::make($this->administratorService->create(
|
return AdministratorResource::make($this->administratorService->create(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ class AdminAppAdministratorController extends Controller
|
|||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$administrator,
|
$administrator,
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class AdministratorResource extends JsonResource
|
|||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'rol_codigo' => $this->rol_codigo,
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'event_id' => $this->event_id,
|
||||||
'role' => $this->whenLoaded('role', fn () => [
|
'role' => $this->whenLoaded('role', fn () => [
|
||||||
'codigo' => $this->role?->codigo,
|
'codigo' => $this->role?->codigo,
|
||||||
'nombre' => $this->role?->nombre,
|
'nombre' => $this->role?->nombre,
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ class AdministratorService
|
|||||||
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
||||||
|
|
||||||
/** @return Collection<int, User> */
|
/** @return Collection<int, User> */
|
||||||
public function list(Tenant $tenant, ?string $search = null): Collection
|
public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->query($tenant)->with('role')
|
return $this->query($tenant, $eventId)->with('role')
|
||||||
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
||||||
$query->where('nombre_apellido', 'like', "%{$search}%")
|
$query->where('nombre_apellido', 'like', "%{$search}%")
|
||||||
->orWhere('dni', 'like', "%{$search}%")
|
->orWhere('dni', 'like', "%{$search}%")
|
||||||
@@ -31,14 +31,15 @@ class AdministratorService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function create(Tenant $tenant, array $data): User
|
public function create(Tenant $tenant, array $data, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $data): User {
|
return DB::transaction(function () use ($tenant, $data, $eventId): User {
|
||||||
$administrator = User::query()->create([
|
$administrator = User::query()->create([
|
||||||
...$this->attributes($data),
|
...$this->attributes($data),
|
||||||
'password' => Str::random(64),
|
'password' => Str::random(64),
|
||||||
'rol_codigo' => RoleCode::AdminApp->value,
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'event_id' => $eventId,
|
||||||
]);
|
]);
|
||||||
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
||||||
$administrator->email,
|
$administrator->email,
|
||||||
@@ -50,10 +51,10 @@ class AdministratorService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function update(Tenant $tenant, int $administratorId, array $data): User
|
public function update(Tenant $tenant, int $administratorId, array $data, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $administratorId, $data): User {
|
return DB::transaction(function () use ($tenant, $administratorId, $data, $eventId): User {
|
||||||
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
$administrator = $this->query($tenant, $eventId)->lockForUpdate()->findOrFail($administratorId);
|
||||||
$administrator->update($this->attributes($data));
|
$administrator->update($this->attributes($data));
|
||||||
|
|
||||||
return $administrator->load('role');
|
return $administrator->load('role');
|
||||||
@@ -66,11 +67,11 @@ class AdministratorService
|
|||||||
// Serialize deletions for this tenant, including requests already authenticated
|
// Serialize deletions for this tenant, including requests already authenticated
|
||||||
// when another administrator removes their account.
|
// when another administrator removes their account.
|
||||||
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||||
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
$administrator = $this->query($tenant, $actor->event_id)->lockForUpdate()->findOrFail($administratorId);
|
||||||
if ($administrator->is($actor)) {
|
if ($administrator->is($actor)) {
|
||||||
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
||||||
}
|
}
|
||||||
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get();
|
$activeAdministrators = $this->query($tenant, $actor->event_id)->lockForUpdate()->get();
|
||||||
if ($activeAdministrators->count() <= 1) {
|
if ($activeAdministrators->count() <= 1) {
|
||||||
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
||||||
}
|
}
|
||||||
@@ -80,10 +81,11 @@ class AdministratorService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function query(Tenant $tenant): Builder
|
private function query(Tenant $tenant, ?int $eventId = null): Builder
|
||||||
{
|
{
|
||||||
return User::query()->where('tenant_codigo', $tenant->codigo)
|
return User::query()->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('rol_codigo', RoleCode::AdminApp->value);
|
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
|
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data
|
/** @param array<string, mixed> $data
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el fronten
|
|||||||
|
|
||||||
## Verificación
|
## Verificación
|
||||||
|
|
||||||
|
Cuando el actor tiene `event_id`, los nuevos administradores heredan su evento y el listado, la búsqueda, la edición y la baja se limitan a ese evento dentro del tenant. La comprobación de administradores activos también usa ese alcance. El evento se toma del usuario autenticado, no del cuerpo de la solicitud; sin `event_id` se conserva el comportamiento por tenant.
|
||||||
|
|
||||||
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
||||||
|
|
||||||
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Core\Auth\Models;
|
namespace App\Domains\Core\Auth\Models;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Core\Authorization\Models\Role;
|
use App\Domains\Core\Authorization\Models\Role;
|
||||||
use App\Domains\Commerce\Catalog\Models\Category;
|
|
||||||
use App\Domains\Ticketing\Event\Models\EventDateChangeView;
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Ticketing\Event\Models\Event;
|
||||||
|
use App\Domains\Ticketing\Event\Models\EventDateChangeView;
|
||||||
use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
|
use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
|
||||||
use Database\Factories\UserFactory;
|
use Database\Factories\UserFactory;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
@@ -20,7 +21,7 @@ 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', 'event_id'])]
|
||||||
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
|
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
|
||||||
class User extends Authenticatable
|
class User extends Authenticatable
|
||||||
{
|
{
|
||||||
@@ -86,6 +87,12 @@ class User extends Authenticatable
|
|||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Event, $this> */
|
||||||
|
public function event(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Event::class);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsToMany<Category, $this> */
|
/** @return BelongsToMany<Category, $this> */
|
||||||
public function scanCategories(): BelongsToMany
|
public function scanCategories(): BelongsToMany
|
||||||
{
|
{
|
||||||
@@ -103,6 +110,7 @@ class User extends Authenticatable
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
'event_id' => 'integer',
|
||||||
'email_verified_at' => 'datetime',
|
'email_verified_at' => 'datetime',
|
||||||
'password' => 'hashed',
|
'password' => 'hashed',
|
||||||
'failed_login_attempts' => 'integer',
|
'failed_login_attempts' => 'integer',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ class UserResource extends JsonResource
|
|||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
'rol_codigo' => $this->rol_codigo,
|
'rol_codigo' => $this->rol_codigo,
|
||||||
'tenant_codigo' => $this->tenant_codigo,
|
'tenant_codigo' => $this->tenant_codigo,
|
||||||
|
'event_id' => $this->event_id,
|
||||||
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
|
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
|
||||||
->map(fn ($category) => [
|
->map(fn ($category) => [
|
||||||
'id' => $category->id,
|
'id' => $category->id,
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ class AdminAppContextService
|
|||||||
{
|
{
|
||||||
$tenant = $user->tenant()
|
$tenant = $user->tenant()
|
||||||
->with([
|
->with([
|
||||||
|
'socialMedia',
|
||||||
|
'addresses',
|
||||||
|
'phoneNumbers',
|
||||||
'menues' => fn ($query) => $query
|
'menues' => fn ($query) => $query
|
||||||
->whereHas(
|
->whereHas(
|
||||||
'roles',
|
'roles',
|
||||||
|
|||||||
@@ -41,8 +41,9 @@ class TenantBootstrapService
|
|||||||
'roles',
|
'roles',
|
||||||
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
||||||
),
|
),
|
||||||
$tenant->storefront_website_type_code === 'onticket_multi_event'
|
...($tenant->storefront_website_type_code === 'onticket_multi_event'
|
||||||
? 'eventCategories' : 'categories' => fn ($query) => $query->orderBy('nombre'),
|
? ['eventCategories' => fn ($query) => $query->orderBy('nombre')]
|
||||||
|
: ['categories' => fn ($query) => $query->orderBy('sort_order')->orderBy('id')]),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,27 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|||||||
|
|
||||||
class PhoneNumber extends Model
|
class PhoneNumber extends Model
|
||||||
{
|
{
|
||||||
protected $fillable = ['label', 'number'];
|
protected $attributes = ['country_code' => '+54', 'mobile_prefix' => '9'];
|
||||||
|
|
||||||
|
protected $fillable = ['label', 'country_code', 'mobile_prefix', 'area_code', 'local_number'];
|
||||||
|
|
||||||
|
public function getNumberAttribute(?string $legacyNumber): string
|
||||||
|
{
|
||||||
|
if ($this->local_number === null) {
|
||||||
|
return $legacyNumber ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$areaCode = $this->mobile_prefix && $this->area_code
|
||||||
|
? '('.$this->area_code.')'
|
||||||
|
: $this->area_code;
|
||||||
|
|
||||||
|
return implode(' ', array_filter([
|
||||||
|
$this->country_code,
|
||||||
|
$this->mobile_prefix,
|
||||||
|
$areaCode,
|
||||||
|
$this->local_number,
|
||||||
|
], fn ($part): bool => $part !== null && $part !== ''));
|
||||||
|
}
|
||||||
|
|
||||||
public static function dialableNumber(string $number): string
|
public static function dialableNumber(string $number): string
|
||||||
{
|
{
|
||||||
@@ -17,9 +37,20 @@ class PhoneNumber extends Model
|
|||||||
|
|
||||||
public function telUrl(): string
|
public function telUrl(): string
|
||||||
{
|
{
|
||||||
|
if ($this->country_code === null || $this->local_number === null) {
|
||||||
return 'tel:'.self::dialableNumber($this->number);
|
return 'tel:'.self::dialableNumber($this->number);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$areaCode = $this->area_code ?? '';
|
||||||
|
if ($this->country_code === '+54') {
|
||||||
|
$areaCode = ltrim($areaCode, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'tel:'.self::dialableNumber(
|
||||||
|
$this->country_code.($this->mobile_prefix ?? '').$areaCode.$this->local_number
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function tenants(): BelongsToMany
|
public function tenants(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(Tenant::class, 'tenant_phone_numbers', 'phone_number_id', 'tenant_codigo', 'id', 'codigo')
|
return $this->belongsToMany(Tenant::class, 'tenant_phone_numbers', 'phone_number_id', 'tenant_codigo', 'id', 'codigo')
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ class PhoneNumberResource extends JsonResource
|
|||||||
return [
|
return [
|
||||||
'id' => $this->id,
|
'id' => $this->id,
|
||||||
'label' => $this->label,
|
'label' => $this->label,
|
||||||
|
'country_code' => $this->country_code,
|
||||||
|
'mobile_prefix' => $this->mobile_prefix,
|
||||||
|
'area_code' => $this->area_code,
|
||||||
|
'local_number' => $this->local_number,
|
||||||
'number' => $this->number,
|
'number' => $this->number,
|
||||||
'tel_url' => $this->resource->telUrl(),
|
'tel_url' => $this->resource->telUrl(),
|
||||||
'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main),
|
'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main),
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ class AdminAppStaffController extends Controller
|
|||||||
return StaffResource::collection($this->staffService->list(
|
return StaffResource::collection($this->staffService->list(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$request->string('search')->trim()->toString() ?: null,
|
$request->string('search')->trim()->toString() ?: null,
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ class AdminAppStaffController extends Controller
|
|||||||
return StaffResource::make($this->staffService->create(
|
return StaffResource::make($this->staffService->create(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +45,13 @@ class AdminAppStaffController extends Controller
|
|||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$staff,
|
$staff,
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, int $staff): Response
|
public function destroy(Request $request, int $staff): Response
|
||||||
{
|
{
|
||||||
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff);
|
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $request->user()->event_id);
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,7 @@ class AdminAppStaffController extends Controller
|
|||||||
$scanner = $this->staffService->find(
|
$scanner = $this->staffService->find(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$staff,
|
$staff,
|
||||||
|
$request->user()->event_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return ScanAttemptResource::collection(
|
return ScanAttemptResource::collection(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class StaffResource extends JsonResource
|
|||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'rol_codigo' => $this->rol_codigo,
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'event_id' => $this->event_id,
|
||||||
'role' => $this->whenLoaded('role', fn () => [
|
'role' => $this->whenLoaded('role', fn () => [
|
||||||
'codigo' => $this->role?->codigo,
|
'codigo' => $this->role?->codigo,
|
||||||
'nombre' => $this->role?->nombre,
|
'nombre' => $this->role?->nombre,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
namespace App\Domains\Core\Staff\Services;
|
namespace App\Domains\Core\Staff\Services;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
use App\Domains\Core\Auth\Models\ResetPasswordAttempt;
|
use App\Domains\Core\Auth\Models\ResetPasswordAttempt;
|
||||||
use App\Domains\Core\Auth\Models\User;
|
use App\Domains\Core\Auth\Models\User;
|
||||||
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
|
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
|
||||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Commerce\Catalog\Models\Category;
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@@ -22,9 +22,9 @@ class StaffService
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @return Collection<int, User> */
|
/** @return Collection<int, User> */
|
||||||
public function list(Tenant $tenant, ?string $search = null): Collection
|
public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->staffQuery($tenant)
|
return $this->staffQuery($tenant, $eventId)
|
||||||
->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')])
|
->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')])
|
||||||
->when($search, function (Builder $query, string $search): void {
|
->when($search, function (Builder $query, string $search): void {
|
||||||
$query->where(function (Builder $query) use ($search): void {
|
$query->where(function (Builder $query) use ($search): void {
|
||||||
@@ -52,18 +52,19 @@ class StaffService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function create(Tenant $tenant, array $data): User
|
public function create(Tenant $tenant, array $data, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
||||||
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
||||||
|
|
||||||
return DB::transaction(function () use ($tenant, $data, $categoryIds): User {
|
return DB::transaction(function () use ($tenant, $data, $categoryIds, $eventId): User {
|
||||||
$staff = User::query()->create([
|
$staff = User::query()->create([
|
||||||
...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
|
...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
|
||||||
'email' => mb_strtolower(trim((string) $data['email'])),
|
'email' => mb_strtolower(trim((string) $data['email'])),
|
||||||
'password' => Str::random(64),
|
'password' => Str::random(64),
|
||||||
'rol_codigo' => RoleCode::Scanner->value,
|
'rol_codigo' => RoleCode::Scanner->value,
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'event_id' => $eventId,
|
||||||
]);
|
]);
|
||||||
$staff->scanCategories()->sync($categoryIds);
|
$staff->scanCategories()->sync($categoryIds);
|
||||||
$this->resetPasswordAttemptService->createForScannerEmail(
|
$this->resetPasswordAttemptService->createForScannerEmail(
|
||||||
@@ -76,9 +77,9 @@ class StaffService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function update(Tenant $tenant, int $staffId, array $data): User
|
public function update(Tenant $tenant, int $staffId, array $data, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
$staff = $this->find($tenant, $staffId);
|
$staff = $this->find($tenant, $staffId, $eventId);
|
||||||
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
||||||
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
||||||
|
|
||||||
@@ -92,9 +93,9 @@ class StaffService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(Tenant $tenant, int $staffId): void
|
public function delete(Tenant $tenant, int $staffId, ?int $eventId = null): void
|
||||||
{
|
{
|
||||||
$staff = $this->find($tenant, $staffId);
|
$staff = $this->find($tenant, $staffId, $eventId);
|
||||||
|
|
||||||
DB::transaction(function () use ($staff): void {
|
DB::transaction(function () use ($staff): void {
|
||||||
$staff->tokens()->delete();
|
$staff->tokens()->delete();
|
||||||
@@ -102,16 +103,17 @@ class StaffService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find(Tenant $tenant, int $staffId): User
|
public function find(Tenant $tenant, int $staffId, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
return $this->staffQuery($tenant)->findOrFail($staffId);
|
return $this->staffQuery($tenant, $eventId)->findOrFail($staffId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function staffQuery(Tenant $tenant): Builder
|
private function staffQuery(Tenant $tenant, ?int $eventId = null): Builder
|
||||||
{
|
{
|
||||||
return User::query()
|
return User::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('rol_codigo', RoleCode::Scanner->value);
|
->where('rol_codigo', RoleCode::Scanner->value)
|
||||||
|
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido
|
|||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado.
|
Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado.
|
||||||
|
|
||||||
|
Si el administrador autenticado tiene `event_id`, el alta de scanners hereda ese valor y las búsquedas, ediciones, bajas y consultas de intentos de escaneo se limitan a personal del mismo evento. El cliente no puede elegir ni cambiar el evento. Sin `event_id`, se mantiene el alcance por tenant.
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Core\Tenant\Requests\AdminApp\UpdateContactRequest;
|
||||||
|
use App\Domains\Core\Tenant\Resources\AdminApp\ContactResource;
|
||||||
|
use App\Domains\Core\Tenant\Services\ContactService;
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
|
class ContactController extends Controller
|
||||||
|
{
|
||||||
|
public function show(Request $request, ContactService $service): ContactResource
|
||||||
|
{
|
||||||
|
return ContactResource::make($service->load($request->user()->tenant()->firstOrFail()));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(UpdateContactRequest $request, ContactService $service): ContactResource
|
||||||
|
{
|
||||||
|
return ContactResource::make($service->update($request->user()->tenant()->firstOrFail(), $request->validated()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Core\Tenant\Requests\AdminApp;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateContactRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
$tenantCode = $this->user()->tenant_codigo;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'social_media' => ['present', 'array', 'max:30'],
|
||||||
|
'social_media.*' => ['array:code,url'],
|
||||||
|
'social_media.*.code' => ['required', 'string', 'distinct', Rule::exists('social_media', 'code')],
|
||||||
|
'social_media.*.url' => ['required', 'url:http,https', 'max:255'],
|
||||||
|
'addresses' => ['present', 'array', 'list', 'max:50'],
|
||||||
|
'addresses.*' => ['array:id,address_text,is_main'],
|
||||||
|
'addresses.*.id' => ['nullable', 'integer', 'distinct', Rule::exists('tenant_addresses', 'address_id')->where('tenant_codigo', $tenantCode)],
|
||||||
|
'addresses.*.address_text' => ['required', 'string', 'max:1000'],
|
||||||
|
'addresses.*.is_main' => ['sometimes', 'boolean'],
|
||||||
|
'phone_numbers' => ['present', 'array', 'list', 'max:50'],
|
||||||
|
'phone_numbers.*' => ['array:id,country_code,mobile_prefix,area_code,local_number,is_main'],
|
||||||
|
'phone_numbers.*.id' => ['nullable', 'integer', 'distinct', Rule::exists('tenant_phone_numbers', 'phone_number_id')->where('tenant_codigo', $tenantCode)],
|
||||||
|
'phone_numbers.*.country_code' => ['present', 'nullable', 'regex:/^\+[0-9]{1,4}$/'],
|
||||||
|
'phone_numbers.*.mobile_prefix' => ['present', 'nullable', 'regex:/^[0-9]{1,4}$/'],
|
||||||
|
'phone_numbers.*.area_code' => ['present', 'nullable', 'regex:/^[0-9]{1,8}$/'],
|
||||||
|
'phone_numbers.*.local_number' => ['required', 'string', 'max:50', 'regex:/^[0-9+() .-]+$/', 'regex:/[0-9]/'],
|
||||||
|
'phone_numbers.*.is_main' => ['sometimes', 'boolean'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attributes(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'social_media' => 'redes sociales',
|
||||||
|
'social_media.*.code' => 'red social',
|
||||||
|
'social_media.*.url' => 'URL de la red social',
|
||||||
|
'addresses' => 'direcciones',
|
||||||
|
'addresses.*.id' => 'dirección',
|
||||||
|
'addresses.*.address_text' => 'dirección',
|
||||||
|
'addresses.*.is_main' => 'dirección principal',
|
||||||
|
'phone_numbers' => 'teléfonos',
|
||||||
|
'phone_numbers.*.id' => 'teléfono',
|
||||||
|
'phone_numbers.*.country_code' => 'código de país',
|
||||||
|
'phone_numbers.*.mobile_prefix' => 'prefijo móvil',
|
||||||
|
'phone_numbers.*.area_code' => 'código de área',
|
||||||
|
'phone_numbers.*.local_number' => 'número de teléfono',
|
||||||
|
'phone_numbers.*.is_main' => 'teléfono principal',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function messages(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'required' => 'Completá :attribute.',
|
||||||
|
'url' => 'La URL debe ser válida y comenzar con http:// o https://.',
|
||||||
|
'regex' => 'El formato de :attribute no es válido.',
|
||||||
|
'exists' => 'El valor de :attribute no está disponible para este comercio.',
|
||||||
|
'distinct' => 'Hay un valor duplicado en :attribute.',
|
||||||
|
'max.string' => 'El campo :attribute admite hasta :max caracteres.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Core\Tenant\Resources\AdminApp;
|
||||||
|
|
||||||
|
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
|
||||||
|
use App\Domains\Core\Tenant\Resources\TenantAddressResource;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
|
class ContactResource extends JsonResource
|
||||||
|
{
|
||||||
|
public function toArray(Request $request): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'social_media' => $this->socialMedia->map(fn ($network) => [
|
||||||
|
'code' => $network->code,
|
||||||
|
'url' => $network->pivot->url,
|
||||||
|
])->values(),
|
||||||
|
'addresses' => TenantAddressResource::collection($this->addresses),
|
||||||
|
'phone_numbers' => PhoneNumberResource::collection($this->phoneNumbers),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,8 +4,8 @@ namespace App\Domains\Core\Tenant\Resources;
|
|||||||
|
|
||||||
use App\Domains\Commerce\Catalog\Models\Category;
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
use App\Domains\Core\Address\Resources\AddressResource;
|
use App\Domains\Core\Address\Resources\AddressResource;
|
||||||
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
|
|
||||||
use App\Domains\Core\Menu\Models\Menu;
|
use App\Domains\Core\Menu\Models\Menu;
|
||||||
|
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
|
||||||
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
|
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticketing\Event\Models\Event;
|
use App\Domains\Ticketing\Event\Models\Event;
|
||||||
@@ -105,6 +105,13 @@ class TenantResource extends JsonResource
|
|||||||
])
|
])
|
||||||
->values()
|
->values()
|
||||||
),
|
),
|
||||||
|
'tenant_social_media' => $this->whenLoaded('socialMedia', fn () => $this->socialMedia
|
||||||
|
->map(fn ($socialMedia) => [
|
||||||
|
'code' => $socialMedia->code,
|
||||||
|
'icon' => $socialMedia->icon,
|
||||||
|
'name' => $socialMedia->name,
|
||||||
|
'url' => $socialMedia->pivot->url,
|
||||||
|
])->values()),
|
||||||
'menues' => $this->whenLoaded(
|
'menues' => $this->whenLoaded(
|
||||||
'menues',
|
'menues',
|
||||||
fn () => $this->menuTree($this->menues)
|
fn () => $this->menuTree($this->menues)
|
||||||
@@ -200,12 +207,14 @@ class TenantResource extends JsonResource
|
|||||||
'nombre' => $category->nombre,
|
'nombre' => $category->nombre,
|
||||||
'subcategories' => $childrenByParent
|
'subcategories' => $childrenByParent
|
||||||
->get($category->id, collect())
|
->get($category->id, collect())
|
||||||
|
->filter(fn (Category $child) => $child->is_enabled)
|
||||||
->map($formatCategory)
|
->map($formatCategory)
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
return $categories
|
return $categories
|
||||||
|
->filter(fn (Category $category) => $category->is_enabled)
|
||||||
->filter(fn (Category $category) => $category->categoria_id === null
|
->filter(fn (Category $category) => $category->categoria_id === null
|
||||||
|| ! $categoryIds->has($category->categoria_id))
|
|| ! $categoryIds->has($category->categoria_id))
|
||||||
->map($formatCategory)
|
->map($formatCategory)
|
||||||
@@ -233,7 +242,7 @@ class TenantResource extends JsonResource
|
|||||||
'route' => $menu->route,
|
'route' => $menu->route,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($menu->pivot?->static_content !== null) {
|
if ($menu->content_type === Menu::CONTENT_TYPE_STATIC && $menu->pivot?->static_content !== null) {
|
||||||
$formatted['static_content'] = $menu->pivot->static_content;
|
$formatted['static_content'] = $menu->pivot->static_content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
73
app/Domains/Core/Tenant/Services/ContactService.php
Normal file
73
app/Domains/Core/Tenant/Services/ContactService.php
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Core\Tenant\Services;
|
||||||
|
|
||||||
|
use App\Domains\Core\Address\Models\Address;
|
||||||
|
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
|
||||||
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
|
||||||
|
class ContactService
|
||||||
|
{
|
||||||
|
public function load(Tenant $tenant): Tenant
|
||||||
|
{
|
||||||
|
return $tenant->load([
|
||||||
|
'socialMedia',
|
||||||
|
'addresses' => fn ($query) => $query->reorder()->orderByPivot('is_main', 'desc')->orderByPivot('id'),
|
||||||
|
'phoneNumbers',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function update(Tenant $tenant, array $data): Tenant
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($tenant, $data): Tenant {
|
||||||
|
// Serialize contact writes, including changes to the principal links.
|
||||||
|
Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail();
|
||||||
|
$social = [];
|
||||||
|
foreach ($data['social_media'] as $index => $item) {
|
||||||
|
$social[$item['code']] = ['url' => $item['url'], 'orden' => $index];
|
||||||
|
}
|
||||||
|
$tenant->socialMedia()->sync($social);
|
||||||
|
|
||||||
|
foreach (['addresses', 'phone_numbers'] as $collection) {
|
||||||
|
$relation = $collection === 'addresses' ? $tenant->addresses() : $tenant->phoneNumbers();
|
||||||
|
$existing = $relation->get()->keyBy('id');
|
||||||
|
$links = [];
|
||||||
|
foreach (array_values($data[$collection]) as $index => $item) {
|
||||||
|
$id = $item['id'] ?? null;
|
||||||
|
if ($id !== null && ! $existing->has($id)) {
|
||||||
|
throw ValidationException::withMessages(["{$collection}.{$index}.id" => 'El contacto no pertenece al comercio.']);
|
||||||
|
}
|
||||||
|
$record = $id === null
|
||||||
|
? ($collection === 'addresses' ? new Address : new PhoneNumber)
|
||||||
|
: $existing->get($id);
|
||||||
|
$attributes = collect($item)->except(['id', 'is_main'])->all();
|
||||||
|
$record->fill($attributes);
|
||||||
|
if (! $record->exists) {
|
||||||
|
$record->label = $collection === 'addresses' ? 'Dirección' : 'Teléfono';
|
||||||
|
}
|
||||||
|
if ($record->exists && $record->isDirty()) {
|
||||||
|
$shared = $record->tenants()->where('tenants.codigo', '!=', $tenant->codigo)->exists()
|
||||||
|
|| ($record instanceof Address && $record->events()->exists());
|
||||||
|
if ($shared) {
|
||||||
|
$record = $record->replicate();
|
||||||
|
}
|
||||||
|
// An edited street address must not retain coordinates for the old location.
|
||||||
|
if ($record instanceof Address && $record->isDirty('address_text')) {
|
||||||
|
$record->latitude = null;
|
||||||
|
$record->longitude = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$record->save();
|
||||||
|
$links[$record->id] = ['is_main' => $index === 0];
|
||||||
|
}
|
||||||
|
// Clear the previous principal before sync to respect the unique index.
|
||||||
|
$relation->newPivotStatement()->where('tenant_codigo', $tenant->codigo)->update(['is_main' => false]);
|
||||||
|
$relation->sync($links);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->load($tenant);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,9 +28,16 @@ El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant
|
|||||||
- `GET /tenants`, `GET /tenants/{codigo}` y `DELETE /tenants/{codigo}`.
|
- `GET /tenants`, `GET /tenants/{codigo}` y `DELETE /tenants/{codigo}`.
|
||||||
- La creación y actualización general de tenants se administran mediante seeders o base de datos; no se exponen por API.
|
- La creación y actualización general de tenants se administran mediante seeders o base de datos; no se exponen por API.
|
||||||
- Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación.
|
- Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación.
|
||||||
|
- `GET /v1/adminapp/tenant/contact` y `PUT /v1/adminapp/tenant/contact`, con `auth:sanctum` y `adminapp.tenant`: consulta y reemplazo completo del contacto del comercio autenticado.
|
||||||
|
|
||||||
|
El PUT de contacto recibe `social_media` (`code`, `url`), `addresses` (`id` opcional para existentes, `address_text`) y `phone_numbers` (`id` opcional, `country_code`, `mobile_prefix`, `area_code`, `local_number`). Las tres colecciones son obligatorias; un array vacío elimina sus vínculos. El primer elemento enviado en cada lista de direcciones o teléfonos es siempre el principal: el backend calcula `is_main` por posición e ignora las marcas enviadas por clientes anteriores. GET y PUT devuelven el principal primero para conservarlo en el siguiente guardado. Los IDs existentes deben estar vinculados al comercio autenticado. Todos los cambios se realizan en una transacción, serializada por tenant, y la respuesta devuelve los registros persistidos y sus IDs.
|
||||||
|
|
||||||
|
Se conservan etiquetas y coordenadas de direcciones sin modificar; al cambiar el texto se limpian las coordenadas para evitar mostrar el mapa de una ubicación anterior. Las ediciones de registros compartidos crean una copia para el comercio, preservando otros comercios y eventos. Las bajas desvinculan registros sin borrar datos compartidos.
|
||||||
|
|
||||||
|
Contacto administra exclusivamente las redes del comercio. `TenantResource.tenant_social_media` siempre expone esas redes y alimenta Contacto y el footer públicos. `social_media` mantiene la selección histórica del evento activo; `event.social_media` sigue disponible para las vistas de evento. Guardar Contacto no modifica redes del evento.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral.
|
Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral.
|
||||||
|
|
||||||
Los resources exponen `phone_numbers` (incluye `is_main`) y `main_phone_number`, con `id`, `label`, `number` y `tel_url`. Contacto muestra la colección y el footer usa el principal. La migración copia primero `tenants.phone` como principal y luego el teléfono de `help.contact`, si es distinto; si no existe teléfono del tenant, el del menú pasa a ser principal. Los duplicados por formato se comparan dentro de cada tenant sin inferir país ni código de área. Los campos anteriores se conservan como datos legacy, pero la tienda consume las relaciones nuevas. No hay endpoints de edición de teléfonos ni cambios del adminapp en esta etapa.
|
Los resources exponen `phone_numbers` (incluye `is_main`) y `main_phone_number`, con `id`, `label`, `number` y `tel_url`. Contacto muestra la colección y el footer usa el principal. La migración copia primero `tenants.phone` como principal y luego el teléfono de `help.contact`, si es distinto; si no existe teléfono del tenant, el del menú pasa a ser principal. Los duplicados por formato se comparan dentro de cada tenant sin inferir país ni código de área. Los campos anteriores se conservan como datos legacy, pero la tienda consume las relaciones nuevas y el adminapp las edita mediante el endpoint de contacto.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Controllers\AdminApp\BrandController;
|
use App\Domains\Core\Tenant\Controllers\AdminApp\BrandController;
|
||||||
|
use App\Domains\Core\Tenant\Controllers\AdminApp\ContactController;
|
||||||
use App\Domains\Core\Tenant\Controllers\AdminApp\HelpMenuController;
|
use App\Domains\Core\Tenant\Controllers\AdminApp\HelpMenuController;
|
||||||
use App\Domains\Core\Tenant\Controllers\AdminApp\WebsiteExtraController;
|
use App\Domains\Core\Tenant\Controllers\AdminApp\WebsiteExtraController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -8,6 +9,8 @@ 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('contact', [ContactController::class, 'show']);
|
||||||
|
Route::put('contact', [ContactController::class, 'update']);
|
||||||
Route::get('brand', [BrandController::class, 'show']);
|
Route::get('brand', [BrandController::class, 'show']);
|
||||||
Route::put('brand', [BrandController::class, 'update']);
|
Route::put('brand', [BrandController::class, 'update']);
|
||||||
Route::get('help-menus', [HelpMenuController::class, 'index']);
|
Route::get('help-menus', [HelpMenuController::class, 'index']);
|
||||||
|
|||||||
@@ -5,28 +5,28 @@ use App\Domains\Ticketing\Desfile\Controllers\EntryReservationController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index'])
|
Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index'])
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.index');
|
->name('adminapp.desfile.entry-reservations.index');
|
||||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf'])
|
Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf'])
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.pdf');
|
->name('adminapp.desfile.entry-reservations.pdf');
|
||||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel'])
|
Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel'])
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.excel');
|
->name('adminapp.desfile.entry-reservations.excel');
|
||||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf'])
|
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf'])
|
||||||
->whereNumber('reservation')
|
->whereNumber('reservation')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.ticket.pdf');
|
->name('adminapp.desfile.entry-reservations.ticket.pdf');
|
||||||
Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store'])
|
Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store'])
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.store');
|
->name('adminapp.desfile.entry-reservations.store');
|
||||||
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy'])
|
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy'])
|
||||||
->whereNumber('reservation')
|
->whereNumber('reservation')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.destroy');
|
->name('adminapp.desfile.entry-reservations.destroy');
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/tenant/desfile')
|
Route::prefix('v1/adminapp/tenant/desfile')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.entradas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('entries', [EntryController::class, 'show'])
|
Route::get('entries', [EntryController::class, 'show'])
|
||||||
->name('adminapp.desfile.entries.show');
|
->name('adminapp.desfile.entries.show');
|
||||||
|
|||||||
@@ -19,48 +19,48 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
->middleware("tenant.menu:{$menuCode}");
|
->middleware("tenant.menu:{$menuCode}");
|
||||||
};
|
};
|
||||||
|
|
||||||
$visibilityRoutes('entries', 'entries', 'onticket.adminapp.fiesta-futbol-infantil.entradas');
|
$visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
|
||||||
$visibilityRoutes('foods', 'foods', 'onticket.adminapp.fiesta-futbol-infantil.comida');
|
$visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
|
||||||
$visibilityRoutes('accommodations', 'accommodations', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos');
|
$visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
|
||||||
$visibilityRoutes('merchandise', 'merchandise', 'onticket.adminapp.fiesta-futbol-infantil.merchandising');
|
$visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
|
||||||
|
|
||||||
Route::get('entries', [EntryController::class, 'index'])
|
Route::get('entries', [EntryController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
->name('adminapp.fiesta-futbol-infantil.entries.index');
|
->name('adminapp.fiesta-futbol-infantil.entries.index');
|
||||||
Route::post('entries', [EntryController::class, 'store'])
|
Route::post('entries', [EntryController::class, 'store'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
->name('adminapp.fiesta-futbol-infantil.entries.store');
|
->name('adminapp.fiesta-futbol-infantil.entries.store');
|
||||||
Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
|
Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
->name('adminapp.fiesta-futbol-infantil.entries.destroy');
|
->name('adminapp.fiesta-futbol-infantil.entries.destroy');
|
||||||
Route::get('foods', [FoodController::class, 'index'])
|
Route::get('foods', [FoodController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.index');
|
->name('adminapp.fiesta-futbol-infantil.foods.index');
|
||||||
Route::post('foods', [FoodController::class, 'store'])
|
Route::post('foods', [FoodController::class, 'store'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.store');
|
->name('adminapp.fiesta-futbol-infantil.foods.store');
|
||||||
Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
|
Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
|
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
|
||||||
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
|
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
||||||
Route::get('accommodations', [AccommodationController::class, 'index'])
|
Route::get('accommodations', [AccommodationController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
->name('adminapp.fiesta-futbol-infantil.accommodations.index');
|
->name('adminapp.fiesta-futbol-infantil.accommodations.index');
|
||||||
Route::post('accommodations', [AccommodationController::class, 'store'])
|
Route::post('accommodations', [AccommodationController::class, 'store'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
->name('adminapp.fiesta-futbol-infantil.accommodations.store');
|
->name('adminapp.fiesta-futbol-infantil.accommodations.store');
|
||||||
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
|
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
|
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
|
||||||
Route::get('merchandise', [MerchandiseController::class, 'index'])
|
Route::get('merchandise', [MerchandiseController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
->name('adminapp.fiesta-futbol-infantil.merchandise.index');
|
->name('adminapp.fiesta-futbol-infantil.merchandise.index');
|
||||||
Route::post('merchandise', [MerchandiseController::class, 'store'])
|
Route::post('merchandise', [MerchandiseController::class, 'store'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
->name('adminapp.fiesta-futbol-infantil.merchandise.store');
|
->name('adminapp.fiesta-futbol-infantil.merchandise.store');
|
||||||
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
|
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
|
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class TicketController extends Controller
|
|||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new AdminAppTicketCollection(
|
return new AdminAppTicketCollection(
|
||||||
$this->ticketService->search($tenant, $request->validated())
|
$this->ticketService->search($tenant, $request->validated(), $request->user()->event_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ class TicketController extends Controller
|
|||||||
{
|
{
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket));
|
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket, $request->user()->event_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource
|
public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource
|
||||||
@@ -45,7 +45,7 @@ class TicketController extends Controller
|
|||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new AdminAppTicketRefundCalculationResource(
|
return new AdminAppTicketRefundCalculationResource(
|
||||||
$this->ticketService->calculateRefund($tenant, $ticket)
|
$this->ticketService->calculateRefund($tenant, $ticket, $request->user()->event_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
return $this->ticketPdfService->download(
|
return $this->ticketPdfService->download(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
$this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
return $this->ticketExcelService->download(
|
return $this->ticketExcelService->download(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
$this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ class AdminAppTicketService
|
|||||||
/**
|
/**
|
||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
*/
|
*/
|
||||||
public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult
|
public function search(Tenant $tenant, array $filters = [], ?int $eventId = null): AdminAppTicketResult
|
||||||
{
|
{
|
||||||
$query = $this->baseQuery($tenant, $filters);
|
$query = $this->baseQuery($tenant, $filters, $eventId);
|
||||||
$countQuery = clone $query;
|
$countQuery = clone $query;
|
||||||
|
|
||||||
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
||||||
@@ -77,7 +77,7 @@ class AdminAppTicketService
|
|||||||
tickets: $tickets,
|
tickets: $tickets,
|
||||||
scannedTickets: $scannedTickets,
|
scannedTickets: $scannedTickets,
|
||||||
totalTickets: $totalTickets,
|
totalTickets: $totalTickets,
|
||||||
refundedTotal: $this->refundSummaryService->totalForTenant($tenant),
|
refundedTotal: $this->refundSummaryService->totalForTenant($tenant, $eventId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +85,9 @@ class AdminAppTicketService
|
|||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
* @return Collection<int, Ticket>
|
* @return Collection<int, Ticket>
|
||||||
*/
|
*/
|
||||||
public function ticketsForExport(Tenant $tenant, array $filters = []): Collection
|
public function ticketsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
$query = $this->baseQuery($tenant, $filters);
|
$query = $this->baseQuery($tenant, $filters, $eventId);
|
||||||
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
||||||
$tickets = $query
|
$tickets = $query
|
||||||
->with(self::RELATIONS)
|
->with(self::RELATIONS)
|
||||||
@@ -97,11 +97,10 @@ class AdminAppTicketService
|
|||||||
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
|
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(Tenant $tenant, int $ticketId): Ticket
|
public function cancel(Tenant $tenant, int $ticketId, ?int $eventId = null): Ticket
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $ticketId): Ticket {
|
return DB::transaction(function () use ($tenant, $ticketId, $eventId): Ticket {
|
||||||
$ticket = Ticket::query()
|
$ticket = $this->ticketsQuery($tenant, $eventId)
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($ticketId);
|
->findOrFail($ticketId);
|
||||||
|
|
||||||
@@ -124,10 +123,9 @@ class AdminAppTicketService
|
|||||||
* partial: string|null,
|
* partial: string|null,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function calculateRefund(Tenant $tenant, int $ticketId): array
|
public function calculateRefund(Tenant $tenant, int $ticketId, ?int $eventId = null): array
|
||||||
{
|
{
|
||||||
$ticket = Ticket::query()
|
$ticket = $this->ticketsQuery($tenant, $eventId)
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->findOrFail($ticketId);
|
->findOrFail($ticketId);
|
||||||
|
|
||||||
if (! $ticket->can_refund()) {
|
if (! $ticket->can_refund()) {
|
||||||
@@ -175,14 +173,13 @@ class AdminAppTicketService
|
|||||||
string $refundType,
|
string $refundType,
|
||||||
?User $createdBy = null,
|
?User $createdBy = null,
|
||||||
): Ticket {
|
): Ticket {
|
||||||
$this->ensureRefundIsAllowed($tenant, $refundType);
|
|
||||||
|
|
||||||
return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket {
|
return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket {
|
||||||
$ticket = Ticket::query()
|
$ticket = $this->ticketsQuery($tenant, $createdBy?->event_id)
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($ticketId);
|
->findOrFail($ticketId);
|
||||||
|
|
||||||
|
$this->ensureRefundIsAllowed($tenant, $refundType);
|
||||||
|
|
||||||
if (! $ticket->can_refund()) {
|
if (! $ticket->can_refund()) {
|
||||||
if ($ticket->status !== Ticket::STATUS_ACTIVE) {
|
if ($ticket->status !== Ticket::STATUS_ACTIVE) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
@@ -310,12 +307,11 @@ class AdminAppTicketService
|
|||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
||||||
* @return Builder<Ticket>
|
* @return Builder<Ticket>
|
||||||
*/
|
*/
|
||||||
private function baseQuery(Tenant $tenant, array $filters): Builder
|
private function baseQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
|
||||||
{
|
{
|
||||||
$search = trim((string) ($filters['q'] ?? ''));
|
$search = trim((string) ($filters['q'] ?? ''));
|
||||||
|
|
||||||
$query = Ticket::query()
|
$query = $this->ticketsQuery($tenant, $eventId)
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->when($search !== '', function (Builder $query) use ($search): void {
|
->when($search !== '', function (Builder $query) use ($search): void {
|
||||||
$this->applySearchFilter($query, $search);
|
$this->applySearchFilter($query, $search);
|
||||||
})
|
})
|
||||||
@@ -355,6 +351,14 @@ class AdminAppTicketService
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return Builder<Ticket> */
|
||||||
|
private function ticketsQuery(Tenant $tenant, ?int $eventId): Builder
|
||||||
|
{
|
||||||
|
return Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->when($eventId !== null, fn (Builder $query): Builder => $query->where('tickets.event_id', $eventId));
|
||||||
|
}
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
/** @param Builder<Ticket> $query */
|
||||||
private function applySearchFilter(Builder $query, string $search): void
|
private function applySearchFilter(Builder $query, string $search): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -82,13 +82,17 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
|
|||||||
- `POST /tickets/pdf`.
|
- `POST /tickets/pdf`.
|
||||||
|
|
||||||
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
|
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
|
||||||
`onticket.adminapp.tickets`:
|
`adminapp.tickets`:
|
||||||
|
|
||||||
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
|
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
|
||||||
`scanned_tickets` y `total_tickets` para el tenant autenticado.
|
`scanned_tickets` y `total_tickets` para el tenant autenticado.
|
||||||
|
|
||||||
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
|
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
|
||||||
|
|
||||||
|
Si el administrador autenticado tiene `event_id`, las consultas de Tickets y sus exportaciones se limitan a `tickets.event_id` dentro del tenant. Los contadores usan el mismo alcance y el total reembolsado se limita a las compras del evento. Sin `event_id`, se conserva el alcance por tenant.
|
||||||
|
|
||||||
|
La cancelación, el cálculo de reembolso y el reembolso buscan el ticket dentro de ese alcance antes de validar o ejecutar la operación. Un ticket de otro evento o sin evento devuelve 404 para un administrador con evento asignado. El alcance se obtiene del usuario autenticado, no de los parámetros del cliente.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación.
|
Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación.
|
||||||
|
|||||||
@@ -7,24 +7,24 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('tickets', [TicketController::class, 'index'])
|
Route::get('tickets', [TicketController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.index');
|
->name('adminapp.tickets.index');
|
||||||
Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel'])
|
Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel'])
|
||||||
->whereNumber('ticket')
|
->whereNumber('ticket')
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.cancel');
|
->name('adminapp.tickets.cancel');
|
||||||
Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund'])
|
Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund'])
|
||||||
->whereNumber('ticket')
|
->whereNumber('ticket')
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.calculate-refund');
|
->name('adminapp.tickets.calculate-refund');
|
||||||
Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund'])
|
Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund'])
|
||||||
->whereNumber('ticket')
|
->whereNumber('ticket')
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.refund');
|
->name('adminapp.tickets.refund');
|
||||||
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
|
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.pdf');
|
->name('adminapp.tickets.pdf');
|
||||||
Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
|
Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.excel');
|
->name('adminapp.tickets.excel');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ Route::prefix('v1/adminapp/forms')
|
|||||||
Route::get(
|
Route::get(
|
||||||
'desfile/entry-reservation',
|
'desfile/entry-reservation',
|
||||||
DesfileEntryReservationFormController::class
|
DesfileEntryReservationFormController::class
|
||||||
)->middleware('tenant.menu:onticket.adminapp.desfile.reservas')
|
)->middleware('tenant.menu:adminapp.desfile.reservas')
|
||||||
->name('adminapp.forms.desfile.entry-reservation');
|
->name('adminapp.forms.desfile.entry-reservation');
|
||||||
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)
|
Route::get('tickets-filter', TicketFilterFormController::class)
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.forms.tickets-filter');
|
->name('adminapp.forms.tickets-filter');
|
||||||
Route::get(
|
Route::get(
|
||||||
'fiesta-futbol-infantil/ticket',
|
'fiesta-futbol-infantil/ticket',
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('menues')->where('code', 'help.contact')->update([
|
||||||
|
'content_type' => 'dynamic',
|
||||||
|
'static_content_schema' => null,
|
||||||
|
]);
|
||||||
|
// Preserve legacy pivot content for rollback; dynamic menus do not expose it.
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
DB::table('menues')->where('code', 'help.contact')->update([
|
||||||
|
'content_type' => 'static',
|
||||||
|
'static_content_schema' => json_encode([
|
||||||
|
'whatsapp' => 'required|array',
|
||||||
|
'whatsapp.whatsapp_url' => 'required|url',
|
||||||
|
'whatsapp.whatsapp_label' => 'required|string',
|
||||||
|
'phone' => 'required|string',
|
||||||
|
'locations' => 'required|array|min:1',
|
||||||
|
'locations.*.label' => 'required|string',
|
||||||
|
'locations.*.addresses' => 'required|array|min:1',
|
||||||
|
'locations.*.addresses.*' => 'required|array:label,address,coordinates',
|
||||||
|
'locations.*.addresses.*.label' => 'required|string',
|
||||||
|
'locations.*.addresses.*.address' => 'required|string',
|
||||||
|
'locations.*.addresses.*.coordinates' => 'required|array|size:2',
|
||||||
|
'locations.*.addresses.*.coordinates.0' => 'required|numeric|between:-90,90',
|
||||||
|
'locations.*.addresses.*.coordinates.1' => 'required|numeric|between:-180,180',
|
||||||
|
'map_locations' => 'prohibited',
|
||||||
|
], JSON_THROW_ON_ERROR),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('phone_numbers', function (Blueprint $table): void {
|
||||||
|
$table->string('country_code', 8)->nullable()->default('+54');
|
||||||
|
$table->string('mobile_prefix', 4)->nullable()->default('9');
|
||||||
|
$table->string('area_code', 8)->nullable();
|
||||||
|
$table->string('local_number')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('phone_numbers')->orderBy('id')->chunkById(100, function ($numbers): void {
|
||||||
|
foreach ($numbers as $phone) {
|
||||||
|
DB::table('phone_numbers')->where('id', $phone->id)
|
||||||
|
->update($this->components($phone->number));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('phone_numbers', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('number');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('phone_numbers', function (Blueprint $table): void {
|
||||||
|
$table->string('number')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('phone_numbers')->orderBy('id')->chunkById(100, function ($numbers): void {
|
||||||
|
foreach ($numbers as $phone) {
|
||||||
|
$parts = array_filter([
|
||||||
|
$phone->country_code,
|
||||||
|
$phone->mobile_prefix,
|
||||||
|
$phone->area_code,
|
||||||
|
$phone->local_number,
|
||||||
|
], fn ($part): bool => $part !== null && $part !== '');
|
||||||
|
DB::table('phone_numbers')->where('id', $phone->id)
|
||||||
|
->update(['number' => implode(' ', $parts)]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('phone_numbers', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn(['country_code', 'mobile_prefix', 'area_code', 'local_number']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array{country_code: ?string, mobile_prefix: ?string, area_code: ?string, local_number: string} */
|
||||||
|
private function components(string $number): array
|
||||||
|
{
|
||||||
|
$number = trim($number);
|
||||||
|
|
||||||
|
if (preg_match('/^(\+?\d{1,3})\s+(?:(9)\s+)?\(?(\d{2,5})\)?[\s-]+([\d\s-]+)$/', $number, $match)) {
|
||||||
|
return [
|
||||||
|
'country_code' => '+'.ltrim($match[1], '+'),
|
||||||
|
'mobile_prefix' => ($match[2] ?? '') ?: null,
|
||||||
|
'area_code' => $match[3],
|
||||||
|
'local_number' => preg_replace('/\D/', '', $match[4]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\(?(0\d{2,5})\)?[\s-]+([\d\s-]+)$/', $number, $match)) {
|
||||||
|
return [
|
||||||
|
'country_code' => null,
|
||||||
|
'mobile_prefix' => null,
|
||||||
|
'area_code' => $match[1],
|
||||||
|
'local_number' => preg_replace('/\D/', '', $match[2]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preg_match('/^\+54(9?)(\d+)$/', $number, $match)) {
|
||||||
|
return [
|
||||||
|
'country_code' => '+54',
|
||||||
|
'mobile_prefix' => $match[1] === '9' ? '9' : null,
|
||||||
|
'area_code' => null,
|
||||||
|
'local_number' => $match[2],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'country_code' => null,
|
||||||
|
'mobile_prefix' => null,
|
||||||
|
'area_code' => null,
|
||||||
|
'local_number' => $number,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('categorias', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('banner_id')
|
||||||
|
->nullable()
|
||||||
|
->constrained('attachments')
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->boolean('banner_enabled')->default(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('categorias', function (Blueprint $table): void {
|
||||||
|
$table->dropConstrainedForeignId('banner_id');
|
||||||
|
$table->dropColumn('banner_enabled');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('event_id')
|
||||||
|
->nullable()
|
||||||
|
->after('tenant_codigo')
|
||||||
|
->constrained('events')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['event_id']);
|
||||||
|
$table->dropColumn('event_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::table('admin_website_types')
|
||||||
|
->where('codigo', 'shopit')
|
||||||
|
->update([
|
||||||
|
'secondary_color' => '#ADADAD',
|
||||||
|
'danger_color' => '#FF8888',
|
||||||
|
'success_color' => '#58B341',
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Presentation settings are operational data; preserve them on rollback.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::table('categorias', function (Blueprint $table): void {
|
||||||
|
$table->unsignedInteger('sort_order')->default(0);
|
||||||
|
$table->index(['tenant_code', 'categoria_id', 'sort_order']);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Preserve the alphabetical navigation order until an administrator reorders it.
|
||||||
|
$orders = [];
|
||||||
|
foreach (DB::table('categorias')->orderBy('nombre')->orderBy('id')->get() as $category) {
|
||||||
|
$group = json_encode([$category->tenant_code, $category->categoria_id]);
|
||||||
|
$order = $orders[$group] ?? 0;
|
||||||
|
DB::table('categorias')->where('id', $category->id)->update(['sort_order' => $order]);
|
||||||
|
$orders[$group] = $order + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('categorias', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['tenant_code', 'categoria_id', 'sort_order']);
|
||||||
|
$table->dropColumn('sort_order');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
DB::transaction(function (): void {
|
||||||
|
$menus = DB::table('menues')->where('code', 'like', 'onticket.%')->get();
|
||||||
|
|
||||||
|
// Create every destination before updating foreign keys, including parents.
|
||||||
|
foreach ($menus as $menu) {
|
||||||
|
$data = (array) $menu;
|
||||||
|
unset($data['id']);
|
||||||
|
$data['code'] = substr($menu->code, strlen('onticket.'));
|
||||||
|
|
||||||
|
if (! DB::table('menues')->where('code', $data['code'])->exists()) {
|
||||||
|
DB::table('menues')->insert($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($menus as $menu) {
|
||||||
|
$code = substr($menu->code, strlen('onticket.'));
|
||||||
|
|
||||||
|
DB::table('menues')->where('parent_menu_code', $menu->code)
|
||||||
|
->update(['parent_menu_code' => $code]);
|
||||||
|
|
||||||
|
$this->moveAssignments('tenants_menues', 'menu_code', 'tenant_code', $menu->code, $code);
|
||||||
|
$this->moveAssignments('roles_menues', 'menu_codigo', 'rol_codigo', $menu->code, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('menues')->whereIn('code', $menus->pluck('code'))->delete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private function moveAssignments(
|
||||||
|
string $table,
|
||||||
|
string $menuColumn,
|
||||||
|
string $ownerColumn,
|
||||||
|
string $sourceCode,
|
||||||
|
string $targetCode,
|
||||||
|
): void {
|
||||||
|
foreach (DB::table($table)->where($menuColumn, $sourceCode)->get() as $assignment) {
|
||||||
|
$data = (array) $assignment;
|
||||||
|
unset($data['id']);
|
||||||
|
$data[$menuColumn] = $targetCode;
|
||||||
|
|
||||||
|
DB::table($table)->updateOrInsert([
|
||||||
|
$ownerColumn => $data[$ownerColumn],
|
||||||
|
$menuColumn => $targetCode,
|
||||||
|
], $data);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table($table)->where($menuColumn, $sourceCode)->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Keep compatible identifiers: merged assignments cannot be split reliably.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -53,85 +53,85 @@ class MenuSeeder extends Seeder
|
|||||||
'route' => '/scanner/detail/:id',
|
'route' => '/scanner/detail/:id',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.event',
|
'code' => 'adminapp.event',
|
||||||
'label' => 'Eventos',
|
'label' => 'Eventos',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/event',
|
'route' => '/admin/event',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.inicio',
|
'code' => 'adminapp.inicio',
|
||||||
'label' => 'Inicio',
|
'label' => 'Inicio',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/inicio',
|
'route' => '/admin/inicio',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.catalog',
|
'code' => 'adminapp.catalog',
|
||||||
'label' => 'Catálogo',
|
'label' => 'Catálogo',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/catalog',
|
'route' => '/admin/catalog',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.combos',
|
'code' => 'adminapp.combos',
|
||||||
'label' => 'Combos',
|
'label' => 'Combos',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/combos',
|
'route' => '/admin/combos',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.categories',
|
'code' => 'adminapp.categories',
|
||||||
'label' => 'Categorías',
|
'label' => 'Categorías',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/categories',
|
'route' => '/admin/categories',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.ventas',
|
'code' => 'adminapp.ventas',
|
||||||
'label' => 'Ventas',
|
'label' => 'Ventas',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/ventas',
|
'route' => '/admin/ventas',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.staff',
|
'code' => 'adminapp.staff',
|
||||||
'label' => 'Usuarios',
|
'label' => 'Usuarios',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/staff',
|
'route' => '/admin/staff',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.tickets',
|
'code' => 'adminapp.tickets',
|
||||||
'label' => 'Tickets',
|
'label' => 'Tickets',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/tickets',
|
'route' => '/admin/tickets',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.entradas',
|
'code' => 'adminapp.fiesta-futbol-infantil.entradas',
|
||||||
'label' => 'Entradas',
|
'label' => 'Entradas',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/entradas',
|
'route' => '/admin/entradas',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.alojamientos',
|
'code' => 'adminapp.fiesta-futbol-infantil.alojamientos',
|
||||||
'label' => 'Alojamientos',
|
'label' => 'Alojamientos',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/alojamientos',
|
'route' => '/admin/alojamientos',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.merchandising',
|
'code' => 'adminapp.fiesta-futbol-infantil.merchandising',
|
||||||
'label' => 'Merchandising',
|
'label' => 'Merchandising',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/merchandising',
|
'route' => '/admin/merchandising',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.comida',
|
'code' => 'adminapp.fiesta-futbol-infantil.comida',
|
||||||
'label' => 'Comida',
|
'label' => 'Comida',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/comidas',
|
'route' => '/admin/comidas',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.desfile.entradas',
|
'code' => 'adminapp.desfile.entradas',
|
||||||
'label' => 'Entradas',
|
'label' => 'Entradas',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/desfile/entradas',
|
'route' => '/admin/desfile/entradas',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.desfile.reservas',
|
'code' => 'adminapp.desfile.reservas',
|
||||||
'label' => 'Reserva de Tickets',
|
'label' => 'Reserva de Tickets',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/desfile/reservas',
|
'route' => '/admin/desfile/reservas',
|
||||||
@@ -224,23 +224,8 @@ class MenuSeeder extends Seeder
|
|||||||
'code' => 'help.contact',
|
'code' => 'help.contact',
|
||||||
'label' => 'Contacto',
|
'label' => 'Contacto',
|
||||||
'parent_menu_code' => 'help',
|
'parent_menu_code' => 'help',
|
||||||
'content_type' => Menu::CONTENT_TYPE_STATIC,
|
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
|
||||||
'static_content_schema' => [
|
'static_content_schema' => null,
|
||||||
'whatsapp' => 'required|array',
|
|
||||||
'whatsapp.whatsapp_url' => 'required|url',
|
|
||||||
'whatsapp.whatsapp_label' => 'required|string',
|
|
||||||
'phone' => 'required|string',
|
|
||||||
'locations' => 'required|array|min:1',
|
|
||||||
'locations.*.label' => 'required|string',
|
|
||||||
'locations.*.addresses' => 'required|array|min:1',
|
|
||||||
'locations.*.addresses.*' => 'required|array:label,address,coordinates',
|
|
||||||
'locations.*.addresses.*.label' => 'required|string',
|
|
||||||
'locations.*.addresses.*.address' => 'required|string',
|
|
||||||
'locations.*.addresses.*.coordinates' => 'required|array|size:2',
|
|
||||||
'locations.*.addresses.*.coordinates.0' => 'required|numeric|between:-90,90',
|
|
||||||
'locations.*.addresses.*.coordinates.1' => 'required|numeric|between:-180,180',
|
|
||||||
'map_locations' => 'prohibited',
|
|
||||||
],
|
|
||||||
'route' => '/ayuda/contacto',
|
'route' => '/ayuda/contacto',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -301,7 +286,7 @@ class MenuSeeder extends Seeder
|
|||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
Menu::query()
|
Menu::query()
|
||||||
->where('code', 'like', 'adminapp.%')
|
->where('code', 'like', 'onticket.adminapp.%')
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
$allRoleMenuCodes = Menu::query()->pluck('code');
|
$allRoleMenuCodes = Menu::query()->pluck('code');
|
||||||
@@ -348,30 +333,30 @@ class MenuSeeder extends Seeder
|
|||||||
'mutual_smep',
|
'mutual_smep',
|
||||||
];
|
];
|
||||||
$ticketAdminMenuCodes = [
|
$ticketAdminMenuCodes = [
|
||||||
'onticket.adminapp.tickets',
|
'adminapp.tickets',
|
||||||
];
|
];
|
||||||
$fiestaCategoryMenuCodes = [
|
$fiestaCategoryMenuCodes = [
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.entradas',
|
'adminapp.fiesta-futbol-infantil.entradas',
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.alojamientos',
|
'adminapp.fiesta-futbol-infantil.alojamientos',
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.merchandising',
|
'adminapp.fiesta-futbol-infantil.merchandising',
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.comida',
|
'adminapp.fiesta-futbol-infantil.comida',
|
||||||
];
|
];
|
||||||
$fiestaExcludedAdminMenuCodes = [
|
$fiestaExcludedAdminMenuCodes = [
|
||||||
'onticket.adminapp.inicio',
|
'adminapp.inicio',
|
||||||
'onticket.adminapp.catalog',
|
'adminapp.catalog',
|
||||||
'onticket.adminapp.categories',
|
'adminapp.categories',
|
||||||
'onticket.adminapp.combos',
|
'adminapp.combos',
|
||||||
];
|
];
|
||||||
$desfileMenuCodes = [
|
$desfileMenuCodes = [
|
||||||
'onticket.adminapp.desfile.entradas',
|
'adminapp.desfile.entradas',
|
||||||
'onticket.adminapp.desfile.reservas',
|
'adminapp.desfile.reservas',
|
||||||
];
|
];
|
||||||
$shopItAdminMenuCodes = Menu::query()
|
$shopItAdminMenuCodes = Menu::query()
|
||||||
->where('code', 'like', 'shopit.adminapp.%')
|
->where('code', 'like', 'shopit.adminapp.%')
|
||||||
->pluck('code')
|
->pluck('code')
|
||||||
->all();
|
->all();
|
||||||
$onTicketAdminMenuCodes = Menu::query()
|
$onTicketAdminMenuCodes = Menu::query()
|
||||||
->where('code', 'like', 'onticket.adminapp.%')
|
->where('code', 'like', 'adminapp.%')
|
||||||
->pluck('code')
|
->pluck('code')
|
||||||
->all();
|
->all();
|
||||||
$onTicketMenuCodes = [
|
$onTicketMenuCodes = [
|
||||||
@@ -399,30 +384,6 @@ class MenuSeeder extends Seeder
|
|||||||
'is_active' => false,
|
'is_active' => false,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
$contactContent = [
|
|
||||||
'whatsapp' => [
|
|
||||||
'whatsapp_url' => 'https://wa.me/543412602222',
|
|
||||||
'whatsapp_label' => 'Chatea con nosotros',
|
|
||||||
],
|
|
||||||
'phone' => '+54 9 (0341) 6658247',
|
|
||||||
'locations' => [
|
|
||||||
'rosario' => [
|
|
||||||
'label' => 'Rosario',
|
|
||||||
'addresses' => [
|
|
||||||
[
|
|
||||||
'label' => 'Gigante de Arroyito',
|
|
||||||
'address' => 'Av. Génova 640, Rosario',
|
|
||||||
'coordinates' => [-32.913997, -60.674567],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'Telepagos',
|
|
||||||
'address' => 'Rioja 1150, piso 12, dpto. 3, Rosario',
|
|
||||||
'coordinates' => [-32.946820, -60.639320],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
$mutualSmepFrequentlyAskedQuestions = [
|
$mutualSmepFrequentlyAskedQuestions = [
|
||||||
[
|
[
|
||||||
'pregunta' => '¿Hay algún límite de compra?',
|
'pregunta' => '¿Hay algún límite de compra?',
|
||||||
@@ -440,25 +401,6 @@ class MenuSeeder extends Seeder
|
|||||||
'is_active' => false,
|
'is_active' => false,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
$mutualSmepContactContent = [
|
|
||||||
'whatsapp' => [
|
|
||||||
'whatsapp_url' => 'https://wa.me/5493412474530',
|
|
||||||
'whatsapp_label' => 'Chateá con Mutual SMEP',
|
|
||||||
],
|
|
||||||
'phone' => '+54 9 341 247-4530',
|
|
||||||
'locations' => [
|
|
||||||
'rosario' => [
|
|
||||||
'label' => 'Rosario',
|
|
||||||
'addresses' => [
|
|
||||||
[
|
|
||||||
'label' => 'Mutual SMEP',
|
|
||||||
'address' => 'San Lorenzo 1543, Rosario, Santa Fe',
|
|
||||||
'coordinates' => [-32.9431184, -60.6437991],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($tenants as $tenant) {
|
foreach ($tenants as $tenant) {
|
||||||
$menuCodes = $allMenus;
|
$menuCodes = $allMenus;
|
||||||
@@ -511,11 +453,6 @@ class MenuSeeder extends Seeder
|
|||||||
? $mutualSmepFrequentlyAskedQuestions
|
? $mutualSmepFrequentlyAskedQuestions
|
||||||
: $frequentlyAskedQuestions,
|
: $frequentlyAskedQuestions,
|
||||||
]);
|
]);
|
||||||
$tenant->menues()->updateExistingPivot('help.contact', [
|
|
||||||
'static_content' => $tenant->codigo === 'mutual_smep'
|
|
||||||
? $mutualSmepContactContent
|
|
||||||
: $contactContent,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,9 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
'site_title' => 'ShopIt',
|
'site_title' => 'ShopIt',
|
||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'primary_color' => '#2FD3AC',
|
'primary_color' => '#2FD3AC',
|
||||||
|
'secondary_color' => '#ADADAD',
|
||||||
|
'danger_color' => '#FF8888',
|
||||||
|
'success_color' => '#58B341',
|
||||||
'site_logo' => $this->shopItLogo(),
|
'site_logo' => $this->shopItLogo(),
|
||||||
'footer_logo' => $this->shopItFooterLogo(),
|
'footer_logo' => $this->shopItFooterLogo(),
|
||||||
'favicon' => $this->shopItFavicon(),
|
'favicon' => $this->shopItFavicon(),
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Auth;
|
namespace Tests\Feature\Auth;
|
||||||
|
|
||||||
|
use App\Domains\Core\Address\Models\Address;
|
||||||
use App\Domains\Core\Auth\Models\User;
|
use App\Domains\Core\Auth\Models\User;
|
||||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||||
use App\Domains\Core\Authorization\Models\Role;
|
use App\Domains\Core\Authorization\Models\Role;
|
||||||
use App\Domains\Core\Menu\Models\Menu;
|
use App\Domains\Core\Menu\Models\Menu;
|
||||||
|
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
|
||||||
|
use App\Domains\Core\Tenant\Models\SocialMedia;
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Laravel\Sanctum\Sanctum;
|
use Laravel\Sanctum\Sanctum;
|
||||||
@@ -46,6 +49,21 @@ class AdminAppMeControllerTest extends TestCase
|
|||||||
$tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]);
|
$tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]);
|
||||||
$otherTenant->menues()->sync([$staff->code]);
|
$otherTenant->menues()->sync([$staff->code]);
|
||||||
|
|
||||||
|
$instagram = SocialMedia::query()->create([
|
||||||
|
'code' => 'instagram', 'name' => 'Instagram', 'icon' => 'fa-brands fa-instagram',
|
||||||
|
]);
|
||||||
|
$tenant->socialMedia()->attach($instagram->code, ['url' => 'https://instagram.com/acme', 'orden' => 0]);
|
||||||
|
$address = Address::query()->create(['label' => 'Principal', 'address_text' => 'San Martín 123']);
|
||||||
|
$tenant->addresses()->attach($address->id, ['is_main' => true]);
|
||||||
|
$phone = PhoneNumber::query()->create([
|
||||||
|
'label' => 'Principal',
|
||||||
|
'country_code' => '+54',
|
||||||
|
'mobile_prefix' => '9',
|
||||||
|
'area_code' => '0341',
|
||||||
|
'local_number' => '6658247',
|
||||||
|
]);
|
||||||
|
$tenant->phoneNumbers()->attach($phone->id, ['is_main' => true]);
|
||||||
|
|
||||||
$user = User::factory()->create([
|
$user = User::factory()->create([
|
||||||
'nombre_apellido' => 'Admin Acme',
|
'nombre_apellido' => 'Admin Acme',
|
||||||
'email' => 'admin@acme.test',
|
'email' => 'admin@acme.test',
|
||||||
@@ -59,6 +77,11 @@ class AdminAppMeControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.user.id', $user->id)
|
->assertJsonPath('data.user.id', $user->id)
|
||||||
->assertJsonPath('data.user.email', 'admin@acme.test')
|
->assertJsonPath('data.user.email', 'admin@acme.test')
|
||||||
->assertJsonPath('data.tenant.codigo', 'acme')
|
->assertJsonPath('data.tenant.codigo', 'acme')
|
||||||
|
->assertJsonPath('data.tenant.social_media.0.url', 'https://instagram.com/acme')
|
||||||
|
->assertJsonPath('data.tenant.addresses.0.address_text', 'San Martín 123')
|
||||||
|
->assertJsonPath('data.tenant.phone_numbers.0.number', '+54 9 (0341) 6658247')
|
||||||
|
->assertJsonPath('data.tenant.phone_numbers.0.area_code', '0341')
|
||||||
|
->assertJsonPath('data.tenant.phone_numbers.0.local_number', '6658247')
|
||||||
->assertJsonCount(1, 'data.tenant.menues')
|
->assertJsonCount(1, 'data.tenant.menues')
|
||||||
->assertJsonPath('data.tenant.menues.0.code', 'admin.catalog')
|
->assertJsonPath('data.tenant.menues.0.code', 'admin.catalog')
|
||||||
->assertJsonPath('data.tenant.menues.0.label', 'Catálogo')
|
->assertJsonPath('data.tenant.menues.0.label', 'Catálogo')
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ class ScannerMeControllerTest extends TestCase
|
|||||||
'route' => '/scanner/scan',
|
'route' => '/scanner/scan',
|
||||||
]);
|
]);
|
||||||
$foreign = Menu::query()->create([
|
$foreign = Menu::query()->create([
|
||||||
'code' => 'onticket.adminapp.inicio',
|
'code' => 'adminapp.inicio',
|
||||||
'label' => 'Administración',
|
'label' => 'Administración',
|
||||||
'route' => '/admin/inicio',
|
'route' => '/admin/inicio',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class UserAuthorizationRelationsTest extends TestCase
|
|||||||
$this->assertTrue(Schema::hasColumns('users', [
|
$this->assertTrue(Schema::hasColumns('users', [
|
||||||
'rol_codigo',
|
'rol_codigo',
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
|
'event_id',
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,5 +29,7 @@ class UserAuthorizationRelationsTest extends TestCase
|
|||||||
$this->assertNull($user->tenant_codigo);
|
$this->assertNull($user->tenant_codigo);
|
||||||
$this->assertSame(RoleCode::User->value, $user->role->codigo);
|
$this->assertSame(RoleCode::User->value, $user->role->codigo);
|
||||||
$this->assertNull($user->tenant);
|
$this->assertNull($user->tenant);
|
||||||
|
$this->assertNull($user->event_id);
|
||||||
|
$this->assertNull($user->event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
271
tests/Feature/Catalog/CategoryConfigurationTest.php
Normal file
271
tests/Feature/Catalog/CategoryConfigurationTest.php
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Catalog;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
|
use App\Domains\Core\Auth\Models\User;
|
||||||
|
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Core\Bootstrap\Services\TenantBootstrapService;
|
||||||
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Core\Tenant\Resources\TenantResource;
|
||||||
|
use App\Domains\Core\Tenant\Services\TenantInformationService;
|
||||||
|
use App\Shared\Attachable\Models\Attachment;
|
||||||
|
use App\Shared\Attachable\Services\AttachmentService;
|
||||||
|
use App\Shared\Storage\Services\TemporaryUrlService;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Support\Facades\Storage;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CategoryConfigurationTest extends TestCase
|
||||||
|
{
|
||||||
|
private const URL = '/api/v1/adminapp/tenant/categories';
|
||||||
|
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Schema::create('tenants', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('codigo')->unique();
|
||||||
|
$table->string('dominio')->nullable();
|
||||||
|
$table->string('base_path')->default('/');
|
||||||
|
$table->string('storefront_website_type_code')->default('shopit');
|
||||||
|
});
|
||||||
|
Schema::create('attachments', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('key')->unique();
|
||||||
|
$table->string('path');
|
||||||
|
$table->string('filename');
|
||||||
|
$table->string('type');
|
||||||
|
$table->string('mime_type')->nullable();
|
||||||
|
$table->string('extension')->nullable();
|
||||||
|
$table->integer('size')->default(0);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
(require database_path('migrations/2026_06_24_000000_create_categorias_table.php'))->up();
|
||||||
|
(require database_path('migrations/2026_08_24_000000_add_is_enabled_to_categorias_table.php'))->up();
|
||||||
|
(require database_path('migrations/2026_10_01_000000_add_banner_to_categorias_table.php'))->up();
|
||||||
|
Schema::create('catalog_items', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('category_id')->nullable()->constrained('categorias')->restrictOnDelete();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
Schema::create('featured_groups', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('category_id')->nullable()->constrained('categorias')->restrictOnDelete();
|
||||||
|
});
|
||||||
|
DB::table('tenants')->insert([['codigo' => 'shop'], ['codigo' => 'other']]);
|
||||||
|
DB::table('categorias')->insert([
|
||||||
|
['tenant_code' => 'shop', 'nombre' => 'Zeta'],
|
||||||
|
['tenant_code' => 'shop', 'nombre' => 'Alfa'],
|
||||||
|
]);
|
||||||
|
(require database_path('migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php'))->up();
|
||||||
|
$this->tenant = Tenant::query()->where('codigo', 'shop')->firstOrFail();
|
||||||
|
$user = new User;
|
||||||
|
$user->forceFill(['id' => 1, 'tenant_codigo' => 'shop', 'rol_codigo' => RoleCode::AdminApp->value]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
$this->mock(TemporaryUrlService::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('generate')->andReturnUsing(fn ($path) => [
|
||||||
|
'temporary_url' => 'https://example.com/'.$path,
|
||||||
|
'temporary_url_expires_at' => now()->addDay()->toIso8601String(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_get_returns_sorted_tree_including_hidden_categories_and_disabled_banners(): void
|
||||||
|
{
|
||||||
|
$parent = Category::where('nombre', 'Alfa')->firstOrFail();
|
||||||
|
$banner = $this->banner();
|
||||||
|
$parent->update(['is_enabled' => false, 'banner_id' => $banner->id]);
|
||||||
|
$child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']);
|
||||||
|
Category::create(['tenant_code' => 'other', 'nombre' => 'Ajena']);
|
||||||
|
|
||||||
|
$response = $this->getJson(self::URL)->assertOk()
|
||||||
|
->assertJsonCount(2, 'data')
|
||||||
|
->assertJsonPath('data.0.nombre', 'Alfa')
|
||||||
|
->assertJsonPath('data.0.is_enabled', false)
|
||||||
|
->assertJsonPath('data.0.banner_enabled', false)
|
||||||
|
->assertJsonPath('data.0.banner_key', $banner->key)
|
||||||
|
->assertJsonPath('data.0.banner_url', 'https://example.com/banner.jpg')
|
||||||
|
->assertJsonPath('data.0.subcategories.0.id', $child->id);
|
||||||
|
$this->assertArrayNotHasKey('sort_order', $response->json('data.0'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_put_creates_edits_reorders_and_removes_only_explicitly_deleted_nodes(): void
|
||||||
|
{
|
||||||
|
$alfa = Category::where('nombre', 'Alfa')->firstOrFail();
|
||||||
|
$zeta = Category::where('nombre', 'Zeta')->firstOrFail();
|
||||||
|
$child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $alfa->id, 'nombre' => 'Eliminar']);
|
||||||
|
$this->putJson(self::URL, [
|
||||||
|
'categories' => [
|
||||||
|
$this->node($zeta, ['nombre' => 'Primera', 'is_enabled' => false]),
|
||||||
|
$this->node($alfa, ['subcategories' => [$this->node(null, ['nombre' => 'Nueva hija'])]]),
|
||||||
|
$this->node(null, ['nombre' => 'Nueva raíz']),
|
||||||
|
],
|
||||||
|
'deleted_ids' => [$child->id],
|
||||||
|
])->assertOk()->assertJsonPath('data.0.nombre', 'Primera')
|
||||||
|
->assertJsonPath('data.1.subcategories.0.nombre', 'Nueva hija')
|
||||||
|
->assertJsonPath('data.2.nombre', 'Nueva raíz');
|
||||||
|
$this->assertSame(0, $zeta->fresh()->sort_order);
|
||||||
|
$this->assertSame(1, $alfa->fresh()->sort_order);
|
||||||
|
$this->assertDatabaseMissing('categorias', ['id' => $child->id]);
|
||||||
|
|
||||||
|
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => []])->assertOk();
|
||||||
|
$this->assertDatabaseCount('categorias', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_preserves_existing_banners_and_supports_replacement_and_removal(): void
|
||||||
|
{
|
||||||
|
$category = Category::where('nombre', 'Alfa')->firstOrFail();
|
||||||
|
$banner = $this->banner();
|
||||||
|
$category->update(['banner_id' => $banner->id]);
|
||||||
|
$this->putJson(self::URL, ['categories' => [$this->node($category, ['banner' => $banner->key, 'banner_enabled' => true])], 'deleted_ids' => []])
|
||||||
|
->assertOk()->assertJsonPath('data.0.banner_key', $banner->key);
|
||||||
|
|
||||||
|
$replacement = $this->banner('replacement.jpg');
|
||||||
|
$this->mock(AttachmentService::class, function ($mock) use ($replacement): void {
|
||||||
|
$mock->shouldReceive('store')->once()->withArgs(fn ($image, $path) => str_starts_with($image, 'data:image/png;base64,') && $path === 'tenants/shop/categories')->andReturn($replacement);
|
||||||
|
});
|
||||||
|
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2ioAAAAASUVORK5CYII=';
|
||||||
|
$this->putJson(self::URL, ['categories' => [$this->node($category, ['banner' => $image])], 'deleted_ids' => []])
|
||||||
|
->assertOk()->assertJsonPath('data.0.banner_key', $replacement->key);
|
||||||
|
$this->putJson(self::URL, ['categories' => [$this->node($category)], 'deleted_ids' => []])
|
||||||
|
->assertOk()->assertJsonPath('data.0.banner_key', null);
|
||||||
|
$this->assertDatabaseHas('attachments', ['id' => $banner->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_foreign_ids_duplicate_ids_and_foreign_banners_atomically(): void
|
||||||
|
{
|
||||||
|
$local = Category::where('nombre', 'Alfa')->firstOrFail();
|
||||||
|
$foreign = Category::create(['tenant_code' => 'other', 'nombre' => 'Ajena']);
|
||||||
|
foreach ([
|
||||||
|
[$this->node($local, ['nombre' => 'Cambio']), $this->node($foreign)],
|
||||||
|
[$this->node($local), $this->node($local)],
|
||||||
|
[$this->node($local, ['banner' => $this->banner()->key])],
|
||||||
|
] as $nodes) {
|
||||||
|
$this->putJson(self::URL, ['categories' => $nodes, 'deleted_ids' => []])->assertUnprocessable();
|
||||||
|
$this->assertSame('Alfa', $local->fresh()->nombre);
|
||||||
|
}
|
||||||
|
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$foreign->id]])->assertUnprocessable();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_deleting_categories_with_products_or_featured_groups(): void
|
||||||
|
{
|
||||||
|
$category = Category::where('nombre', 'Alfa')->firstOrFail();
|
||||||
|
foreach (['catalog_items', 'featured_groups'] as $table) {
|
||||||
|
DB::table($table)->insert(['category_id' => $category->id]);
|
||||||
|
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$category->id]])
|
||||||
|
->assertUnprocessable()->assertJsonValidationErrors('deleted_ids');
|
||||||
|
$this->assertDatabaseHas('categorias', ['id' => $category->id]);
|
||||||
|
DB::table($table)->delete();
|
||||||
|
}
|
||||||
|
DB::table('catalog_items')->insert(['category_id' => $category->id, 'deleted_at' => now()]);
|
||||||
|
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$category->id]])
|
||||||
|
->assertUnprocessable()->assertJsonValidationErrors('deleted_ids');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_stores_new_banner_uploads_and_cleans_up_files_when_a_save_rolls_back(): void
|
||||||
|
{
|
||||||
|
Storage::fake('s3');
|
||||||
|
$image = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+j2ioAAAAASUVORK5CYII=';
|
||||||
|
$parent = Category::where('nombre', 'Alfa')->firstOrFail();
|
||||||
|
$this->putJson(self::URL, ['categories' => [$this->node($parent, ['banner' => $image])], 'deleted_ids' => []])->assertOk();
|
||||||
|
$banner = $parent->fresh()->banner;
|
||||||
|
Storage::disk('s3')->assertExists($banner->path);
|
||||||
|
Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']);
|
||||||
|
$this->putJson(self::URL, ['categories' => [$this->node(null, ['banner' => $image])], 'deleted_ids' => [$parent->id]])->assertUnprocessable();
|
||||||
|
$this->assertDatabaseCount('attachments', 1);
|
||||||
|
$this->assertCount(1, Storage::disk('s3')->allFiles());
|
||||||
|
$this->assertDatabaseHas('categorias', ['id' => $parent->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_rejects_orphaning_children_and_cycles_but_can_delete_a_whole_tree(): void
|
||||||
|
{
|
||||||
|
$parent = Category::where('nombre', 'Alfa')->firstOrFail();
|
||||||
|
$child = Category::create(['tenant_code' => 'shop', 'categoria_id' => $parent->id, 'nombre' => 'Hija']);
|
||||||
|
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$parent->id]])->assertUnprocessable();
|
||||||
|
$this->putJson(self::URL, ['categories' => [$this->node($child, ['subcategories' => [$this->node($parent)]])], 'deleted_ids' => []])->assertOk();
|
||||||
|
$this->assertSame($child->id, $parent->fresh()->categoria_id);
|
||||||
|
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => [$parent->id, $child->id]])->assertOk();
|
||||||
|
$this->assertDatabaseMissing('categorias', ['id' => $parent->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_validates_nested_names_images_and_required_fields(): void
|
||||||
|
{
|
||||||
|
foreach ([['nombre' => ' '], ['banner' => 'not-an-image'], ['is_enabled' => 'invalid']] as $patch) {
|
||||||
|
$this->putJson(self::URL, [
|
||||||
|
'categories' => [$this->node(null, ['subcategories' => [$this->node(null, $patch)]])],
|
||||||
|
'deleted_ids' => [],
|
||||||
|
])->assertUnprocessable();
|
||||||
|
}
|
||||||
|
$this->putJson(self::URL, ['categories' => []])->assertUnprocessable()->assertJsonValidationErrors('deleted_ids');
|
||||||
|
$this->assertDatabaseCount('categorias', 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_non_adminapp_users_cannot_change_categories(): void
|
||||||
|
{
|
||||||
|
$user = new User;
|
||||||
|
$user->forceFill(['id' => 2, 'tenant_codigo' => 'shop', 'rol_codigo' => RoleCode::User->value]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
$this->getJson(self::URL)->assertForbidden();
|
||||||
|
$this->putJson(self::URL, ['categories' => [], 'deleted_ids' => []])->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_sort_order_migration_can_be_rolled_back(): void
|
||||||
|
{
|
||||||
|
(require database_path('migrations/2026_10_01_000200_add_sort_order_to_categorias_table.php'))->down();
|
||||||
|
$this->assertFalse(Schema::hasColumn('categorias', 'sort_order'));
|
||||||
|
$this->assertDatabaseCount('categorias', 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_public_bootstrap_uses_saved_order_without_exposing_it_and_hides_disabled_branches(): void
|
||||||
|
{
|
||||||
|
DB::table('tenants')->where('codigo', 'shop')->update(['dominio' => 'shop.test']);
|
||||||
|
$alfa = Category::where('nombre', 'Alfa')->firstOrFail();
|
||||||
|
$zeta = Category::where('nombre', 'Zeta')->firstOrFail();
|
||||||
|
$alfa->update(['is_enabled' => false]);
|
||||||
|
$first = Category::create(['tenant_code' => 'shop', 'nombre' => 'Z hija', 'categoria_id' => $zeta->id, 'sort_order' => 0]);
|
||||||
|
Category::create(['tenant_code' => 'shop', 'nombre' => 'A hija', 'categoria_id' => $zeta->id, 'sort_order' => 1]);
|
||||||
|
Category::create(['tenant_code' => 'shop', 'nombre' => 'Oculta', 'categoria_id' => $zeta->id, 'is_enabled' => false]);
|
||||||
|
Category::create(['tenant_code' => 'shop', 'nombre' => 'Hija de padre oculto', 'categoria_id' => $alfa->id]);
|
||||||
|
$this->mock(TenantInformationService::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('load')->once()->andReturnUsing(function ($tenant, $relations) {
|
||||||
|
return $tenant->load(['categories' => $relations['categories']]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
$loaded = app(TenantBootstrapService::class)->get('shop.test');
|
||||||
|
$this->assertSame($alfa->id, $loaded->categories->first()->id);
|
||||||
|
$resourceTenant = new Tenant(['codigo' => 'shop', 'storefront_website_type_code' => 'shopit']);
|
||||||
|
foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) {
|
||||||
|
$resourceTenant->setRelation($relation, null);
|
||||||
|
}
|
||||||
|
$resourceTenant->setRelation('categories', $loaded->categories);
|
||||||
|
$data = TenantResource::make($resourceTenant)->resolve(request());
|
||||||
|
$this->assertCount(1, $data['categories']);
|
||||||
|
$this->assertSame('Zeta', $data['categories'][0]['nombre']);
|
||||||
|
$this->assertSame(['Z hija', 'A hija'], $data['categories'][0]['subcategories']->pluck('nombre')->all());
|
||||||
|
$this->assertSame($first->id, $data['categories'][0]['subcategories'][0]['id']);
|
||||||
|
$this->assertArrayNotHasKey('sort_order', $data['categories'][0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function node(?Category $category, array $patch = []): array
|
||||||
|
{
|
||||||
|
return array_replace([
|
||||||
|
'id' => $category?->id,
|
||||||
|
'nombre' => $category?->nombre ?? 'Nueva',
|
||||||
|
'is_enabled' => true,
|
||||||
|
'banner_enabled' => false,
|
||||||
|
'banner' => null,
|
||||||
|
'subcategories' => [],
|
||||||
|
], $patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function banner(string $path = 'banner.jpg'): Attachment
|
||||||
|
{
|
||||||
|
return Attachment::create(['path' => $path, 'filename' => $path, 'type' => 'image']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
|||||||
use App\Domains\Commerce\Catalog\Models\Category;
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
use App\Domains\Commerce\Catalog\Models\Inventory;
|
use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Shared\Attachable\Models\Attachment;
|
||||||
|
use App\Shared\Storage\Services\TemporaryUrlService;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
@@ -72,6 +74,39 @@ class CategoryDetailTest extends TestCase
|
|||||||
->assertJsonCount(0, 'data');
|
->assertJsonCount(0, 'data');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_returns_the_banner_url_only_when_a_banner_exists_and_is_enabled(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('category-banner');
|
||||||
|
$category = $this->createCategory($tenant, 'Remeras');
|
||||||
|
$banner = Attachment::query()->create([
|
||||||
|
'path' => 'categories/banner.jpg',
|
||||||
|
'filename' => 'banner.jpg',
|
||||||
|
'type' => 'image',
|
||||||
|
'mime_type' => 'image/jpeg',
|
||||||
|
'extension' => 'jpg',
|
||||||
|
'size' => 100,
|
||||||
|
]);
|
||||||
|
$this->mock(TemporaryUrlService::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('generate')->once()
|
||||||
|
->with('categories/banner.jpg', 1440)
|
||||||
|
->andReturn([
|
||||||
|
'temporary_url' => 'https://example.com/banner.jpg',
|
||||||
|
'temporary_url_expires_at' => now()->addDay()->toIso8601String(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
$url = "/api/tenants/{$tenant->codigo}/categories/{$category->id}";
|
||||||
|
|
||||||
|
$category->update(['banner_enabled' => true]);
|
||||||
|
$this->getJson($url)->assertOk()->assertJsonPath('category.banner_url', null);
|
||||||
|
|
||||||
|
$category->update(['banner_id' => $banner->id, 'banner_enabled' => false]);
|
||||||
|
$this->getJson($url)->assertOk()->assertJsonPath('category.banner_url', null);
|
||||||
|
|
||||||
|
$category->update(['banner_enabled' => true]);
|
||||||
|
$this->getJson($url)->assertOk()
|
||||||
|
->assertJsonPath('category.banner_url', 'https://example.com/banner.jpg');
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_includes_products_from_all_descendant_categories(): void
|
public function test_it_includes_products_from_all_descendant_categories(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('category-descendants');
|
$tenant = $this->createTenant('category-descendants');
|
||||||
|
|||||||
@@ -186,7 +186,7 @@ class EntryControllerTest extends TestCase
|
|||||||
'admin_website_type_code' => 'onticket',
|
'admin_website_type_code' => 'onticket',
|
||||||
]);
|
]);
|
||||||
$menu = Menu::query()->firstOrCreate(
|
$menu = Menu::query()->firstOrCreate(
|
||||||
['code' => 'onticket.adminapp.desfile.entradas'],
|
['code' => 'adminapp.desfile.entradas'],
|
||||||
['label' => 'Entradas', 'route' => '/admin/desfile/entradas'],
|
['label' => 'Entradas', 'route' => '/admin/desfile/entradas'],
|
||||||
);
|
);
|
||||||
$tenant->menues()->attach($menu->code);
|
$tenant->menues()->attach($menu->code);
|
||||||
|
|||||||
@@ -236,7 +236,7 @@ class AccommodationControllerTest extends TestCase
|
|||||||
|
|
||||||
if ($withMenu) {
|
if ($withMenu) {
|
||||||
$menu = Menu::query()->firstOrCreate(
|
$menu = Menu::query()->firstOrCreate(
|
||||||
['code' => 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'],
|
['code' => 'adminapp.fiesta-futbol-infantil.alojamientos'],
|
||||||
['label' => 'Alojamientos', 'route' => '/admin/alojamientos'],
|
['label' => 'Alojamientos', 'route' => '/admin/alojamientos'],
|
||||||
);
|
);
|
||||||
$tenant->menues()->syncWithoutDetaching([$menu->code]);
|
$tenant->menues()->syncWithoutDetaching([$menu->code]);
|
||||||
|
|||||||
@@ -43,10 +43,10 @@ class EntryControllerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$tenant = $this->createFiestaTenant();
|
$tenant = $this->createFiestaTenant();
|
||||||
$categories = [
|
$categories = [
|
||||||
'entries' => ['Entradas', 'onticket.adminapp.fiesta-futbol-infantil.entradas'],
|
'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'],
|
||||||
'foods' => ['Comidas', 'onticket.adminapp.fiesta-futbol-infantil.comida'],
|
'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'],
|
||||||
'accommodations' => ['Alojamientos', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'],
|
'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'],
|
||||||
'merchandise' => ['Merchandising', 'onticket.adminapp.fiesta-futbol-infantil.merchandising'],
|
'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'],
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($categories as [$label, $menuCode]) {
|
foreach ($categories as [$label, $menuCode]) {
|
||||||
@@ -405,7 +405,7 @@ class EntryControllerTest extends TestCase
|
|||||||
{
|
{
|
||||||
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
$tenant = $this->createTenant('fiesta_futbol_infantil');
|
||||||
$menu = Menu::query()->create([
|
$menu = Menu::query()->create([
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.entradas',
|
'code' => 'adminapp.fiesta-futbol-infantil.entradas',
|
||||||
'label' => 'Entradas',
|
'label' => 'Entradas',
|
||||||
'route' => '/admin/entradas',
|
'route' => '/admin/entradas',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -367,7 +367,7 @@ class FoodControllerTest extends TestCase
|
|||||||
'time_end' => '23:59',
|
'time_end' => '23:59',
|
||||||
]);
|
]);
|
||||||
$menu = Menu::query()->create([
|
$menu = Menu::query()->create([
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.comida',
|
'code' => 'adminapp.fiesta-futbol-infantil.comida',
|
||||||
'label' => 'Comida',
|
'label' => 'Comida',
|
||||||
'route' => '/admin/comidas',
|
'route' => '/admin/comidas',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -293,7 +293,7 @@ class MerchandiseControllerTest extends TestCase
|
|||||||
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
|
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
|
||||||
]);
|
]);
|
||||||
$menu = Menu::query()->create([
|
$menu = Menu::query()->create([
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.merchandising',
|
'code' => 'adminapp.fiesta-futbol-infantil.merchandising',
|
||||||
'label' => 'Merchandising',
|
'label' => 'Merchandising',
|
||||||
'route' => '/admin/merchandising',
|
'route' => '/admin/merchandising',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ class AdminAppDesfileEntryReservationFormControllerTest extends TestCase
|
|||||||
private function grantReservationsMenu(Tenant $tenant): void
|
private function grantReservationsMenu(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
$menu = Menu::query()->create([
|
$menu = Menu::query()->create([
|
||||||
'code' => 'onticket.adminapp.desfile.reservas',
|
'code' => 'adminapp.desfile.reservas',
|
||||||
'label' => 'Reserva de Tickets',
|
'label' => 'Reserva de Tickets',
|
||||||
'route' => '/admin/desfile/reservas',
|
'route' => '/admin/desfile/reservas',
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
|
|||||||
private function grantTicketsMenu(Tenant $tenant): void
|
private function grantTicketsMenu(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
$menu = Menu::query()->create([
|
$menu = Menu::query()->create([
|
||||||
'code' => 'onticket.adminapp.tickets',
|
'code' => 'adminapp.tickets',
|
||||||
'label' => 'Tickets',
|
'label' => 'Tickets',
|
||||||
'route' => '/admin/tickets',
|
'route' => '/admin/tickets',
|
||||||
]);
|
]);
|
||||||
|
|||||||
77
tests/Feature/Menu/TicketMenuAccessTest.php
Normal file
77
tests/Feature/Menu/TicketMenuAccessTest.php
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Menu;
|
||||||
|
|
||||||
|
use App\Domains\Core\Auth\Models\User;
|
||||||
|
use App\Http\Middleware\EnsureTenantHasMenu;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use PHPUnit\Framework\Attributes\DataProvider;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\HttpException;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TicketMenuAccessTest extends TestCase
|
||||||
|
{
|
||||||
|
public static function menuAssignments(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'compatible code' => ['adminapp.tickets', 'current', true],
|
||||||
|
'prefixed code' => ['onticket.adminapp.tickets', 'current', false],
|
||||||
|
'another tenant' => ['adminapp.tickets', 'other', false],
|
||||||
|
'unrelated menu' => ['adminapp.ventas', 'current', false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
#[DataProvider('menuAssignments')]
|
||||||
|
public function test_ticket_menu_requires_an_association_with_the_authenticated_tenant(string $menuCode, string $assignedTenant, bool $allowed): void
|
||||||
|
{
|
||||||
|
Schema::create('tenants', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('codigo');
|
||||||
|
});
|
||||||
|
Schema::create('menues', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('code');
|
||||||
|
});
|
||||||
|
Schema::create('tenants_menues', function (Blueprint $table): void {
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->string('menu_code');
|
||||||
|
$table->boolean('is_enabled')->default(true);
|
||||||
|
});
|
||||||
|
DB::table('tenants')->insert(['codigo' => 'current']);
|
||||||
|
DB::table('menues')->insert(['code' => $menuCode]);
|
||||||
|
DB::table('tenants_menues')->insert(['tenant_code' => $assignedTenant, 'menu_code' => $menuCode]);
|
||||||
|
|
||||||
|
$user = new User(['tenant_codigo' => 'current']);
|
||||||
|
$request = Request::create('/api/v1/adminapp/tenant/tickets');
|
||||||
|
$request->setUserResolver(fn () => $user);
|
||||||
|
if (! $allowed) {
|
||||||
|
$this->expectException(HttpException::class);
|
||||||
|
$this->expectExceptionCode(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = (new EnsureTenantHasMenu)->handle($request, fn () => response('allowed'), 'adminapp.tickets');
|
||||||
|
$this->assertSame('allowed', $response->getContent());
|
||||||
|
} catch (HttpException $exception) {
|
||||||
|
$this->assertSame(404, $exception->getStatusCode());
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_all_ticket_routes_and_filter_form_use_the_updated_menu_codes(): void
|
||||||
|
{
|
||||||
|
foreach ([
|
||||||
|
'adminapp.tickets.index', 'adminapp.tickets.cancel',
|
||||||
|
'adminapp.tickets.calculate-refund', 'adminapp.tickets.refund',
|
||||||
|
'adminapp.tickets.pdf', 'adminapp.tickets.excel', 'adminapp.forms.tickets-filter',
|
||||||
|
] as $name) {
|
||||||
|
$route = Route::getRoutes()->getByName($name);
|
||||||
|
$this->assertNotNull($route);
|
||||||
|
$this->assertContains('tenant.menu:adminapp.tickets', $route->gatherMiddleware());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
tests/Feature/Migrations/AddBannerToCategoriesTest.php
Normal file
75
tests/Feature/Migrations/AddBannerToCategoriesTest.php
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AddBannerToCategoriesTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Schema::create('attachments', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
});
|
||||||
|
Schema::create('categorias', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->boolean('is_enabled')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
DB::table('categorias')->insert(['nombre' => 'Existente']);
|
||||||
|
|
||||||
|
(require database_path('migrations/2026_10_01_000000_add_banner_to_categorias_table.php'))->up();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_existing_categories_default_to_no_banner_and_disabled(): void
|
||||||
|
{
|
||||||
|
$category = Category::query()->firstOrFail();
|
||||||
|
|
||||||
|
$this->assertNull($category->banner_id);
|
||||||
|
$this->assertFalse($category->banner_enabled);
|
||||||
|
$this->assertNull($category->banner);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_banner_can_be_assigned_and_deleting_it_preserves_the_category(): void
|
||||||
|
{
|
||||||
|
$bannerId = DB::table('attachments')->insertGetId([]);
|
||||||
|
$category = Category::query()->create([
|
||||||
|
'nombre' => 'Con banner',
|
||||||
|
'banner_id' => $bannerId,
|
||||||
|
'banner_enabled' => true,
|
||||||
|
])->fresh();
|
||||||
|
|
||||||
|
$this->assertSame($bannerId, $category->banner_id);
|
||||||
|
$this->assertTrue($category->banner_enabled);
|
||||||
|
$this->assertSame($bannerId, $category->banner->id);
|
||||||
|
|
||||||
|
DB::table('attachments')->where('id', $bannerId)->delete();
|
||||||
|
|
||||||
|
$this->assertNull($category->fresh()->banner_id);
|
||||||
|
$this->assertDatabaseHas('categorias', ['id' => $category->id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_banner_must_reference_an_existing_attachment(): void
|
||||||
|
{
|
||||||
|
$this->expectException(QueryException::class);
|
||||||
|
|
||||||
|
Category::query()->create(['nombre' => 'Inválida', 'banner_id' => 999]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_migration_can_be_rolled_back_without_removing_categories(): void
|
||||||
|
{
|
||||||
|
(require database_path('migrations/2026_10_01_000000_add_banner_to_categorias_table.php'))->down();
|
||||||
|
|
||||||
|
$this->assertFalse(Schema::hasColumn('categorias', 'banner_id'));
|
||||||
|
$this->assertFalse(Schema::hasColumn('categorias', 'banner_enabled'));
|
||||||
|
$this->assertDatabaseHas('categorias', ['nombre' => 'Existente']);
|
||||||
|
}
|
||||||
|
}
|
||||||
57
tests/Feature/Migrations/AddEventIdToUsersTest.php
Normal file
57
tests/Feature/Migrations/AddEventIdToUsersTest.php
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use App\Domains\Core\Administrator\Resources\AdministratorResource;
|
||||||
|
use App\Domains\Core\Auth\Models\User;
|
||||||
|
use App\Domains\Core\Auth\Resources\UserResource;
|
||||||
|
use Illuminate\Database\QueryException;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AddEventIdToUsersTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_event_assignment_preserves_existing_users_and_restricts_event_deletion(): void
|
||||||
|
{
|
||||||
|
Schema::create('events', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
});
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo')->nullable();
|
||||||
|
$table->softDeletes();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
DB::table('users')->insert(['id' => 1, 'tenant_codigo' => 'legacy']);
|
||||||
|
DB::table('events')->insert(['id' => 42]);
|
||||||
|
|
||||||
|
$migration = require database_path('migrations/2026_10_01_000000_add_event_id_to_users_table.php');
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$legacy = User::query()->findOrFail(1);
|
||||||
|
$this->assertNull($legacy->event_id);
|
||||||
|
$this->assertNull($legacy->event);
|
||||||
|
$this->assertSame('legacy', $legacy->tenant_codigo);
|
||||||
|
|
||||||
|
$legacy->update(['event_id' => '42']);
|
||||||
|
$user = $legacy->fresh();
|
||||||
|
$this->assertSame(42, $user->event_id);
|
||||||
|
$this->assertSame(42, $user->event->id);
|
||||||
|
$this->assertSame(42, UserResource::make($user)->resolve(new Request)['event_id']);
|
||||||
|
$this->assertSame(42, AdministratorResource::make($user)->resolve(new Request)['event_id']);
|
||||||
|
|
||||||
|
try {
|
||||||
|
DB::table('events')->where('id', 42)->delete();
|
||||||
|
$this->fail('An assigned event must not be deleted.');
|
||||||
|
} catch (QueryException $exception) {
|
||||||
|
$this->assertStringContainsString('FOREIGN KEY', $exception->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
$migration->down();
|
||||||
|
$this->assertFalse(Schema::hasColumn('users', 'event_id'));
|
||||||
|
$this->assertSame('legacy', DB::table('users')->where('id', 1)->value('tenant_codigo'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Tests\Feature\Migrations;
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
|
||||||
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
|
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
use Illuminate\Database\QueryException;
|
use Illuminate\Database\QueryException;
|
||||||
@@ -96,6 +97,49 @@ class ContactPhoneNumbersTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_splits_mobile_and_landline_numbers_and_builds_dialable_urls(): void
|
||||||
|
{
|
||||||
|
DB::table('phone_numbers')->insert([
|
||||||
|
['label' => 'Móvil', 'number' => '+54 9 (0341) 6658247'],
|
||||||
|
['label' => 'Fijo', 'number' => '+54 341 111-1111'],
|
||||||
|
['label' => 'Local', 'number' => '0341 1234567'],
|
||||||
|
['label' => 'Móvil sin signo', 'number' => '54 9 (0341) 6658247'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$migration = require database_path('migrations/2026_09_30_000700_split_phone_number_components.php');
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
[$mobile, $landline, $local, $mobileWithoutPlus] = PhoneNumber::query()->orderBy('id')->get()->all();
|
||||||
|
$this->assertSame('+54', $mobile->country_code);
|
||||||
|
$this->assertSame('9', $mobile->mobile_prefix);
|
||||||
|
$this->assertSame('0341', $mobile->area_code);
|
||||||
|
$this->assertSame('6658247', $mobile->local_number);
|
||||||
|
$this->assertSame('+54 9 (0341) 6658247', $mobile->number);
|
||||||
|
$this->assertSame('tel:+5493416658247', $mobile->telUrl());
|
||||||
|
$this->assertSame('+54 341 1111111', $landline->number);
|
||||||
|
$this->assertSame('tel:+543411111111', $landline->telUrl());
|
||||||
|
$this->assertSame('0341 1234567', $local->number);
|
||||||
|
$this->assertSame('tel:03411234567', $local->telUrl());
|
||||||
|
$this->assertSame('+54 9 (0341) 6658247', $mobileWithoutPlus->number);
|
||||||
|
$resource = PhoneNumberResource::make($mobile)->resolve();
|
||||||
|
$this->assertSame('9', $resource['mobile_prefix']);
|
||||||
|
$this->assertSame('0341', $resource['area_code']);
|
||||||
|
$this->assertSame('tel:+5493416658247', $resource['tel_url']);
|
||||||
|
|
||||||
|
$created = PhoneNumber::query()->create([
|
||||||
|
'label' => 'Nuevo',
|
||||||
|
'area_code' => '0341',
|
||||||
|
'local_number' => '7777777',
|
||||||
|
]);
|
||||||
|
$this->assertSame('+54', $created->fresh()->country_code);
|
||||||
|
$this->assertSame('9', $created->fresh()->mobile_prefix);
|
||||||
|
$this->assertSame('+54 9 (0341) 7777777', $created->number);
|
||||||
|
$this->assertSame('tel:+5493417777777', $created->telUrl());
|
||||||
|
|
||||||
|
$migration->down();
|
||||||
|
$this->assertDatabaseHas('phone_numbers', ['id' => $mobile->id, 'number' => '+54 9 0341 6658247']);
|
||||||
|
}
|
||||||
|
|
||||||
private function contact(string $tenant, mixed $phone): void
|
private function contact(string $tenant, mixed $phone): void
|
||||||
{
|
{
|
||||||
DB::table('tenants_menues')->insert([
|
DB::table('tenants_menues')->insert([
|
||||||
|
|||||||
48
tests/Feature/Migrations/DynamicContactMenuTest.php
Normal file
48
tests/Feature/Migrations/DynamicContactMenuTest.php
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class DynamicContactMenuTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_converts_only_contact_and_preserves_legacy_data_for_rollback(): void
|
||||||
|
{
|
||||||
|
Schema::create('menues', function (Blueprint $table): void {
|
||||||
|
$table->string('code')->primary();
|
||||||
|
$table->string('content_type');
|
||||||
|
$table->json('static_content_schema')->nullable();
|
||||||
|
});
|
||||||
|
Schema::create('tenants_menues', function (Blueprint $table): void {
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->string('menu_code');
|
||||||
|
$table->json('static_content')->nullable();
|
||||||
|
});
|
||||||
|
DB::table('menues')->insert([
|
||||||
|
['code' => 'help.contact', 'content_type' => 'static', 'static_content_schema' => '{}'],
|
||||||
|
['code' => 'help.faq', 'content_type' => 'static', 'static_content_schema' => '{}'],
|
||||||
|
]);
|
||||||
|
$legacy = json_encode(['whatsapp' => ['whatsapp_url' => 'https://wa.me/123']], JSON_THROW_ON_ERROR);
|
||||||
|
DB::table('tenants_menues')->insert([
|
||||||
|
'tenant_code' => 'test', 'menu_code' => 'help.contact', 'static_content' => $legacy,
|
||||||
|
]);
|
||||||
|
$migration = require database_path('migrations/2026_09_30_000600_make_contact_menu_dynamic.php');
|
||||||
|
$migration->up();
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$this->assertDatabaseHas('menues', [
|
||||||
|
'code' => 'help.contact', 'content_type' => 'dynamic', 'static_content_schema' => null,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('menues', ['code' => 'help.faq', 'content_type' => 'static', 'static_content_schema' => '{}']);
|
||||||
|
$this->assertDatabaseHas('tenants_menues', ['menu_code' => 'help.contact', 'static_content' => $legacy]);
|
||||||
|
|
||||||
|
$migration->down();
|
||||||
|
$this->assertDatabaseHas('menues', ['code' => 'help.contact', 'content_type' => 'static']);
|
||||||
|
$schema = json_decode(DB::table('menues')->where('code', 'help.contact')->value('static_content_schema'), true, flags: JSON_THROW_ON_ERROR);
|
||||||
|
$this->assertSame('required|url', $schema['whatsapp.whatsapp_url']);
|
||||||
|
$this->assertDatabaseHas('tenants_menues', ['menu_code' => 'help.contact', 'static_content' => $legacy]);
|
||||||
|
}
|
||||||
|
}
|
||||||
108
tests/Feature/Migrations/RemoveOnticketMenuCodePrefixTest.php
Normal file
108
tests/Feature/Migrations/RemoveOnticketMenuCodePrefixTest.php
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class RemoveOnticketMenuCodePrefixTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
Schema::create('menues', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('code')->unique();
|
||||||
|
$table->string('label');
|
||||||
|
$table->string('parent_menu_code')->nullable();
|
||||||
|
$table->foreign('parent_menu_code')->references('code')->on('menues')->nullOnDelete();
|
||||||
|
});
|
||||||
|
Schema::create('tenants_menues', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->string('menu_code');
|
||||||
|
$table->json('static_content')->nullable();
|
||||||
|
$table->boolean('is_enabled')->default(true);
|
||||||
|
$table->timestamps();
|
||||||
|
$table->foreign('menu_code')->references('code')->on('menues')->cascadeOnDelete();
|
||||||
|
$table->unique(['tenant_code', 'menu_code']);
|
||||||
|
});
|
||||||
|
Schema::create('roles_menues', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('rol_codigo');
|
||||||
|
$table->string('menu_codigo');
|
||||||
|
$table->string('access_level')->default('none');
|
||||||
|
$table->foreign('menu_codigo')->references('code')->on('menues')->cascadeOnDelete();
|
||||||
|
$table->unique(['rol_codigo', 'menu_codigo']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_preserves_hierarchy_assignments_content_and_access_and_can_run_twice(): void
|
||||||
|
{
|
||||||
|
DB::table('menues')->insert(['code' => 'onticket.custom', 'label' => 'Custom']);
|
||||||
|
DB::table('menues')->insert([
|
||||||
|
['code' => 'onticket.custom.child', 'label' => 'Child', 'parent_menu_code' => 'onticket.custom'],
|
||||||
|
['code' => 'unrelated', 'label' => 'Unrelated', 'parent_menu_code' => 'onticket.custom'],
|
||||||
|
['code' => 'shopit.adminapp.marca', 'label' => 'Marca', 'parent_menu_code' => null],
|
||||||
|
]);
|
||||||
|
$createdAt = '2026-09-01 12:00:00';
|
||||||
|
DB::table('tenants_menues')->insert([
|
||||||
|
'tenant_code' => 'tenant', 'menu_code' => 'onticket.custom.child',
|
||||||
|
'static_content' => '{"title":"Content"}', 'is_enabled' => false,
|
||||||
|
'created_at' => $createdAt, 'updated_at' => $createdAt,
|
||||||
|
]);
|
||||||
|
DB::table('roles_menues')->insert([
|
||||||
|
'rol_codigo' => 'adminapp', 'menu_codigo' => 'onticket.custom.child', 'access_level' => 'edit',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$migration = require database_path('migrations/2026_10_02_000000_remove_onticket_menu_code_prefix.php');
|
||||||
|
$migration->up();
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$this->assertFalse(DB::table('menues')->where('code', 'like', 'onticket.%')->exists());
|
||||||
|
$this->assertDatabaseHas('menues', ['code' => 'custom.child', 'label' => 'Child', 'parent_menu_code' => 'custom']);
|
||||||
|
$this->assertDatabaseHas('menues', ['code' => 'unrelated', 'parent_menu_code' => 'custom']);
|
||||||
|
$this->assertDatabaseHas('menues', ['code' => 'shopit.adminapp.marca']);
|
||||||
|
$this->assertDatabaseHas('tenants_menues', [
|
||||||
|
'tenant_code' => 'tenant', 'menu_code' => 'custom.child',
|
||||||
|
'static_content' => '{"title":"Content"}', 'is_enabled' => false, 'created_at' => $createdAt,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('roles_menues', [
|
||||||
|
'rol_codigo' => 'adminapp', 'menu_codigo' => 'custom.child', 'access_level' => 'edit',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseCount('tenants_menues', 1);
|
||||||
|
$this->assertDatabaseCount('roles_menues', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_merges_existing_codes_without_losing_the_active_assignment_settings(): void
|
||||||
|
{
|
||||||
|
DB::table('menues')->insert([
|
||||||
|
['code' => 'adminapp.tickets', 'label' => 'Tickets'],
|
||||||
|
['code' => 'onticket.adminapp.tickets', 'label' => 'Tickets prefixed'],
|
||||||
|
]);
|
||||||
|
DB::table('tenants_menues')->insert([
|
||||||
|
['tenant_code' => 'tenant', 'menu_code' => 'adminapp.tickets', 'is_enabled' => true],
|
||||||
|
['tenant_code' => 'tenant', 'menu_code' => 'onticket.adminapp.tickets', 'is_enabled' => false],
|
||||||
|
['tenant_code' => 'other', 'menu_code' => 'adminapp.tickets', 'is_enabled' => true],
|
||||||
|
]);
|
||||||
|
DB::table('roles_menues')->insert([
|
||||||
|
['rol_codigo' => 'adminapp', 'menu_codigo' => 'adminapp.tickets', 'access_level' => 'edit'],
|
||||||
|
['rol_codigo' => 'adminapp', 'menu_codigo' => 'onticket.adminapp.tickets', 'access_level' => 'none'],
|
||||||
|
['rol_codigo' => 'other', 'menu_codigo' => 'adminapp.tickets', 'access_level' => 'view'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$migration = require database_path('migrations/2026_10_02_000000_remove_onticket_menu_code_prefix.php');
|
||||||
|
$migration->up();
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('menues', 1);
|
||||||
|
$this->assertDatabaseCount('tenants_menues', 2);
|
||||||
|
$this->assertDatabaseCount('roles_menues', 2);
|
||||||
|
$this->assertDatabaseHas('tenants_menues', ['tenant_code' => 'tenant', 'menu_code' => 'adminapp.tickets', 'is_enabled' => false]);
|
||||||
|
$this->assertDatabaseHas('tenants_menues', ['tenant_code' => 'other', 'menu_code' => 'adminapp.tickets', 'is_enabled' => true]);
|
||||||
|
$this->assertDatabaseHas('roles_menues', ['rol_codigo' => 'adminapp', 'menu_codigo' => 'adminapp.tickets', 'access_level' => 'none']);
|
||||||
|
$this->assertDatabaseHas('roles_menues', ['rol_codigo' => 'other', 'menu_codigo' => 'adminapp.tickets', 'access_level' => 'view']);
|
||||||
|
}
|
||||||
|
}
|
||||||
49
tests/Feature/Migrations/UpdateShopitAdminColorsTest.php
Normal file
49
tests/Feature/Migrations/UpdateShopitAdminColorsTest.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Migrations;
|
||||||
|
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class UpdateShopitAdminColorsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_updates_only_shopit_admin_colors_and_preserves_them_on_rollback(): void
|
||||||
|
{
|
||||||
|
Schema::create('admin_website_types', function (Blueprint $table): void {
|
||||||
|
$table->string('codigo')->primary();
|
||||||
|
$table->string('primary_color');
|
||||||
|
$table->string('secondary_color');
|
||||||
|
$table->string('danger_color');
|
||||||
|
$table->string('success_color');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
foreach (['shopit', 'onticket'] as $code) {
|
||||||
|
DB::table('admin_website_types')->insert([
|
||||||
|
'codigo' => $code,
|
||||||
|
'primary_color' => '#2FD3AC',
|
||||||
|
'secondary_color' => '#777777',
|
||||||
|
'danger_color' => '#E04A4A',
|
||||||
|
'success_color' => '#81BC73',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$migration = require database_path('migrations/2026_10_01_000100_update_shopit_admin_secondary_and_danger_colors.php');
|
||||||
|
$migration->up();
|
||||||
|
$shopit = DB::table('admin_website_types')->where('codigo', 'shopit')->first();
|
||||||
|
$this->assertSame('#ADADAD', $shopit->secondary_color);
|
||||||
|
$this->assertSame('#FF8888', $shopit->danger_color);
|
||||||
|
$this->assertSame('#58B341', $shopit->success_color);
|
||||||
|
$this->assertSame('#2FD3AC', $shopit->primary_color);
|
||||||
|
$onticket = DB::table('admin_website_types')->where('codigo', 'onticket')->first();
|
||||||
|
$this->assertSame('#777777', $onticket->secondary_color);
|
||||||
|
$this->assertSame('#E04A4A', $onticket->danger_color);
|
||||||
|
$this->assertSame('#81BC73', $onticket->success_color);
|
||||||
|
|
||||||
|
$migration->down();
|
||||||
|
$this->assertSame('#ADADAD', DB::table('admin_website_types')->where('codigo', 'shopit')->value('secondary_color'));
|
||||||
|
$this->assertSame('#FF8888', DB::table('admin_website_types')->where('codigo', 'shopit')->value('danger_color'));
|
||||||
|
$this->assertSame('#58B341', DB::table('admin_website_types')->where('codigo', 'shopit')->value('success_color'));
|
||||||
|
}
|
||||||
|
}
|
||||||
183
tests/Feature/Sale/AdminAppSaleEventScopeTest.php
Normal file
183
tests/Feature/Sale/AdminAppSaleEventScopeTest.php
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Sale;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Commerce\Purchase\Services\CheckoutService;
|
||||||
|
use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService;
|
||||||
|
use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService;
|
||||||
|
use App\Domains\Core\Auth\Models\User;
|
||||||
|
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppSaleEventScopeTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Isolated schema: the full legacy migration chain cannot run on SQLite.
|
||||||
|
Schema::create('tenants', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('codigo');
|
||||||
|
});
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
Schema::create('compras', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$table->unsignedBigInteger('event_id')->nullable();
|
||||||
|
$table->string('nombre_apellido');
|
||||||
|
$table->string('status');
|
||||||
|
$table->decimal('total', 12, 2);
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
Schema::create('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('compra_id');
|
||||||
|
$table->integer('cantidad');
|
||||||
|
});
|
||||||
|
Schema::create('tickets', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('source_purchase_item_id');
|
||||||
|
});
|
||||||
|
Schema::create('ticket_refunds', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('purchase_item_id');
|
||||||
|
$table->decimal('amount', 12, 2);
|
||||||
|
});
|
||||||
|
Schema::create('value_changes', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->string('trackable_type');
|
||||||
|
$table->unsignedBigInteger('trackable_id');
|
||||||
|
$table->string('attribute');
|
||||||
|
$table->string('old_value');
|
||||||
|
$table->string('new_value');
|
||||||
|
$table->timestamp('changed_at');
|
||||||
|
$table->string('actor_type');
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('tenants')->insert(['codigo' => 'onticket']);
|
||||||
|
foreach ([1 => ['onticket', 10], 2 => ['onticket', 20], 3 => ['onticket', null], 4 => ['other', 10]] as $id => [$tenant, $event]) {
|
||||||
|
DB::table('compras')->insert([
|
||||||
|
'id' => $id,
|
||||||
|
'tenant_codigo' => $tenant,
|
||||||
|
'event_id' => $event,
|
||||||
|
'nombre_apellido' => 'Cliente',
|
||||||
|
'status' => Purchase::STATUS_PAID,
|
||||||
|
'total' => $id * 100,
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now(),
|
||||||
|
]);
|
||||||
|
DB::table('compra_items')->insert(['id' => $id, 'compra_id' => $id, 'cantidad' => 1]);
|
||||||
|
DB::table('ticket_refunds')->insert(['purchase_item_id' => $id, 'amount' => $id * 10]);
|
||||||
|
DB::table('value_changes')->insert([
|
||||||
|
'id' => $id,
|
||||||
|
'tenant_code' => $tenant,
|
||||||
|
'trackable_type' => (new Purchase)->getMorphClass(),
|
||||||
|
'trackable_id' => $id,
|
||||||
|
'attribute' => 'status',
|
||||||
|
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
'new_value' => Purchase::STATUS_PAID,
|
||||||
|
'changed_at' => now(),
|
||||||
|
'actor_type' => 'system',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
$this->actingAsAdministrator(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_totals_and_filters_cannot_escape_the_authenticated_event(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales?event_id=20&q=Cliente')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', 1)
|
||||||
|
->assertJsonPath('confirmed_sales_total', '100.00')
|
||||||
|
->assertJsonPath('refunded_total', '10.00');
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales?id=2')->assertOk()->assertJsonCount(0, 'data');
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales/modifications?q=Cliente')
|
||||||
|
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.sale_id', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unscoped_administrators_keep_access_to_all_sales_in_their_tenant(): void
|
||||||
|
{
|
||||||
|
$this->actingAsAdministrator(null);
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales')->assertOk()->assertJsonCount(3, 'data')
|
||||||
|
->assertJsonPath('confirmed_sales_total', '600.00')->assertJsonPath('refunded_total', '60.00');
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales/modifications')->assertOk()->assertJsonCount(3, 'data');
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales/2')->assertOk();
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales/3')->assertOk();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_foreign_and_unassigned_sales_are_inaccessible_before_any_checkout_action(): void
|
||||||
|
{
|
||||||
|
$this->mock(CheckoutService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldNotReceive('confirmPaidPurchase');
|
||||||
|
$mock->shouldNotReceive('cancelPurchaseFromAdmin');
|
||||||
|
});
|
||||||
|
foreach ([2, 3, 4] as $id) {
|
||||||
|
$this->getJson("/api/v1/adminapp/tenant/sales/{$id}")->assertNotFound();
|
||||||
|
$this->getJson("/api/v1/adminapp/tenant/sales/{$id}/tickets")->assertNotFound();
|
||||||
|
$this->postJson("/api/v1/adminapp/tenant/sales/{$id}/confirm", ['event_id' => 20])->assertNotFound();
|
||||||
|
$this->postJson("/api/v1/adminapp/tenant/sales/{$id}/cancel", ['event_id' => 20])->assertNotFound();
|
||||||
|
}
|
||||||
|
$this->assertSame(Purchase::STATUS_PAID, DB::table('compras')->where('id', 2)->value('status'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_own_event_allows_detail_tickets_and_checkout_actions(): void
|
||||||
|
{
|
||||||
|
// Empty snapshots keep this fixture focused on authorization.
|
||||||
|
DB::table('compra_items')->where('compra_id', 1)->delete();
|
||||||
|
$this->mock(CheckoutService::class, function (MockInterface $mock): void {
|
||||||
|
foreach (['confirmPaidPurchase', 'cancelPurchaseFromAdmin'] as $method) {
|
||||||
|
$mock->shouldReceive($method)->once()->withArgs(fn (Purchase $sale): bool => $sale->id === 1)
|
||||||
|
->andReturnUsing(fn (Purchase $sale): Purchase => $sale);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales/1')->assertOk()->assertJsonPath('data.id', 1);
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/sales/1/tickets')->assertOk();
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/sales/1/confirm')->assertOk()->assertJsonPath('data.id', 1);
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/sales/1/cancel')->assertOk()->assertJsonPath('data.id', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pdf_and_excel_exports_only_receive_sales_and_history_for_the_own_event(): void
|
||||||
|
{
|
||||||
|
foreach ([AdminAppSalePdfService::class, AdminAppSaleExcelService::class] as $class) {
|
||||||
|
$this->mock($class, function (MockInterface $mock) use ($class): void {
|
||||||
|
foreach (['downloadSales', 'downloadModifications'] as $method) {
|
||||||
|
$mock->shouldReceive($method)->once()->withArgs(
|
||||||
|
fn ($tenant, Collection $rows, $timezone): bool => $tenant->codigo === 'onticket'
|
||||||
|
&& $rows->pluck('id')->all() === [1] && $timezone === 'UTC'
|
||||||
|
)->andReturn($class === AdminAppSalePdfService::class
|
||||||
|
? response('pdf') : new StreamedResponse(fn () => print ('excel')));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (['pdf', 'excel', 'modifications/pdf', 'modifications/excel'] as $path) {
|
||||||
|
$this->getJson("/api/v1/adminapp/tenant/sales/{$path}?timezone=UTC&event_id=20")->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actingAsAdministrator(?int $eventId): void
|
||||||
|
{
|
||||||
|
$user = new User;
|
||||||
|
$user->setRawAttributes([
|
||||||
|
'id' => 1,
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => 'onticket',
|
||||||
|
'event_id' => $eventId,
|
||||||
|
]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -135,7 +135,7 @@ class DesfilePuraTendenciaSeederTest extends TestCase
|
|||||||
|
|
||||||
$expectedMenus = DB::table('tenants_menues')
|
$expectedMenus = DB::table('tenants_menues')
|
||||||
->where('tenant_code', 'fiesta_futbol_infantil')
|
->where('tenant_code', 'fiesta_futbol_infantil')
|
||||||
->where('menu_code', 'not like', 'onticket.adminapp.fiesta-futbol-infantil.%')
|
->where('menu_code', 'not like', 'adminapp.fiesta-futbol-infantil.%')
|
||||||
->orderBy('menu_code')
|
->orderBy('menu_code')
|
||||||
->pluck('menu_code')
|
->pluck('menu_code')
|
||||||
->all();
|
->all();
|
||||||
@@ -147,9 +147,9 @@ class DesfilePuraTendenciaSeederTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame($expectedMenus, $actualMenus);
|
$this->assertSame($expectedMenus, $actualMenus);
|
||||||
$this->assertContains('main.adminapp', $actualMenus);
|
$this->assertContains('main.adminapp', $actualMenus);
|
||||||
$this->assertContains('onticket.adminapp.event', $actualMenus);
|
$this->assertContains('adminapp.event', $actualMenus);
|
||||||
$this->assertContains('scanner.scan', $actualMenus);
|
$this->assertContains('scanner.scan', $actualMenus);
|
||||||
$this->assertNotContains('onticket.adminapp.fiesta-futbol-infantil.entradas', $actualMenus);
|
$this->assertNotContains('adminapp.fiesta-futbol-infantil.entradas', $actualMenus);
|
||||||
|
|
||||||
$catalogItem = DB::table('catalog_items')
|
$catalogItem = DB::table('catalog_items')
|
||||||
->where('tenant_code', 'desfile_pura_tendencia')
|
->where('tenant_code', 'desfile_pura_tendencia')
|
||||||
|
|||||||
@@ -26,24 +26,24 @@ class MenuSeederTest extends TestCase
|
|||||||
$this->seed(MenuSeeder::class);
|
$this->seed(MenuSeeder::class);
|
||||||
|
|
||||||
$expectedMenus = [
|
$expectedMenus = [
|
||||||
'onticket.adminapp.inicio' => ['Inicio', '/admin/inicio'],
|
'adminapp.inicio' => ['Inicio', '/admin/inicio'],
|
||||||
'onticket.adminapp.catalog' => ['Catálogo', '/admin/catalog'],
|
'adminapp.catalog' => ['Catálogo', '/admin/catalog'],
|
||||||
'onticket.adminapp.categories' => ['Categorías', '/admin/categories'],
|
'adminapp.categories' => ['Categorías', '/admin/categories'],
|
||||||
'onticket.adminapp.combos' => ['Combos', '/admin/combos'],
|
'adminapp.combos' => ['Combos', '/admin/combos'],
|
||||||
'onticket.adminapp.event' => ['Eventos', '/admin/event'],
|
'adminapp.event' => ['Eventos', '/admin/event'],
|
||||||
'onticket.adminapp.staff' => ['Usuarios', '/admin/staff'],
|
'adminapp.staff' => ['Usuarios', '/admin/staff'],
|
||||||
'onticket.adminapp.ventas' => ['Ventas', '/admin/ventas'],
|
'adminapp.ventas' => ['Ventas', '/admin/ventas'],
|
||||||
];
|
];
|
||||||
$fiestaCategoryMenus = [
|
$fiestaCategoryMenus = [
|
||||||
'onticket.adminapp.tickets' => ['Tickets', '/admin/tickets'],
|
'adminapp.tickets' => ['Tickets', '/admin/tickets'],
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'],
|
'adminapp.fiesta-futbol-infantil.entradas' => ['Entradas', '/admin/entradas'],
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'],
|
'adminapp.fiesta-futbol-infantil.alojamientos' => ['Alojamientos', '/admin/alojamientos'],
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'],
|
'adminapp.fiesta-futbol-infantil.merchandising' => ['Merchandising', '/admin/merchandising'],
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.comida' => ['Comida', '/admin/comidas'],
|
'adminapp.fiesta-futbol-infantil.comida' => ['Comida', '/admin/comidas'],
|
||||||
];
|
];
|
||||||
$desfileMenus = [
|
$desfileMenus = [
|
||||||
'onticket.adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'],
|
'adminapp.desfile.entradas' => ['Entradas', '/admin/desfile/entradas'],
|
||||||
'onticket.adminapp.desfile.reservas' => ['Reserva de Tickets', '/admin/desfile/reservas'],
|
'adminapp.desfile.reservas' => ['Reserva de Tickets', '/admin/desfile/reservas'],
|
||||||
];
|
];
|
||||||
$shopItMenus = $this->shopItAdminMenus();
|
$shopItMenus = $this->shopItAdminMenus();
|
||||||
|
|
||||||
@@ -95,10 +95,10 @@ class MenuSeederTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
foreach ([
|
foreach ([
|
||||||
'onticket.adminapp.inicio',
|
'adminapp.inicio',
|
||||||
'onticket.adminapp.catalog',
|
'adminapp.catalog',
|
||||||
'onticket.adminapp.categories',
|
'adminapp.categories',
|
||||||
'onticket.adminapp.combos',
|
'adminapp.combos',
|
||||||
] as $code) {
|
] as $code) {
|
||||||
$this->assertFalse(
|
$this->assertFalse(
|
||||||
$fiestaTenant->menues()->where('menues.code', $code)->exists()
|
$fiestaTenant->menues()->where('menues.code', $code)->exists()
|
||||||
@@ -163,7 +163,7 @@ class MenuSeederTest extends TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->assertFalse(
|
$this->assertFalse(
|
||||||
Menu::query()->where('code', 'like', 'adminapp.%')->exists()
|
Menu::query()->where('code', 'like', 'onticket.%')->exists()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,7 +213,7 @@ class MenuSeederTest extends TestCase
|
|||||||
|
|
||||||
$this->assertSame($userMenuCodes, $userRoleMenuCodes);
|
$this->assertSame($userMenuCodes, $userRoleMenuCodes);
|
||||||
$this->assertNotContains('main.adminapp', $userRoleMenuCodes);
|
$this->assertNotContains('main.adminapp', $userRoleMenuCodes);
|
||||||
$this->assertNotContains('onticket.adminapp.catalog', $userRoleMenuCodes);
|
$this->assertNotContains('adminapp.catalog', $userRoleMenuCodes);
|
||||||
|
|
||||||
$scannerRoleMenuCodes = Role::query()
|
$scannerRoleMenuCodes = Role::query()
|
||||||
->where('codigo', RoleCode::Scanner->value)
|
->where('codigo', RoleCode::Scanner->value)
|
||||||
@@ -338,26 +338,8 @@ class MenuSeederTest extends TestCase
|
|||||||
], $faq->static_content_schema);
|
], $faq->static_content_schema);
|
||||||
|
|
||||||
$contact = Menu::query()->where('code', 'help.contact')->firstOrFail();
|
$contact = Menu::query()->where('code', 'help.contact')->firstOrFail();
|
||||||
$this->assertSame(Menu::CONTENT_TYPE_STATIC, $contact->content_type);
|
$this->assertSame(Menu::CONTENT_TYPE_DYNAMIC, $contact->content_type);
|
||||||
$this->assertSame('required|url', $contact->static_content_schema['whatsapp.whatsapp_url']);
|
$this->assertNull($contact->static_content_schema);
|
||||||
$this->assertSame('required|string', $contact->static_content_schema['phone']);
|
|
||||||
$this->assertSame(
|
|
||||||
'required|array|min:1',
|
|
||||||
$contact->static_content_schema['locations.*.addresses']
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
'required|array|size:2',
|
|
||||||
$contact->static_content_schema['locations.*.addresses.*.coordinates']
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
'required|string',
|
|
||||||
$contact->static_content_schema['locations.*.addresses.*.address']
|
|
||||||
);
|
|
||||||
$this->assertSame(
|
|
||||||
'required|numeric|between:-90,90',
|
|
||||||
$contact->static_content_schema['locations.*.addresses.*.coordinates.0']
|
|
||||||
);
|
|
||||||
$this->assertSame('prohibited', $contact->static_content_schema['map_locations']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_assigns_help_to_the_supported_tenants_with_faq_content(): void
|
public function test_it_assigns_help_to_the_supported_tenants_with_faq_content(): void
|
||||||
@@ -385,30 +367,6 @@ class MenuSeederTest extends TestCase
|
|||||||
'is_active' => false,
|
'is_active' => false,
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
$expectedContact = [
|
|
||||||
'whatsapp' => [
|
|
||||||
'whatsapp_url' => 'https://wa.me/543412602222',
|
|
||||||
'whatsapp_label' => 'Chatea con nosotros',
|
|
||||||
],
|
|
||||||
'phone' => '+54 9 (0341) 6658247',
|
|
||||||
'locations' => [
|
|
||||||
'rosario' => [
|
|
||||||
'label' => 'Rosario',
|
|
||||||
'addresses' => [
|
|
||||||
[
|
|
||||||
'label' => 'Gigante de Arroyito',
|
|
||||||
'address' => 'Av. Génova 640, Rosario',
|
|
||||||
'coordinates' => [-32.913997, -60.674567],
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'label' => 'Telepagos',
|
|
||||||
'address' => 'Rioja 1150, piso 12, dpto. 3, Rosario',
|
|
||||||
'coordinates' => [-32.946820, -60.639320],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ([$sonder, $fiesta] as $tenant) {
|
foreach ([$sonder, $fiesta] as $tenant) {
|
||||||
$helpMenus = $tenant->menues()
|
$helpMenus = $tenant->menues()
|
||||||
@@ -422,10 +380,7 @@ class MenuSeederTest extends TestCase
|
|||||||
$expectedQuestions,
|
$expectedQuestions,
|
||||||
$helpMenus->firstWhere('code', 'help.faq')->pivot->static_content
|
$helpMenus->firstWhere('code', 'help.faq')->pivot->static_content
|
||||||
);
|
);
|
||||||
$this->assertEquals(
|
$this->assertNull($helpMenus->firstWhere('code', 'help.contact')->pivot->static_content);
|
||||||
$expectedContact,
|
|
||||||
$helpMenus->firstWhere('code', 'help.contact')->pivot->static_content
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->assertFalse(
|
$this->assertFalse(
|
||||||
|
|||||||
158
tests/Feature/Staff/StaffEventScopeTest.php
Normal file
158
tests/Feature/Staff/StaffEventScopeTest.php
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Staff;
|
||||||
|
|
||||||
|
use App\Domains\Core\Auth\Models\User;
|
||||||
|
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class StaffEventScopeTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Exercise HTTP authorization on SQLite without the incompatible legacy migrations.
|
||||||
|
Schema::create('tenants', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('codigo');
|
||||||
|
$table->boolean('scanner_category_validation_enabled')->default(false);
|
||||||
|
});
|
||||||
|
Schema::create('roles', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('codigo');
|
||||||
|
$table->string('nombre');
|
||||||
|
});
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('rol_codigo');
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$table->unsignedBigInteger('event_id')->nullable();
|
||||||
|
$table->string('nombre_apellido');
|
||||||
|
$table->string('dni');
|
||||||
|
$table->string('email');
|
||||||
|
$table->string('active_email')->nullable();
|
||||||
|
$table->string('password')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
Schema::create('categorias', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->string('tenant_code')->nullable();
|
||||||
|
$table->unsignedBigInteger('categoria_id')->nullable();
|
||||||
|
});
|
||||||
|
Schema::create('catalog_items', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('category_id');
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
Schema::create('category_scanners', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('user_id');
|
||||||
|
$table->unsignedBigInteger('categoria_id');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
Schema::create('personal_access_tokens', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tokenable_type');
|
||||||
|
$table->unsignedBigInteger('tokenable_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('tenants')->insert(['codigo' => 'onticket']);
|
||||||
|
foreach (['adminapp', 'scanner'] as $role) {
|
||||||
|
DB::table('roles')->insert(['codigo' => $role, 'nombre' => $role]);
|
||||||
|
foreach ([10, 20, null] as $eventId) {
|
||||||
|
$this->insertUser($role, 'onticket', $eventId);
|
||||||
|
}
|
||||||
|
$this->insertUser($role, 'other', 10);
|
||||||
|
}
|
||||||
|
Sanctum::actingAs(User::query()->findOrFail(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_lists_and_searches_are_restricted_to_the_authenticated_event(): void
|
||||||
|
{
|
||||||
|
foreach (['administrators' => 1, 'staff' => 5] as $path => $id) {
|
||||||
|
foreach (['', '?search=Persona&event_id=20'] as $query) {
|
||||||
|
$this->getJson("/api/v1/adminapp/tenant/{$path}{$query}")
|
||||||
|
->assertOk()->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('data.0.id', $id)->assertJsonPath('data.0.event_id', 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_creation_inherits_the_actor_event_even_if_the_client_supplies_another(): void
|
||||||
|
{
|
||||||
|
$this->mock(ResetPasswordAttemptService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('createForAdminAppEmail')->once();
|
||||||
|
$mock->shouldReceive('createForScannerEmail')->once();
|
||||||
|
});
|
||||||
|
foreach (['administrators' => 'adminapp', 'staff' => 'scanner'] as $path => $role) {
|
||||||
|
$this->postJson("/api/v1/adminapp/tenant/{$path}", [
|
||||||
|
...$this->payload("new-{$role}@example.com"), 'event_id' => 20,
|
||||||
|
])->assertSuccessful()->assertJsonPath('data.event_id', 10);
|
||||||
|
$this->assertDatabaseHas('users', ['email' => "new-{$role}@example.com", 'event_id' => 10, 'rol_codigo' => $role]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_foreign_and_unassigned_staff_cannot_be_edited_deleted_or_inspected(): void
|
||||||
|
{
|
||||||
|
foreach (['administrators' => [2, 3, 4], 'staff' => [6, 7, 8]] as $path => $ids) {
|
||||||
|
foreach ($ids as $id) {
|
||||||
|
$this->putJson("/api/v1/adminapp/tenant/{$path}/{$id}", $this->payload("update-{$id}@example.com"))->assertNotFound();
|
||||||
|
$this->deleteJson("/api/v1/adminapp/tenant/{$path}/{$id}")->assertNotFound();
|
||||||
|
$this->assertDatabaseHas('users', ['id' => $id, 'deleted_at' => null, 'email' => "person-{$id}@example.com"]);
|
||||||
|
if ($path === 'staff') {
|
||||||
|
$this->getJson("/api/v1/adminapp/tenant/staff/{$id}/scan-attempts")->assertNotFound();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_own_staff_can_be_edited_and_deleted_without_changing_its_event(): void
|
||||||
|
{
|
||||||
|
$adminId = $this->insertUser('adminapp', 'onticket', 10);
|
||||||
|
foreach (['administrators' => $adminId, 'staff' => 5] as $path => $id) {
|
||||||
|
$this->putJson("/api/v1/adminapp/tenant/{$path}/{$id}", [
|
||||||
|
...$this->payload("updated-{$id}@example.com"), 'event_id' => 20,
|
||||||
|
])->assertOk()->assertJsonPath('data.event_id', 10);
|
||||||
|
$this->deleteJson("/api/v1/adminapp/tenant/{$path}/{$id}")->assertNoContent();
|
||||||
|
$this->assertSoftDeleted('users', ['id' => $id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unscoped_actor_keeps_tenant_lists_and_creates_without_an_event(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(User::query()->findOrFail(3));
|
||||||
|
$this->mock(ResetPasswordAttemptService::class, function (MockInterface $mock): void {
|
||||||
|
$mock->shouldReceive('createForAdminAppEmail')->once();
|
||||||
|
$mock->shouldReceive('createForScannerEmail')->once();
|
||||||
|
});
|
||||||
|
foreach (['administrators', 'staff'] as $path) {
|
||||||
|
$this->getJson("/api/v1/adminapp/tenant/{$path}")->assertOk()->assertJsonCount(3, 'data');
|
||||||
|
$this->postJson("/api/v1/adminapp/tenant/{$path}", $this->payload("legacy-{$path}@example.com"))
|
||||||
|
->assertSuccessful()->assertJsonPath('data.event_id', null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function insertUser(string $role, string $tenant, ?int $eventId): int
|
||||||
|
{
|
||||||
|
$id = DB::table('users')->count() + 1;
|
||||||
|
|
||||||
|
return DB::table('users')->insertGetId([
|
||||||
|
'id' => $id, 'rol_codigo' => $role, 'tenant_codigo' => $tenant,
|
||||||
|
'event_id' => $eventId, 'nombre_apellido' => 'Persona', 'dni' => '12345678',
|
||||||
|
'email' => "person-{$id}@example.com", 'active_email' => "person-{$id}@example.com",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payload(string $email): array
|
||||||
|
{
|
||||||
|
return ['nombre_apellido' => 'Persona editada', 'dni' => '87654321', 'email' => $email];
|
||||||
|
}
|
||||||
|
}
|
||||||
232
tests/Feature/Tenant/AdminAppContactControllerTest.php
Normal file
232
tests/Feature/Tenant/AdminAppContactControllerTest.php
Normal file
@@ -0,0 +1,232 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Tenant;
|
||||||
|
|
||||||
|
use App\Domains\Core\Address\Models\Address;
|
||||||
|
use App\Domains\Core\Auth\Models\User;
|
||||||
|
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||||
|
use App\Domains\Core\Tenant\Models\SocialMedia;
|
||||||
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Core\Tenant\Resources\TenantResource;
|
||||||
|
use App\Domains\Core\Tenant\Services\ContactService;
|
||||||
|
use App\Domains\Ticketing\Event\Models\Event;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppContactControllerTest extends TestCase
|
||||||
|
{
|
||||||
|
private Tenant $tenant;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
// Use the real contact migrations in the guarded in-memory database. The
|
||||||
|
// unrelated historical migration chain cannot currently run on SQLite.
|
||||||
|
Schema::create('tenants', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('codigo')->unique();
|
||||||
|
$table->string('nombre');
|
||||||
|
$table->string('dominio');
|
||||||
|
foreach (array_keys((new Tenant)->getAttributes()) as $column) {
|
||||||
|
$table->string($column)->nullable();
|
||||||
|
}
|
||||||
|
$table->unsignedBigInteger('active_event_id')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
foreach ([
|
||||||
|
'2026_07_23_000200_create_social_media_tables.php',
|
||||||
|
'2026_07_23_000300_add_orden_to_tenant_social_media_table.php',
|
||||||
|
'2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php',
|
||||||
|
'2026_09_30_000400_create_phone_numbers_and_tenant_phone_numbers_tables.php',
|
||||||
|
'2026_09_30_000700_split_phone_number_components.php',
|
||||||
|
] as $migration) {
|
||||||
|
(require database_path('migrations/'.$migration))->up();
|
||||||
|
}
|
||||||
|
Schema::create('events', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->string('slug');
|
||||||
|
$table->string('title');
|
||||||
|
$table->unsignedBigInteger('address_id')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
Schema::create('event_social_media', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('event_id');
|
||||||
|
$table->string('social_media_code');
|
||||||
|
$table->string('url');
|
||||||
|
$table->unsignedInteger('orden');
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
$this->tenant = Tenant::create(['codigo' => 'contact-test', 'nombre' => 'Contact', 'dominio' => 'contact.test']);
|
||||||
|
SocialMedia::firstOrCreate(['code' => 'instagram'], ['name' => 'Instagram', 'icon' => 'instagram']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function login(): void
|
||||||
|
{
|
||||||
|
Sanctum::actingAs(new User([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $this->tenant->codigo,
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function payload(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'social_media' => [['code' => 'instagram', 'url' => 'https://instagram.com/contact']],
|
||||||
|
'addresses' => [['address_text' => 'San Martín 123']],
|
||||||
|
'phone_numbers' => [[
|
||||||
|
'country_code' => '+54', 'mobile_prefix' => null, 'area_code' => '341',
|
||||||
|
'local_number' => '5551234',
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_authentication_and_admin_role_are_required(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/contact')->assertUnauthorized();
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertUnauthorized();
|
||||||
|
Sanctum::actingAs(new User(['tenant_codigo' => $this->tenant->codigo]));
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertForbidden();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_contact_is_persisted_and_exposed_in_storefront_resource(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk()
|
||||||
|
->assertJsonPath('data.phone_numbers.0.mobile_prefix', null)
|
||||||
|
->assertJsonPath('data.addresses.0.is_main', true);
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/contact')->assertOk()
|
||||||
|
->assertJsonPath('data.addresses.0.address_text', 'San Martín 123');
|
||||||
|
$storefront = $this->storefrontData();
|
||||||
|
$this->assertSame('https://instagram.com/contact', $storefront['tenant_social_media'][0]['url']);
|
||||||
|
$this->assertSame('San Martín 123', $storefront['main_address']->resolve()['address_text']);
|
||||||
|
$this->assertSame('tel:+543415551234', $storefront['main_phone_number']->resolve()['tel_url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_existing_contacts_can_be_updated_replaced_and_cleared(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
$first = $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk()->json('data');
|
||||||
|
$payload = $this->payload();
|
||||||
|
$payload['addresses'] = [
|
||||||
|
['id' => $first['addresses'][0]['id'], 'address_text' => 'Updated', 'is_main' => false],
|
||||||
|
['address_text' => 'New principal', 'is_main' => true],
|
||||||
|
];
|
||||||
|
$payload['phone_numbers'][0]['id'] = $first['phone_numbers'][0]['id'];
|
||||||
|
$payload['phone_numbers'][0]['local_number'] = '7771234';
|
||||||
|
$payload['phone_numbers'][0]['is_main'] = false;
|
||||||
|
$payload['phone_numbers'][] = [...$this->payload()['phone_numbers'][0], 'local_number' => '8881234', 'is_main' => true];
|
||||||
|
$updated = $this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonCount(2, 'data.addresses')->json('data');
|
||||||
|
// Position wins, even if a client sends conflicting is_main flags.
|
||||||
|
$this->assertSame('Updated', $this->tenant->mainAddress()->firstOrFail()->address_text);
|
||||||
|
$this->assertSame('7771234', $this->tenant->mainPhoneNumber()->firstOrFail()->local_number);
|
||||||
|
$payload['addresses'] = array_reverse($updated['addresses']);
|
||||||
|
$payload['addresses'] = array_map(fn ($item) => array_intersect_key($item, array_flip(['id', 'address_text'])), $payload['addresses']);
|
||||||
|
$payload['phone_numbers'] = array_reverse($payload['phone_numbers']);
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()
|
||||||
|
->assertJsonPath('data.addresses.0.address_text', 'New principal')
|
||||||
|
->assertJsonPath('data.addresses.0.is_main', true)
|
||||||
|
->assertJsonPath('data.addresses.1.is_main', false);
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/contact')->assertOk()
|
||||||
|
->assertJsonPath('data.addresses.0.address_text', 'New principal');
|
||||||
|
$this->assertSame('New principal', $this->tenant->mainAddress()->firstOrFail()->address_text);
|
||||||
|
$this->assertSame('8881234', $this->tenant->mainPhoneNumber()->firstOrFail()->local_number);
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', [
|
||||||
|
'social_media' => [], 'addresses' => [], 'phone_numbers' => [],
|
||||||
|
])->assertOk()->assertJsonCount(0, 'data.phone_numbers');
|
||||||
|
$this->assertNull($this->tenant->mainAddress()->first());
|
||||||
|
$this->assertNull($this->tenant->mainPhoneNumber()->first());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_foreign_ids_and_invalid_payloads_do_not_change_contact(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk();
|
||||||
|
$foreign = Address::create(['label' => 'Foreign', 'address_text' => 'Private']);
|
||||||
|
$payload = $this->payload();
|
||||||
|
$payload['social_media'][0]['url'] = 'https://instagram.com/changed';
|
||||||
|
$payload['addresses'][0]['id'] = $foreign->id;
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable()->assertJsonValidationErrors('addresses.0.id');
|
||||||
|
$payload = $this->payload();
|
||||||
|
$payload['phone_numbers'][0]['id'] = 999999;
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable()->assertJsonValidationErrors('phone_numbers.0.id');
|
||||||
|
$payload = $this->payload();
|
||||||
|
$payload['social_media'][0]['url'] = 'javascript:alert(1)';
|
||||||
|
$payload['addresses'][] = $payload['addresses'][0];
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors(['social_media.0.url']);
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/contact')->assertJsonPath('data.social_media.0.url', 'https://instagram.com/contact');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_service_rolls_back_all_changes_if_contact_membership_changes(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk();
|
||||||
|
$payload = $this->payload();
|
||||||
|
$payload['social_media'] = [];
|
||||||
|
$payload['phone_numbers'][0]['id'] = 999999;
|
||||||
|
try {
|
||||||
|
app(ContactService::class)->update($this->tenant, $payload);
|
||||||
|
$this->fail('An invalid contact ID must reject the whole transaction.');
|
||||||
|
} catch (ValidationException) {
|
||||||
|
$this->assertSame(1, $this->tenant->socialMedia()->count());
|
||||||
|
$this->assertSame(1, $this->tenant->addresses()->count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_shared_records_are_not_modified_for_other_tenants(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
$first = $this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk()->json('data');
|
||||||
|
$other = Tenant::create(['codigo' => 'other-contact', 'nombre' => 'Other', 'dominio' => 'other-contact.test']);
|
||||||
|
$other->addresses()->attach($first['addresses'][0]['id'], ['is_main' => true]);
|
||||||
|
$other->phoneNumbers()->attach($first['phone_numbers'][0]['id'], ['is_main' => true]);
|
||||||
|
$payload = $this->payload();
|
||||||
|
$payload['addresses'][0] = [...$payload['addresses'][0], 'id' => $first['addresses'][0]['id'], 'address_text' => 'Changed'];
|
||||||
|
$payload['phone_numbers'][0] = [...$payload['phone_numbers'][0], 'id' => $first['phone_numbers'][0]['id'], 'local_number' => '9991234'];
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk();
|
||||||
|
$this->assertSame('San Martín 123', $other->addresses()->firstOrFail()->address_text);
|
||||||
|
$this->assertSame('5551234', $other->phoneNumbers()->firstOrFail()->local_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_contact_networks_are_independent_of_active_event_networks(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
$event = Event::create(['tenant_code' => $this->tenant->codigo, 'slug' => 'event', 'title' => 'Event']);
|
||||||
|
$event->socialMedia()->attach('instagram', ['url' => 'https://instagram.com/event', 'orden' => 0]);
|
||||||
|
$this->tenant->update(['active_event_id' => $event->id]);
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $this->payload())->assertOk();
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/contact')->assertOk()
|
||||||
|
->assertJsonPath('data.social_media.0.url', 'https://instagram.com/contact');
|
||||||
|
$this->assertSame('https://instagram.com/event', $event->socialMedia()->firstOrFail()->pivot->url);
|
||||||
|
$tenant = $this->tenant->fresh()->load(['socialMedia', 'activeEvent.socialMedia']);
|
||||||
|
$this->assertSame('https://instagram.com/contact', $tenant->socialMedia->first()->pivot->url);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function storefrontData(): array
|
||||||
|
{
|
||||||
|
$tenant = $this->tenant->fresh()->load(['socialMedia', 'addresses', 'mainAddress', 'phoneNumbers', 'mainPhoneNumber']);
|
||||||
|
foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) {
|
||||||
|
$tenant->setRelation($relation, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
return TenantResource::make($tenant)->resolve(request());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_address_coordinates_are_preserved_until_the_address_changes(): void
|
||||||
|
{
|
||||||
|
$this->login();
|
||||||
|
$address = Address::create(['label' => 'Sucursal', 'address_text' => 'Original', 'latitude' => -32.9, 'longitude' => -60.6]);
|
||||||
|
$this->tenant->addresses()->attach($address->id, ['is_main' => true]);
|
||||||
|
$payload = $this->payload();
|
||||||
|
$payload['addresses'] = [['id' => $address->id, 'address_text' => 'Original', 'is_main' => true]];
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonPath('data.addresses.0.latitude', -32.9);
|
||||||
|
$payload['addresses'][0]['address_text'] = 'New address';
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/contact', $payload)->assertOk()->assertJsonPath('data.addresses.0.latitude', null);
|
||||||
|
$this->assertSame('Sucursal', $address->fresh()->label);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -510,7 +510,7 @@ class BootstrapTenantControllerTest extends TestCase
|
|||||||
$tenant->menues()->sync([
|
$tenant->menues()->sync([
|
||||||
$parent->code,
|
$parent->code,
|
||||||
$staticChild->code => ['static_content' => $staticContent],
|
$staticChild->code => ['static_content' => $staticContent],
|
||||||
$dynamicChild->code,
|
$dynamicChild->code => ['static_content' => ['legacy' => 'must not be returned']],
|
||||||
]);
|
]);
|
||||||
$userRole->menus()->sync([
|
$userRole->menus()->sync([
|
||||||
$parent->code,
|
$parent->code,
|
||||||
|
|||||||
@@ -1359,7 +1359,7 @@ class AdminAppTicketControllerTest extends TestCase
|
|||||||
private function grantTicketsMenu(Tenant $tenant): void
|
private function grantTicketsMenu(Tenant $tenant): void
|
||||||
{
|
{
|
||||||
$menu = Menu::query()->create([
|
$menu = Menu::query()->create([
|
||||||
'code' => 'onticket.adminapp.tickets',
|
'code' => 'adminapp.tickets',
|
||||||
'label' => 'Tickets',
|
'label' => 'Tickets',
|
||||||
'route' => '/admin/tickets',
|
'route' => '/admin/tickets',
|
||||||
]);
|
]);
|
||||||
|
|||||||
178
tests/Feature/Ticket/AdminAppTicketEventScopeTest.php
Normal file
178
tests/Feature/Ticket/AdminAppTicketEventScopeTest.php
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature\Ticket;
|
||||||
|
|
||||||
|
use App\Domains\Core\Auth\Models\User;
|
||||||
|
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketExcelService;
|
||||||
|
use App\Domains\Ticketing\Ticket\Services\AdminAppTicketPdfService;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
use Laravel\Sanctum\Sanctum;
|
||||||
|
use Mockery\MockInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AdminAppTicketEventScopeTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
|
||||||
|
// Keep HTTP tests isolated from legacy migrations incompatible with SQLite.
|
||||||
|
Schema::create('tenants', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('codigo');
|
||||||
|
$table->string('timezone')->default('UTC');
|
||||||
|
$table->boolean('allow_ticket_refund')->default(false);
|
||||||
|
$table->boolean('allow_ticket_total_refund')->default(false);
|
||||||
|
$table->boolean('allow_ticket_partial_refund')->default(false);
|
||||||
|
});
|
||||||
|
Schema::create('menues', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('code');
|
||||||
|
});
|
||||||
|
Schema::create('tenants_menues', function (Blueprint $table): void {
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->string('menu_code');
|
||||||
|
});
|
||||||
|
Schema::create('users', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('nombre_apellido');
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
Schema::create('tickets', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->unsignedBigInteger('event_id')->nullable();
|
||||||
|
$table->string('ticket');
|
||||||
|
foreach (['source_variant_id', 'source_catalog_item_id', 'source_purchase_item_id', 'scanner_user_id', 'user_id'] as $column) {
|
||||||
|
$table->unsignedBigInteger($column)->nullable();
|
||||||
|
}
|
||||||
|
foreach (['used_at', 'disabled_at', 'cancelled_at', 'refunded_at'] as $column) {
|
||||||
|
$table->timestamp($column)->nullable();
|
||||||
|
}
|
||||||
|
$table->timestamps();
|
||||||
|
});
|
||||||
|
Schema::create('compras', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_codigo');
|
||||||
|
$table->unsignedBigInteger('event_id')->nullable();
|
||||||
|
$table->string('nombre_apellido');
|
||||||
|
});
|
||||||
|
Schema::create('compra_items', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('compra_id');
|
||||||
|
$table->decimal('precio_unitario', 12, 2);
|
||||||
|
});
|
||||||
|
Schema::create('ticket_refunds', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('ticket_id')->nullable();
|
||||||
|
$table->unsignedBigInteger('purchase_item_id');
|
||||||
|
$table->decimal('amount', 12, 2);
|
||||||
|
});
|
||||||
|
Schema::create('desfile_entry_reservations', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->unsignedBigInteger('ticket_id');
|
||||||
|
$table->softDeletes();
|
||||||
|
});
|
||||||
|
Schema::create('value_changes', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('tenant_code');
|
||||||
|
$table->string('trackable_type');
|
||||||
|
$table->unsignedBigInteger('trackable_id');
|
||||||
|
$table->string('attribute');
|
||||||
|
$table->string('old_value')->nullable();
|
||||||
|
$table->string('new_value')->nullable();
|
||||||
|
$table->timestamp('changed_at');
|
||||||
|
$table->string('actor_type');
|
||||||
|
$table->unsignedBigInteger('user_id')->nullable();
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('tenants')->insert(['codigo' => 'onticket']);
|
||||||
|
DB::table('menues')->insert(['code' => 'adminapp.tickets']);
|
||||||
|
DB::table('tenants_menues')->insert(['tenant_code' => 'onticket', 'menu_code' => 'adminapp.tickets']);
|
||||||
|
foreach ([1 => ['onticket', 10], 2 => ['onticket', 20], 3 => ['onticket', null], 4 => ['other', 10]] as $id => [$tenant, $event]) {
|
||||||
|
DB::table('tickets')->insert([
|
||||||
|
'id' => $id, 'tenant_code' => $tenant, 'event_id' => $event,
|
||||||
|
'ticket' => "ticket-{$id}", 'used_at' => now(),
|
||||||
|
]);
|
||||||
|
DB::table('compras')->insert(['id' => $id, 'tenant_codigo' => $tenant, 'event_id' => $event, 'nombre_apellido' => 'Cliente']);
|
||||||
|
DB::table('compra_items')->insert(['id' => $id, 'compra_id' => $id, 'precio_unitario' => 100]);
|
||||||
|
DB::table('ticket_refunds')->insert(['purchase_item_id' => $id, 'amount' => $id * 10]);
|
||||||
|
}
|
||||||
|
$this->actingAsAdmin(10);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_list_counts_refunded_total_and_search_cannot_escape_the_user_event(): void
|
||||||
|
{
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=id&event_id=20')
|
||||||
|
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', 1)
|
||||||
|
->assertJsonPath('scanned_tickets', 1)->assertJsonPath('total_tickets', 1)
|
||||||
|
->assertJsonPath('refunded_total', '10.00');
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?q=2')->assertOk()->assertJsonCount(0, 'data');
|
||||||
|
// Status uses sorting in memory rather than the database.
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=status')
|
||||||
|
->assertOk()->assertJsonCount(1, 'data')->assertJsonPath('data.0.id', 1);
|
||||||
|
DB::table('tickets')->where('id', 1)->update(['used_at' => null]);
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?status=active')
|
||||||
|
->assertOk()->assertJsonCount(1, 'data')
|
||||||
|
->assertJsonPath('scanned_tickets', 0)->assertJsonPath('total_tickets', 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_foreign_unassigned_and_other_tenant_tickets_cannot_be_modified_or_refunded(): void
|
||||||
|
{
|
||||||
|
foreach ([2, 3, 4] as $id) {
|
||||||
|
$this->postJson("/api/v1/adminapp/tenant/tickets/{$id}/cancel", ['event_id' => 20])->assertNotFound();
|
||||||
|
$this->getJson("/api/v1/adminapp/tenant/tickets/{$id}/refund")->assertNotFound();
|
||||||
|
$this->postJson("/api/v1/adminapp/tenant/tickets/{$id}/refund", ['refund_type' => 'total', 'event_id' => 20])->assertNotFound();
|
||||||
|
$this->assertDatabaseHas('tickets', ['id' => $id, 'cancelled_at' => null, 'refunded_at' => null]);
|
||||||
|
}
|
||||||
|
$this->assertDatabaseCount('value_changes', 0);
|
||||||
|
$this->assertDatabaseCount('ticket_refunds', 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_own_ticket_can_be_cancelled_and_refund_requests_reach_business_validation(): void
|
||||||
|
{
|
||||||
|
DB::table('tickets')->where('id', 1)->update(['used_at' => null]);
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets/1/refund')->assertUnprocessable();
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/tickets/1/refund', ['refund_type' => 'total'])->assertUnprocessable();
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/tickets/1/cancel')->assertOk();
|
||||||
|
$this->assertNotNull(DB::table('tickets')->where('id', 1)->value('cancelled_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_unscoped_admin_keeps_the_tenant_scope(): void
|
||||||
|
{
|
||||||
|
$this->actingAsAdmin(null);
|
||||||
|
$this->getJson('/api/v1/adminapp/tenant/tickets?sort_by=id')
|
||||||
|
->assertOk()->assertJsonCount(3, 'data')->assertJsonPath('total_tickets', 3)
|
||||||
|
->assertJsonPath('refunded_total', '60.00');
|
||||||
|
DB::table('tickets')->where('id', 3)->update(['used_at' => null]);
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/tickets/3/cancel')->assertOk();
|
||||||
|
$this->postJson('/api/v1/adminapp/tenant/tickets/4/cancel')->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_pdf_and_excel_receive_only_the_tickets_of_the_user_event(): void
|
||||||
|
{
|
||||||
|
foreach ([AdminAppTicketPdfService::class, AdminAppTicketExcelService::class] as $class) {
|
||||||
|
$this->mock($class, function (MockInterface $mock) use ($class): void {
|
||||||
|
$mock->shouldReceive('download')->once()->withArgs(
|
||||||
|
fn ($tenant, Collection $tickets, $timezone): bool => $tenant->codigo === 'onticket'
|
||||||
|
&& $tickets->pluck('id')->all() === [1] && $timezone === 'UTC'
|
||||||
|
)->andReturn($class === AdminAppTicketPdfService::class
|
||||||
|
? response('pdf') : new StreamedResponse(fn () => print ('excel')));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
foreach (['pdf', 'excel'] as $format) {
|
||||||
|
$this->getJson("/api/v1/adminapp/tenant/tickets/{$format}?timezone=UTC&event_id=20")->assertOk();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function actingAsAdmin(?int $eventId): void
|
||||||
|
{
|
||||||
|
$user = new User;
|
||||||
|
$user->setRawAttributes(['id' => 1, 'rol_codigo' => 'adminapp', 'tenant_codigo' => 'onticket', 'event_id' => $eventId]);
|
||||||
|
Sanctum::actingAs($user);
|
||||||
|
}
|
||||||
|
}
|
||||||
55
tests/Unit/Bootstrap/TenantContactResourceTest.php
Normal file
55
tests/Unit/Bootstrap/TenantContactResourceTest.php
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Bootstrap;
|
||||||
|
|
||||||
|
use App\Domains\Core\Menu\Models\Menu;
|
||||||
|
use App\Domains\Core\Menu\Models\TenantMenu;
|
||||||
|
use App\Domains\Core\Tenant\Models\SocialMedia;
|
||||||
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Core\Tenant\Resources\TenantResource;
|
||||||
|
use App\Domains\Ticketing\Event\Models\Event;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class TenantContactResourceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_dynamic_contact_omits_legacy_content_and_prioritizes_active_event_social_media(): void
|
||||||
|
{
|
||||||
|
$tenant = new Tenant(['codigo' => 'test']);
|
||||||
|
foreach (['favicon', 'headerLogo', 'footerLogo', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) {
|
||||||
|
$tenant->setRelation($relation, null);
|
||||||
|
}
|
||||||
|
$contact = new Menu([
|
||||||
|
'code' => 'help.contact', 'label' => 'Contacto', 'content_type' => 'dynamic', 'route' => '/ayuda/contacto',
|
||||||
|
]);
|
||||||
|
$contact->setRelation('pivot', new TenantMenu(['static_content' => ['whatsapp' => 'legacy']]));
|
||||||
|
$faq = new Menu(['code' => 'help.faq', 'label' => 'FAQ', 'content_type' => 'static']);
|
||||||
|
$faq->setRelation('pivot', new TenantMenu(['static_content' => ['question' => 'Example']]));
|
||||||
|
$tenant->setRelation('menues', collect([$contact, $faq]));
|
||||||
|
$tenantSocial = new SocialMedia(['code' => 'whatsapp', 'name' => 'WhatsApp', 'icon' => 'fa-brands fa-whatsapp']);
|
||||||
|
$tenantSocial->setRelation('pivot', new Pivot(['url' => 'https://wa.me/111']));
|
||||||
|
$eventSocial = new SocialMedia(['code' => 'whatsapp', 'name' => 'WhatsApp', 'icon' => 'fa-brands fa-whatsapp']);
|
||||||
|
$eventSocial->setRelation('pivot', new Pivot(['url' => 'https://wa.me/222']));
|
||||||
|
$tenant->setRelation('socialMedia', collect([$tenantSocial]));
|
||||||
|
$event = new Event;
|
||||||
|
$event->setRelation('socialMedia', collect([$eventSocial]));
|
||||||
|
$event->setRelation('address', null);
|
||||||
|
$event->setRelation('dates', collect());
|
||||||
|
$tenant->setRelation('activeEvent', $event);
|
||||||
|
|
||||||
|
$data = TenantResource::make($tenant)->resolve(request());
|
||||||
|
$this->assertSame('dynamic', $data['menues'][0]['content_type']);
|
||||||
|
$this->assertArrayNotHasKey('static_content', $data['menues'][0]);
|
||||||
|
$this->assertSame(['question' => 'Example'], $data['menues'][1]['static_content']);
|
||||||
|
$this->assertSame('https://wa.me/222', $data['social_media'][0]['url']);
|
||||||
|
$this->assertSame('https://wa.me/111', $data['tenant_social_media'][0]['url']);
|
||||||
|
|
||||||
|
$event->setRelation('socialMedia', collect());
|
||||||
|
$data = TenantResource::make($tenant)->resolve(request());
|
||||||
|
$this->assertCount(0, $data['social_media']);
|
||||||
|
|
||||||
|
$tenant->setRelation('activeEvent', null);
|
||||||
|
$data = TenantResource::make($tenant)->resolve(request());
|
||||||
|
$this->assertSame('https://wa.me/111', $data['social_media'][0]['url']);
|
||||||
|
}
|
||||||
|
}
|
||||||
37
tests/Unit/Catalog/CategoryResourceTest.php
Normal file
37
tests/Unit/Catalog/CategoryResourceTest.php
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit\Catalog;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
|
use App\Domains\Commerce\Catalog\Resources\CategoryResource;
|
||||||
|
use App\Shared\Attachable\Models\Attachment;
|
||||||
|
use App\Shared\Storage\Services\TemporaryUrlService;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class CategoryResourceTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_it_returns_the_banner_url_only_when_the_banner_exists_and_is_enabled(): void
|
||||||
|
{
|
||||||
|
$category = new Category(['nombre' => 'Remeras', 'banner_enabled' => true]);
|
||||||
|
$category->setRelation('banner', null);
|
||||||
|
$this->assertNull(CategoryResource::make($category)->resolve()['banner_url']);
|
||||||
|
|
||||||
|
$category->setRelation('banner', new Attachment(['path' => 'categories/banner.jpg']));
|
||||||
|
$category->banner_enabled = false;
|
||||||
|
$this->mock(TemporaryUrlService::class, function ($mock): void {
|
||||||
|
$mock->shouldReceive('generate')->once()
|
||||||
|
->with('categories/banner.jpg', 1440)
|
||||||
|
->andReturn([
|
||||||
|
'temporary_url' => 'https://example.com/banner.jpg',
|
||||||
|
'temporary_url_expires_at' => now()->addDay()->toIso8601String(),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
$this->assertNull(CategoryResource::make($category)->resolve()['banner_url']);
|
||||||
|
|
||||||
|
$category->banner_enabled = true;
|
||||||
|
$this->assertSame(
|
||||||
|
'https://example.com/banner.jpg',
|
||||||
|
CategoryResource::make($category)->resolve()['banner_url'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user