Compare commits
10 Commits
dev
...
tenant/des
| Author | SHA1 | Date | |
|---|---|---|---|
| de8da72354 | |||
| 4be94275f1 | |||
| 21b0517e6c | |||
| 7fbdc00c64 | |||
| 58c7a0f580 | |||
| a63aea0463 | |||
| b8251f3b63 | |||
| a919e9366b | |||
| 11dbcb4082 | |||
| ed321d6c6c |
@@ -2,10 +2,12 @@
|
||||
|
||||
namespace App\Domains\Catalog\Controllers;
|
||||
|
||||
use App\Domains\Cart\Services\CartService;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
||||
use App\Domains\Catalog\Requests\CatalogItemDetailRequest;
|
||||
use App\Domains\Catalog\Requests\CatalogVariantOptionsRequest;
|
||||
use App\Domains\Catalog\Requests\CategoryPageRequest;
|
||||
use App\Domains\Catalog\Requests\FeaturedGroupPageRequest;
|
||||
use App\Domains\Catalog\Requests\SearchCatalogItemsRequest;
|
||||
@@ -14,8 +16,10 @@ use App\Domains\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||
use App\Domains\Catalog\Resources\CatalogItemDetailResource;
|
||||
use App\Domains\Catalog\Resources\CatalogItemResource;
|
||||
use App\Domains\Catalog\Resources\CatalogSearchItemResource;
|
||||
use App\Domains\Catalog\Resources\CatalogVariantOptionsResource;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Catalog\Services\FeaturedGroupService;
|
||||
use App\Domains\Catalog\Services\VariantSelectionService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
@@ -110,6 +114,35 @@ class CatalogController extends Controller
|
||||
);
|
||||
}
|
||||
|
||||
public function variantOptions(
|
||||
CatalogVariantOptionsRequest $request,
|
||||
Tenant $tenant,
|
||||
CatalogItem $catalogItem,
|
||||
VariantSelectionService $variantSelectionService,
|
||||
CartService $cartService,
|
||||
): CatalogVariantOptionsResource {
|
||||
abort_unless($catalogItem->tenant_code === $tenant->codigo, 404);
|
||||
|
||||
$includedVariantId = null;
|
||||
$cartItemId = $request->validated('cart_item_id');
|
||||
|
||||
if ($cartItemId !== null) {
|
||||
$cartItem = $cartService->show($tenant, $request)
|
||||
->items
|
||||
->firstWhere('id', (int) $cartItemId);
|
||||
abort_unless($cartItem?->catalog_item_id === $catalogItem->id, 404);
|
||||
$includedVariantId = $cartItem->variant_id;
|
||||
}
|
||||
|
||||
return CatalogVariantOptionsResource::make(
|
||||
$variantSelectionService->options(
|
||||
$catalogItem,
|
||||
$request->validated('selected_values', []),
|
||||
$includedVariantId,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function store(
|
||||
StoreCatalogItemRequest $request,
|
||||
Tenant $tenant,
|
||||
|
||||
@@ -133,6 +133,12 @@ class CatalogItem extends Model
|
||||
|
||||
/** @return BelongsToMany<Attachment, $this> */
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->allAttachments()->wherePivot('is_enabled', true);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attachment, $this> */
|
||||
public function allAttachments(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
Attachment::class,
|
||||
@@ -140,7 +146,7 @@ class CatalogItem extends Model
|
||||
'catalog_item_id',
|
||||
'attachment_id'
|
||||
)
|
||||
->withPivot('orden')
|
||||
->withPivot(['orden', 'is_enabled'])
|
||||
->wherePivotNull('variant_id')
|
||||
->orderByPivot('orden');
|
||||
}
|
||||
|
||||
@@ -84,6 +84,12 @@ class Variant extends Model
|
||||
|
||||
/** @return BelongsToMany<Attachment, $this> */
|
||||
public function attachments(): BelongsToMany
|
||||
{
|
||||
return $this->allAttachments()->wherePivot('is_enabled', true);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Attachment, $this> */
|
||||
public function allAttachments(): BelongsToMany
|
||||
{
|
||||
$relation = $this->belongsToMany(
|
||||
Attachment::class,
|
||||
@@ -91,7 +97,7 @@ class Variant extends Model
|
||||
'variant_id',
|
||||
'attachment_id'
|
||||
)
|
||||
->withPivot('orden')
|
||||
->withPivot(['orden', 'is_enabled'])
|
||||
->orderByPivot('orden');
|
||||
|
||||
if ($this->catalog_item_id !== null) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class CatalogVariantOptionsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, list<string>> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'selected_values' => ['sometimes', 'array'],
|
||||
'selected_values.*' => ['nullable'],
|
||||
'cart_item_id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,10 @@ class CatalogFeaturedItemResource extends JsonResource
|
||||
return $this->columnWithImageData($catalogItem);
|
||||
}
|
||||
|
||||
if ($featuredGroup->product_layout === ProductLayout::TicketSelector) {
|
||||
return $this->ticketSelectorData($catalogItem);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'id' => $catalogItem->id,
|
||||
'type' => $catalogItem->type->value,
|
||||
@@ -48,13 +52,22 @@ class CatalogFeaturedItemResource extends JsonResource
|
||||
->values(),
|
||||
];
|
||||
|
||||
if ($featuredGroup->product_layout === ProductLayout::TicketSelector) {
|
||||
$data['image'] = $this->firstImageUrl($catalogItem);
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function ticketSelectorData(CatalogItem $catalogItem): array
|
||||
{
|
||||
return [
|
||||
'id' => $catalogItem->id,
|
||||
'type' => $catalogItem->type->value,
|
||||
'nombre' => $catalogItem->nombre,
|
||||
'descripcion' => $catalogItem->descripcion,
|
||||
'precio' => $catalogItem->precio,
|
||||
'image' => $this->firstImageUrl($catalogItem),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function columnWithImageData(CatalogItem $catalogItem): array
|
||||
{
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class CatalogVariantOptionsResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return $this->resource;
|
||||
}
|
||||
}
|
||||
221
app/Domains/Catalog/Services/VariantSelectionService.php
Normal file
221
app/Domains/Catalog/Services/VariantSelectionService.php
Normal file
@@ -0,0 +1,221 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class VariantSelectionService
|
||||
{
|
||||
/**
|
||||
* @param array<string, mixed> $selectedValues
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function options(
|
||||
CatalogItem $catalogItem,
|
||||
array $selectedValues,
|
||||
?int $includedVariantId = null,
|
||||
): array {
|
||||
$catalogItem->load([
|
||||
'itemAttributes.attribute',
|
||||
'variants' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.inventory',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
]);
|
||||
|
||||
$variants = $catalogItem->visibleVariants($includedVariantId)
|
||||
->values();
|
||||
$normalizedSelections = collect($selectedValues)
|
||||
->mapWithKeys(fn ($value, string $key): array => [$key => $this->normalizeValue($value)])
|
||||
->filter(fn ($value): bool => $value !== null && $value !== '' && $value !== [])
|
||||
->all();
|
||||
$matchingVariants = $variants
|
||||
->filter(fn (Variant $variant): bool => $this->matches($variant, $normalizedSelections))
|
||||
->values();
|
||||
$attributeKeys = $this->attributeKeys($catalogItem, $variants);
|
||||
$isComplete = $attributeKeys->isNotEmpty()
|
||||
&& $attributeKeys->every(fn (string $key): bool => array_key_exists($key, $normalizedSelections));
|
||||
$resolvedVariant = $isComplete && $matchingVariants->count() === 1
|
||||
? $matchingVariants->first()
|
||||
: null;
|
||||
|
||||
return [
|
||||
'selectors' => $this->selectors(
|
||||
$catalogItem,
|
||||
$variants,
|
||||
$attributeKeys,
|
||||
$matchingVariants->isEmpty() ? [] : $normalizedSelections,
|
||||
),
|
||||
'selected_values' => (object) ($matchingVariants->isEmpty() ? [] : $normalizedSelections),
|
||||
'resolved_variant' => $resolvedVariant === null
|
||||
? null
|
||||
: $this->variantData($catalogItem, $resolvedVariant),
|
||||
'valid' => $matchingVariants->isNotEmpty(),
|
||||
'available_variant_count' => $variants->count(),
|
||||
'matching_variant_count' => $matchingVariants->count(),
|
||||
'price_range' => $this->priceRange($catalogItem, $variants),
|
||||
];
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $selectedValues */
|
||||
private function matches(Variant $variant, array $selectedValues): bool
|
||||
{
|
||||
$variantValues = $variant->selectionValues();
|
||||
|
||||
foreach ($selectedValues as $key => $selectedValue) {
|
||||
if (! $variantValues->has($key)
|
||||
|| $this->valueKey($variantValues->get($key)) !== $this->valueKey($selectedValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @param Collection<int, Variant> $variants */
|
||||
private function attributeKeys(CatalogItem $catalogItem, Collection $variants): Collection
|
||||
{
|
||||
return $variants
|
||||
->flatMap(fn (Variant $variant): array => $variant
|
||||
->selectorOptions($catalogItem->itemAttributes)
|
||||
->keys()
|
||||
->all())
|
||||
->unique()
|
||||
->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Variant> $variants
|
||||
* @param Collection<int, string> $attributeKeys
|
||||
* @param array<string, mixed> $selectedValues
|
||||
* @return list<array<string, mixed>>
|
||||
*/
|
||||
private function selectors(
|
||||
CatalogItem $catalogItem,
|
||||
Collection $variants,
|
||||
Collection $attributeKeys,
|
||||
array $selectedValues,
|
||||
): array {
|
||||
return $attributeKeys
|
||||
->map(function (string $key, int $index) use (
|
||||
$catalogItem,
|
||||
$variants,
|
||||
$attributeKeys,
|
||||
$selectedValues,
|
||||
): array {
|
||||
$previousKeys = $attributeKeys->take($index);
|
||||
$previousSelections = collect($selectedValues)
|
||||
->only($previousKeys->all())
|
||||
->all();
|
||||
$compatibleVariants = $variants
|
||||
->filter(fn (Variant $variant): bool => $this->matches($variant, $previousSelections));
|
||||
|
||||
return [
|
||||
'key' => $key,
|
||||
'label' => $this->attributeLabel($catalogItem, $key),
|
||||
'options' => $this->optionsFor($catalogItem, $compatibleVariants, $key),
|
||||
'enabled' => $index === 0 || $previousKeys->every(
|
||||
fn (string $previousKey): bool => array_key_exists($previousKey, $selectedValues),
|
||||
),
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Variant> $variants
|
||||
* @return list<mixed>
|
||||
*/
|
||||
private function optionsFor(CatalogItem $catalogItem, Collection $variants, string $key): array
|
||||
{
|
||||
$options = [];
|
||||
$seen = [];
|
||||
|
||||
foreach ($variants as $variant) {
|
||||
$option = $variant->selectorOptions($catalogItem->itemAttributes)->get($key);
|
||||
if ($option === null || $option === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$optionKey = $this->valueKey($option);
|
||||
if (isset($seen[$optionKey])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$seen[$optionKey] = true;
|
||||
$options[] = $option;
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function attributeLabel(CatalogItem $catalogItem, string $key): string
|
||||
{
|
||||
if ($key === 'event_date') {
|
||||
return 'Fecha';
|
||||
}
|
||||
|
||||
return $catalogItem->itemAttributes
|
||||
->first(fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo === $key)
|
||||
?->attribute
|
||||
?->nombre ?? str($key)->headline()->toString();
|
||||
}
|
||||
|
||||
/** @param Collection<int, Variant> $variants */
|
||||
private function priceRange(CatalogItem $catalogItem, Collection $variants): array
|
||||
{
|
||||
$prices = $variants
|
||||
->map(fn (Variant $variant): float => $variant->getPrice())
|
||||
->whenEmpty(fn (Collection $prices): Collection => $prices->push($catalogItem->getPrice()));
|
||||
|
||||
return [
|
||||
'minimum' => number_format((float) $prices->min(), 2, '.', ''),
|
||||
'maximum' => number_format((float) $prices->max(), 2, '.', ''),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function variantData(CatalogItem $catalogItem, Variant $variant): array
|
||||
{
|
||||
return [
|
||||
'id' => $variant->id,
|
||||
'descripcion' => $variant->getDescription(),
|
||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory?->availableStock(),
|
||||
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
||||
];
|
||||
}
|
||||
|
||||
private function normalizeValue(mixed $value): mixed
|
||||
{
|
||||
if (is_array($value) && array_key_exists('value', $value)) {
|
||||
return (string) $value['value'];
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map(fn ($item) => $this->normalizeValue($item), $value);
|
||||
}
|
||||
|
||||
return is_scalar($value) ? (string) $value : null;
|
||||
}
|
||||
|
||||
private function valueKey(mixed $value): string
|
||||
{
|
||||
$normalized = $this->normalizeValue($value);
|
||||
|
||||
if (is_array($normalized)) {
|
||||
sort($normalized);
|
||||
}
|
||||
|
||||
return json_encode($normalized, JSON_THROW_ON_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
||||
Route::get('categories/{category}', [CatalogController::class, 'category'])
|
||||
->name('categories.show');
|
||||
Route::get('catalog-items/{catalogItem}', [CatalogController::class, 'show']);
|
||||
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
|
||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||
});
|
||||
|
||||
|
||||
57
app/Domains/Desfile/Controllers/EntryController.php
Normal file
57
app/Domains/Desfile/Controllers/EntryController.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Desfile\Controllers;
|
||||
|
||||
use App\Domains\Desfile\Requests\ReplaceEntryImageRequest;
|
||||
use App\Domains\Desfile\Requests\SyncEntryRowsRequest;
|
||||
use App\Domains\Desfile\Requests\UpdateEntryImageRequest;
|
||||
use App\Domains\Desfile\Resources\EntryResource;
|
||||
use App\Domains\Desfile\Services\EntryService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class EntryController extends Controller
|
||||
{
|
||||
public function __construct(private readonly EntryService $entryService) {}
|
||||
|
||||
public function show(Request $request): EntryResource
|
||||
{
|
||||
return new EntryResource(
|
||||
$this->entryService->current($request->user()->tenant()->firstOrFail()),
|
||||
);
|
||||
}
|
||||
|
||||
public function update(SyncEntryRowsRequest $request): EntryResource
|
||||
{
|
||||
return new EntryResource($this->entryService->syncRows(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated('rows'),
|
||||
));
|
||||
}
|
||||
|
||||
public function replaceImage(ReplaceEntryImageRequest $request): JsonResponse
|
||||
{
|
||||
return (new EntryResource($this->entryService->replaceImage(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->file('image'),
|
||||
$request->boolean('is_enabled', true),
|
||||
)))->response();
|
||||
}
|
||||
|
||||
public function updateImage(UpdateEntryImageRequest $request): EntryResource
|
||||
{
|
||||
return new EntryResource($this->entryService->updateImage(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->boolean('is_enabled'),
|
||||
));
|
||||
}
|
||||
|
||||
public function destroyImage(Request $request): Response
|
||||
{
|
||||
$this->entryService->deleteImage($request->user()->tenant()->firstOrFail());
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
22
app/Domains/Desfile/Requests/ReplaceEntryImageRequest.php
Normal file
22
app/Domains/Desfile/Requests/ReplaceEntryImageRequest.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Desfile\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ReplaceEntryImageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'image' => ['required', 'image', 'mimes:jpeg,jpg,png,webp', 'max:10240'],
|
||||
'is_enabled' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
55
app/Domains/Desfile/Requests/SyncEntryRowsRequest.php
Normal file
55
app/Domains/Desfile/Requests/SyncEntryRowsRequest.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Desfile\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class SyncEntryRowsRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'rows' => ['required', 'array', 'min:1', 'max:1000'],
|
||||
'rows.*' => ['required', 'array:type,sector,row,max_seat,price'],
|
||||
'rows.*.type' => ['required', 'string', 'max:100'],
|
||||
'rows.*.sector' => ['required', 'string', 'max:100'],
|
||||
'rows.*.row' => ['required', 'integer', 'between:1,5'],
|
||||
'rows.*.max_seat' => ['required', 'integer', 'between:1,100'],
|
||||
'rows.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, callable> */
|
||||
public function after(): array
|
||||
{
|
||||
return [function (Validator $validator): void {
|
||||
$combinations = [];
|
||||
|
||||
foreach ($this->input('rows', []) as $index => $row) {
|
||||
if (! is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$combination = collect(['type', 'sector', 'row'])
|
||||
->map(fn (string $field): string => mb_strtolower(trim((string) ($row[$field] ?? ''))))
|
||||
->implode('|');
|
||||
|
||||
if (isset($combinations[$combination])) {
|
||||
$validator->errors()->add(
|
||||
"rows.{$index}",
|
||||
'La combinación de tipo, sector y fila no puede repetirse.',
|
||||
);
|
||||
}
|
||||
|
||||
$combinations[$combination] = true;
|
||||
}
|
||||
}];
|
||||
}
|
||||
}
|
||||
21
app/Domains/Desfile/Requests/UpdateEntryImageRequest.php
Normal file
21
app/Domains/Desfile/Requests/UpdateEntryImageRequest.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Desfile\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateEntryImageRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'is_enabled' => ['required', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
56
app/Domains/Desfile/Resources/EntryResource.php
Normal file
56
app/Domains/Desfile/Resources/EntryResource.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Desfile\Resources;
|
||||
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin CatalogItem */
|
||||
class EntryResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$image = $this->allAttachments->first();
|
||||
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'rows' => $this->variants
|
||||
->map(function ($variant): array {
|
||||
$values = $variant->selectionValues();
|
||||
|
||||
return [
|
||||
'type' => $values->get('tipo'),
|
||||
'sector' => $values->get('sector'),
|
||||
'row' => $values->get('fila'),
|
||||
'seat' => (int) $values->get('asiento'),
|
||||
'price' => $variant->getPrice(),
|
||||
];
|
||||
})
|
||||
->groupBy(fn (array $variant): string => implode('|', [
|
||||
mb_strtolower(trim((string) $variant['type'])),
|
||||
mb_strtolower(trim((string) $variant['sector'])),
|
||||
mb_strtolower(trim((string) $variant['row'])),
|
||||
]))
|
||||
->map(function ($variants): array {
|
||||
$first = $variants->first();
|
||||
|
||||
return [
|
||||
'type' => $first['type'],
|
||||
'sector' => $first['sector'],
|
||||
'row' => $first['row'],
|
||||
'max_seat' => $variants->max('seat'),
|
||||
'price' => number_format($first['price'], 2, '.', ''),
|
||||
];
|
||||
})
|
||||
->values(),
|
||||
'image' => $image === null ? null : [
|
||||
'key' => $image->key,
|
||||
'filename' => $image->filename,
|
||||
'url' => $image->getTemporaryUrl(1440),
|
||||
'is_enabled' => (bool) $image->pivot->is_enabled,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
302
app/Domains/Desfile/Services/EntryService.php
Normal file
302
app/Domains/Desfile/Services/EntryService.php
Normal file
@@ -0,0 +1,302 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Desfile\Services;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class EntryService
|
||||
{
|
||||
private const ATTRIBUTE_MAP = [
|
||||
'type' => 'tipo',
|
||||
'sector' => 'sector',
|
||||
'row' => 'fila',
|
||||
'seat' => 'asiento',
|
||||
];
|
||||
|
||||
private const ROW_ATTRIBUTE_MAP = [
|
||||
'type' => 'tipo',
|
||||
'sector' => 'sector',
|
||||
'row' => 'fila',
|
||||
];
|
||||
|
||||
public function __construct(private readonly AttachmentService $attachmentService) {}
|
||||
|
||||
public function current(Tenant $tenant): CatalogItem
|
||||
{
|
||||
return $this->entryQuery($tenant)
|
||||
->with([
|
||||
'allAttachments',
|
||||
'itemAttributes.attribute.options',
|
||||
'variants' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.inventory',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
])
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
*/
|
||||
public function syncRows(Tenant $tenant, array $rows): CatalogItem
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $rows): void {
|
||||
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
|
||||
$itemAttributes = $this->itemAttributes($entry);
|
||||
$existingVariants = $entry->variants()
|
||||
->with(['inventory', 'definitions.itemAttribute.attribute'])
|
||||
->lockForUpdate()
|
||||
->get();
|
||||
$existingBySelection = $existingVariants->keyBy(
|
||||
fn (Variant $variant): string => $this->selectionKey($variant->selectionValues()->all()),
|
||||
);
|
||||
$desiredSelections = collect();
|
||||
|
||||
foreach (array_values($rows) as $index => $data) {
|
||||
$rowValues = $this->resolveRowValues($itemAttributes, $data, $index);
|
||||
|
||||
foreach (range(1, (int) $data['max_seat']) as $seat) {
|
||||
$values = [
|
||||
...$rowValues,
|
||||
'asiento' => $this->resolveAttributeValue(
|
||||
$itemAttributes,
|
||||
'asiento',
|
||||
(string) $seat,
|
||||
"rows.{$index}.max_seat",
|
||||
),
|
||||
];
|
||||
$selectionKey = $this->selectionKey($values);
|
||||
$desiredSelections->put($selectionKey, true);
|
||||
$variant = $existingBySelection->get($selectionKey);
|
||||
|
||||
if ($variant === null) {
|
||||
$variant = $entry->variants()->create([
|
||||
'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id,
|
||||
'descripcion' => $this->description($values),
|
||||
'precio' => $data['price'],
|
||||
]);
|
||||
$variant->definitions()->createMany(
|
||||
collect($values)->map(
|
||||
fn (string $value, string $code): array => [
|
||||
'item_attribute_id' => $itemAttributes[$code]->id,
|
||||
'value' => $value,
|
||||
],
|
||||
)->values()->all(),
|
||||
);
|
||||
} else {
|
||||
$variant->update([
|
||||
'descripcion' => $this->description($values),
|
||||
'precio' => $data['price'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($existingVariants as $variant) {
|
||||
$selectionKey = $this->selectionKey($variant->selectionValues()->all());
|
||||
if ($desiredSelections->has($selectionKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->assertVariantCanChangeIdentity($variant, 'rows');
|
||||
$variant->delete();
|
||||
}
|
||||
|
||||
$minimumPrice = $entry->variants()->min('precio');
|
||||
if ($minimumPrice !== null) {
|
||||
$entry->update(['precio' => $minimumPrice]);
|
||||
}
|
||||
});
|
||||
|
||||
return $this->current($tenant);
|
||||
}
|
||||
|
||||
public function replaceImage(
|
||||
Tenant $tenant,
|
||||
UploadedFile $image,
|
||||
bool $isEnabled,
|
||||
): CatalogItem {
|
||||
$attachment = $this->attachmentService->store($image, 'catalog-items');
|
||||
$previousAttachments = collect();
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($tenant, $attachment, $isEnabled, &$previousAttachments): void {
|
||||
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
|
||||
$previousAttachments = $entry->allAttachments()->get();
|
||||
$entry->allAttachments()->sync([
|
||||
$attachment->id => [
|
||||
'orden' => 0,
|
||||
'is_enabled' => $isEnabled,
|
||||
],
|
||||
]);
|
||||
});
|
||||
} catch (Throwable $throwable) {
|
||||
$this->deleteAttachmentQuietly($attachment);
|
||||
throw $throwable;
|
||||
}
|
||||
|
||||
$previousAttachments->each(fn (Attachment $previous) => $this->deleteIfUnused($previous));
|
||||
|
||||
return $this->current($tenant);
|
||||
}
|
||||
|
||||
public function updateImage(Tenant $tenant, bool $isEnabled): CatalogItem
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $isEnabled): void {
|
||||
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
|
||||
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
|
||||
|
||||
$entry->allAttachments()->updateExistingPivot($attachment->id, [
|
||||
'is_enabled' => $isEnabled,
|
||||
]);
|
||||
});
|
||||
|
||||
return $this->current($tenant);
|
||||
}
|
||||
|
||||
public function deleteImage(Tenant $tenant): void
|
||||
{
|
||||
$attachment = DB::transaction(function () use ($tenant): Attachment {
|
||||
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
|
||||
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
|
||||
$entry->allAttachments()->detach($attachment->id);
|
||||
|
||||
return $attachment;
|
||||
});
|
||||
|
||||
$this->deleteIfUnused($attachment);
|
||||
}
|
||||
|
||||
/** @return Collection<string, ItemAttribute> */
|
||||
private function itemAttributes(CatalogItem $entry): Collection
|
||||
{
|
||||
$attributes = $entry->itemAttributes()
|
||||
->with('attribute.options')
|
||||
->get()
|
||||
->filter(fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute !== null)
|
||||
->keyBy(fn (ItemAttribute $itemAttribute): string => $itemAttribute->attribute->codigo);
|
||||
$missing = collect(self::ATTRIBUTE_MAP)->diff($attributes->keys());
|
||||
|
||||
if ($missing->isNotEmpty()) {
|
||||
throw ValidationException::withMessages([
|
||||
'rows' => [
|
||||
'Faltan atributos requeridos para las entradas del desfile: '.$missing->implode(', ').'.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<string, ItemAttribute> $itemAttributes
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<string, string>
|
||||
*/
|
||||
private function resolveRowValues(Collection $itemAttributes, array $data, int $index): array
|
||||
{
|
||||
$values = [];
|
||||
|
||||
foreach (self::ROW_ATTRIBUTE_MAP as $input => $code) {
|
||||
$values[$code] = $this->resolveAttributeValue(
|
||||
$itemAttributes,
|
||||
$code,
|
||||
(string) $data[$input],
|
||||
"rows.{$index}.{$input}",
|
||||
);
|
||||
}
|
||||
|
||||
return $values;
|
||||
}
|
||||
|
||||
/** @param Collection<string, ItemAttribute> $itemAttributes */
|
||||
private function resolveAttributeValue(
|
||||
Collection $itemAttributes,
|
||||
string $code,
|
||||
string $requestedValue,
|
||||
string $errorKey,
|
||||
): string {
|
||||
$requestedValue = trim($requestedValue);
|
||||
$option = $itemAttributes[$code]->attribute->options->first(
|
||||
fn ($candidate): bool => $this->normalize($candidate->value) === $this->normalize($requestedValue),
|
||||
);
|
||||
|
||||
if ($option === null) {
|
||||
throw ValidationException::withMessages([
|
||||
$errorKey => ['La opción seleccionada no es válida.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return $option->value;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $values */
|
||||
private function selectionKey(array $values): string
|
||||
{
|
||||
return collect(self::ATTRIBUTE_MAP)
|
||||
->map(fn (string $code): string => $this->normalize((string) ($values[$code] ?? '')))
|
||||
->implode('|');
|
||||
}
|
||||
|
||||
private function assertVariantCanChangeIdentity(Variant $variant, string $key): void
|
||||
{
|
||||
$inventory = $variant->inventory;
|
||||
|
||||
if (($inventory?->reserved_stock ?? 0) > 0 || ($inventory?->sold_units ?? 0) > 0) {
|
||||
throw ValidationException::withMessages([
|
||||
$key => [
|
||||
'No se puede modificar ni eliminar un asiento con ventas o reservas.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, string> $values */
|
||||
private function description(array $values): string
|
||||
{
|
||||
return "Sector {$values['sector']} - Fila {$values['fila']} - Asiento {$values['asiento']} - {$values['tipo']}";
|
||||
}
|
||||
|
||||
private function normalize(string $value): string
|
||||
{
|
||||
return Str::ascii(mb_strtolower(trim($value)));
|
||||
}
|
||||
|
||||
/** @return Builder<CatalogItem> */
|
||||
private function entryQuery(Tenant $tenant): Builder
|
||||
{
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('slug', 'entrada');
|
||||
}
|
||||
|
||||
private function deleteIfUnused(Attachment $attachment): void
|
||||
{
|
||||
if (DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->deleteAttachmentQuietly($attachment);
|
||||
}
|
||||
|
||||
private function deleteAttachmentQuietly(Attachment $attachment): void
|
||||
{
|
||||
try {
|
||||
$this->attachmentService->delete($attachment);
|
||||
} catch (Throwable $throwable) {
|
||||
report($throwable);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,19 @@
|
||||
<?php
|
||||
|
||||
// Desfile tenant routes will be registered here.
|
||||
use App\Domains\Desfile\Controllers\EntryController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant/desfile')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas'])
|
||||
->group(function (): void {
|
||||
Route::get('entries', [EntryController::class, 'show'])
|
||||
->name('adminapp.desfile.entries.show');
|
||||
Route::put('entries', [EntryController::class, 'update'])
|
||||
->name('adminapp.desfile.entries.update');
|
||||
Route::post('entries/image', [EntryController::class, 'replaceImage'])
|
||||
->name('adminapp.desfile.entries.image.replace');
|
||||
Route::patch('entries/image', [EntryController::class, 'updateImage'])
|
||||
->name('adminapp.desfile.entries.image.update');
|
||||
Route::delete('entries/image', [EntryController::class, 'destroyImage'])
|
||||
->name('adminapp.desfile.entries.image.destroy');
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<?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('catalog_items_attachments', function (Blueprint $table): void {
|
||||
$table->boolean('is_enabled')->default(true)->after('orden');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('catalog_items_attachments', function (Blueprint $table): void {
|
||||
$table->dropColumn('is_enabled');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TENANT_CODE = 'desfile_pura_tendencia';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$this->swapRowAndSeat(
|
||||
array_map('strval', range(1, 5)),
|
||||
array_map('strval', range(1, 17)),
|
||||
);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->swapRowAndSeat(
|
||||
array_map('strval', range(1, 17)),
|
||||
array_map('strval', range(1, 5)),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $rowOptions
|
||||
* @param list<string> $seatOptions
|
||||
*/
|
||||
private function swapRowAndSeat(array $rowOptions, array $seatOptions): void
|
||||
{
|
||||
DB::transaction(function () use ($rowOptions, $seatOptions): void {
|
||||
$entryId = DB::table('catalog_items')
|
||||
->where('tenant_code', self::TENANT_CODE)
|
||||
->where('slug', 'entrada')
|
||||
->value('id');
|
||||
|
||||
if ($entryId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$attributes = DB::table('attribute')
|
||||
->where('tenant_codigo', self::TENANT_CODE)
|
||||
->whereIn('codigo', ['tipo', 'sector', 'fila', 'asiento'])
|
||||
->pluck('id', 'codigo');
|
||||
|
||||
if (! $attributes->has(['tipo', 'sector', 'fila', 'asiento'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->replaceOptions((int) $attributes['fila'], $rowOptions);
|
||||
$this->replaceOptions((int) $attributes['asiento'], $seatOptions);
|
||||
|
||||
$itemAttributes = DB::table('item_attributes')
|
||||
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
|
||||
->where('item_attributes.catalog_item_id', $entryId)
|
||||
->whereIn('attribute.codigo', ['tipo', 'sector', 'fila', 'asiento'])
|
||||
->pluck('item_attributes.id', 'attribute.codigo');
|
||||
|
||||
if (! $itemAttributes->has(['tipo', 'sector', 'fila', 'asiento'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (['tipo' => 1, 'sector' => 2, 'fila' => 3, 'asiento' => 4] as $code => $sortOrder) {
|
||||
DB::table('item_attributes')->where('id', $itemAttributes[$code])->update([
|
||||
'sort_order' => $sortOrder,
|
||||
]);
|
||||
}
|
||||
|
||||
$definitions = DB::table('variant_values')
|
||||
->join('item_attributes', 'item_attributes.id', '=', 'variant_values.item_attribute_id')
|
||||
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
|
||||
->join('variantes', 'variantes.id', '=', 'variant_values.variant_id')
|
||||
->where('variantes.catalog_item_id', $entryId)
|
||||
->whereIn('attribute.codigo', ['tipo', 'sector', 'fila', 'asiento'])
|
||||
->get([
|
||||
'variant_values.id',
|
||||
'variant_values.variant_id',
|
||||
'variant_values.value',
|
||||
'attribute.codigo',
|
||||
])
|
||||
->groupBy('variant_id');
|
||||
|
||||
foreach ($definitions as $variantId => $variantDefinitions) {
|
||||
$values = $variantDefinitions->keyBy('codigo');
|
||||
$row = $values->get('fila');
|
||||
$seat = $values->get('asiento');
|
||||
|
||||
if ($row === null || $seat === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
DB::table('variant_values')->where('id', $row->id)->update(['value' => $seat->value]);
|
||||
DB::table('variant_values')->where('id', $seat->id)->update(['value' => $row->value]);
|
||||
|
||||
$type = $values->get('tipo')?->value;
|
||||
$sector = $values->get('sector')?->value;
|
||||
if ($type !== null && $sector !== null) {
|
||||
DB::table('variantes')->where('id', $variantId)->update([
|
||||
'descripcion' => "Sector {$sector} - Fila {$seat->value} - Asiento {$row->value} - {$type}",
|
||||
]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** @param list<string> $options */
|
||||
private function replaceOptions(int $attributeId, array $options): void
|
||||
{
|
||||
DB::table('attribute_options')->where('attribute_id', $attributeId)->delete();
|
||||
|
||||
$now = now();
|
||||
DB::table('attribute_options')->insert(array_map(
|
||||
fn (string $option, int $index): array => [
|
||||
'attribute_id' => $attributeId,
|
||||
'validity_time_id' => null,
|
||||
'value' => $option,
|
||||
'label' => $option,
|
||||
'sort_order' => $index + 1,
|
||||
'metadata' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
$options,
|
||||
array_keys($options),
|
||||
));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const TENANT_CODE = 'desfile_pura_tendencia';
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$this->replaceSeatOptions(100);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$this->replaceSeatOptions(17);
|
||||
}
|
||||
|
||||
private function replaceSeatOptions(int $maximum): void
|
||||
{
|
||||
$attributeId = DB::table('attribute')
|
||||
->where('tenant_codigo', self::TENANT_CODE)
|
||||
->where('codigo', 'asiento')
|
||||
->value('id');
|
||||
|
||||
if ($attributeId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($attributeId, $maximum): void {
|
||||
DB::table('attribute_options')->where('attribute_id', $attributeId)->delete();
|
||||
|
||||
$now = now();
|
||||
DB::table('attribute_options')->insert(array_map(
|
||||
fn (int $seat): array => [
|
||||
'attribute_id' => $attributeId,
|
||||
'validity_time_id' => null,
|
||||
'value' => (string) $seat,
|
||||
'label' => (string) $seat,
|
||||
'sort_order' => $seat,
|
||||
'metadata' => null,
|
||||
'created_at' => $now,
|
||||
'updated_at' => $now,
|
||||
],
|
||||
range(1, $maximum),
|
||||
));
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -264,7 +264,8 @@ class CatalogControllerTest extends TestCase
|
||||
->assertJsonPath('0.layout', ProductLayout::TicketSelector->value)
|
||||
->assertJsonPath('0.group_layout', GroupLayout::Single->value)
|
||||
->assertJsonCount(1, '0.items')
|
||||
->assertJsonPath('0.items.0.nombre', 'Primera entrada');
|
||||
->assertJsonPath('0.items.0.nombre', 'Primera entrada')
|
||||
->assertJsonMissingPath('0.items.0.variants');
|
||||
}
|
||||
|
||||
public function test_groups_can_source_items_from_a_category_or_the_entire_catalog(): void
|
||||
|
||||
@@ -62,6 +62,7 @@ class CatalogItemControllerTest extends TestCase
|
||||
'catalog_item_id' => $item->id,
|
||||
'variant_id' => $variant->id,
|
||||
'orden' => 0,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -296,6 +296,68 @@ class CatalogItemDetailControllerTest extends TestCase
|
||||
->assertJsonPath('data.attributes.0.show_in_selector', false);
|
||||
}
|
||||
|
||||
public function test_it_resolves_available_variant_options_from_partial_selections(): void
|
||||
{
|
||||
$tenant = $this->createTenant('variant-options');
|
||||
$item = $this->createItem($tenant, 'Numbered entry');
|
||||
$sector = Attribute::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'codigo' => 'sector',
|
||||
'nombre' => 'Sector',
|
||||
'type' => FieldType::Select,
|
||||
]);
|
||||
$seat = Attribute::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'codigo' => 'seat',
|
||||
'nombre' => 'Seat',
|
||||
'type' => FieldType::Select,
|
||||
]);
|
||||
$itemSector = $item->itemAttributes()->create([
|
||||
'attribute_id' => $sector->id,
|
||||
'sort_order' => 0,
|
||||
]);
|
||||
$itemSeat = $item->itemAttributes()->create([
|
||||
'attribute_id' => $seat->id,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
$first = $this->createVariant($item, 1, 0);
|
||||
$second = $this->createVariant($item, 1, 0);
|
||||
$third = $this->createVariant($item, 1, 0);
|
||||
|
||||
foreach ([[$first, 'A', '1'], [$second, 'A', '2'], [$third, 'B', '3']] as [$variant, $sectorValue, $seatValue]) {
|
||||
$variant->definitions()->createMany([
|
||||
['item_attribute_id' => $itemSector->id, 'value' => $sectorValue],
|
||||
['item_attribute_id' => $itemSeat->id, 'value' => $seatValue],
|
||||
]);
|
||||
}
|
||||
|
||||
$this->postJson(
|
||||
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}/variant-options",
|
||||
[
|
||||
'selected_values' => ['sector' => 'A'],
|
||||
],
|
||||
)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.valid', true)
|
||||
->assertJsonPath('data.resolved_variant', null)
|
||||
->assertJsonMissingPath('data.variants')
|
||||
->assertJsonCount(2, 'data.selectors')
|
||||
->assertJsonPath('data.selectors.0.key', 'sector')
|
||||
->assertJsonCount(2, 'data.selectors.0.options')
|
||||
->assertJsonCount(2, 'data.selectors.1.options')
|
||||
->assertJsonPath('data.selectors.1.options.0.value', '1')
|
||||
->assertJsonPath('data.available_variant_count', 3)
|
||||
->assertJsonPath('data.matching_variant_count', 2);
|
||||
|
||||
$this->postJson(
|
||||
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}/variant-options",
|
||||
['selected_values' => ['sector' => 'A', 'seat' => '1']],
|
||||
)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.resolved_variant.id', $first->id)
|
||||
->assertJsonPath('data.resolved_variant.values.sector.value', 'A');
|
||||
}
|
||||
|
||||
private function createItem(
|
||||
Tenant $tenant,
|
||||
string $name,
|
||||
|
||||
@@ -104,6 +104,7 @@ class CatalogSchemaTest extends TestCase
|
||||
'catalog_item_id',
|
||||
'attachment_id',
|
||||
'orden',
|
||||
'is_enabled',
|
||||
], Schema::getColumnListing('catalog_items_attachments'));
|
||||
}
|
||||
|
||||
@@ -170,12 +171,14 @@ class CatalogSchemaTest extends TestCase
|
||||
'variant_id' => null,
|
||||
'attachment_id' => $itemAttachment->id,
|
||||
'orden' => 2,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
$this->assertDatabaseHas('catalog_items_attachments', [
|
||||
'catalog_item_id' => $item->id,
|
||||
'variant_id' => $variant->id,
|
||||
'attachment_id' => $variantAttachment->id,
|
||||
'orden' => 3,
|
||||
'is_enabled' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
280
tests/Feature/Desfile/EntryControllerTest.php
Normal file
280
tests/Feature/Desfile/EntryControllerTest.php
Normal file
@@ -0,0 +1,280 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Desfile;
|
||||
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Enums\InventorySubject;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use Database\Seeders\AuthorizationSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class EntryControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(AuthorizationSeeder::class);
|
||||
WebsiteType::query()->create([
|
||||
'codigo' => 'onticket',
|
||||
'nombre' => 'OnTicket',
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_returns_row_configs_and_expands_them_into_seat_variants(): void
|
||||
{
|
||||
[$tenant, $entry] = $this->configuredEntry();
|
||||
$existing = $this->createVariant($entry, 'NORMAL', 'A', '1', '1', 100000);
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/desfile/entries')
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $entry->id)
|
||||
->assertJsonPath('data.rows.0.type', 'NORMAL')
|
||||
->assertJsonPath('data.rows.0.row', '1')
|
||||
->assertJsonPath('data.rows.0.max_seat', 1)
|
||||
->assertJsonPath('data.rows.0.price', '100000.00');
|
||||
|
||||
$response = $this->putJson('/api/v1/adminapp/tenant/desfile/entries', [
|
||||
'rows' => [
|
||||
[
|
||||
'type' => 'NORMAL',
|
||||
'sector' => 'A',
|
||||
'row' => '1',
|
||||
'max_seat' => 2,
|
||||
'price' => 120000,
|
||||
],
|
||||
[
|
||||
'type' => 'VIP + LUNCH',
|
||||
'sector' => 'B',
|
||||
'row' => '2',
|
||||
'max_seat' => 3,
|
||||
'price' => 250000,
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonCount(2, 'data.rows')
|
||||
->assertJsonPath('data.rows.0.max_seat', 2)
|
||||
->assertJsonPath('data.rows.0.price', '120000.00')
|
||||
->assertJsonPath('data.rows.1.max_seat', 3)
|
||||
->assertJsonPath('data.rows.1.type', 'VIP + LUNCH');
|
||||
|
||||
$this->assertDatabaseCount('variantes', 5);
|
||||
$this->assertDatabaseHas('catalog_items', [
|
||||
'id' => $entry->id,
|
||||
'precio' => 120000,
|
||||
]);
|
||||
$this->assertSame('120000.00', $existing->fresh()->precio);
|
||||
$this->assertSame(2, $entry->variants()->where('precio', 120000)->count());
|
||||
$this->assertSame(3, $entry->variants()->where('precio', 250000)->count());
|
||||
$this->assertSame(5, Inventory::query()->where('real_stock', 1)->count());
|
||||
}
|
||||
|
||||
public function test_it_replaces_and_toggles_the_entry_image(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
[$tenant, $entry] = $this->configuredEntry();
|
||||
$oldImage = Attachment::query()->create([
|
||||
'path' => 'catalog-items/old.png',
|
||||
'filename' => 'old.png',
|
||||
'type' => AttachmentType::Image,
|
||||
'mime_type' => 'image/png',
|
||||
'extension' => 'png',
|
||||
'size' => 10,
|
||||
]);
|
||||
Storage::disk('s3')->put($oldImage->path, 'old');
|
||||
$entry->allAttachments()->attach($oldImage->id, ['orden' => 0]);
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->post('/api/v1/adminapp/tenant/desfile/entries/image', [
|
||||
'image' => UploadedFile::fake()->image('plano.png'),
|
||||
'is_enabled' => false,
|
||||
], ['Accept' => 'application/json'])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.image.filename', 'plano.png')
|
||||
->assertJsonPath('data.image.is_enabled', false);
|
||||
|
||||
$this->assertDatabaseMissing('attachments', ['id' => $oldImage->id]);
|
||||
$this->assertCount(0, $entry->fresh()->attachments);
|
||||
$this->assertCount(1, $entry->fresh()->allAttachments);
|
||||
|
||||
$this->patchJson('/api/v1/adminapp/tenant/desfile/entries/image', [
|
||||
'is_enabled' => true,
|
||||
])
|
||||
->assertOk()
|
||||
->assertJsonPath('data.image.is_enabled', true);
|
||||
|
||||
$this->assertCount(1, $entry->fresh()->attachments);
|
||||
}
|
||||
|
||||
public function test_it_rejects_duplicate_rows(): void
|
||||
{
|
||||
[$tenant] = $this->configuredEntry();
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$payload = [
|
||||
'rows' => [
|
||||
$this->rowPayload('NORMAL', 'A', '1', 10, 100),
|
||||
$this->rowPayload('normal', 'A', '1', 20, 200),
|
||||
],
|
||||
];
|
||||
|
||||
$this->putJson('/api/v1/adminapp/tenant/desfile/entries', $payload)
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('rows.1');
|
||||
}
|
||||
|
||||
public function test_it_rejects_rows_and_seat_quantities_outside_the_configured_ranges(): void
|
||||
{
|
||||
[$tenant] = $this->configuredEntry();
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->putJson('/api/v1/adminapp/tenant/desfile/entries', [
|
||||
'rows' => [
|
||||
$this->rowPayload('NORMAL', 'A', '6', 1, 100),
|
||||
$this->rowPayload('NORMAL', 'A', '1', 101, 100),
|
||||
],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['rows.0.row', 'rows.1.max_seat']);
|
||||
}
|
||||
|
||||
public function test_it_cannot_reduce_the_maximum_below_a_reserved_seat(): void
|
||||
{
|
||||
[$tenant, $entry] = $this->configuredEntry();
|
||||
$reserved = $this->createVariant($entry, 'NORMAL', 'A', '1', '3', 100);
|
||||
$reserved->inventory()->update(['reserved_stock' => 1]);
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->putJson('/api/v1/adminapp/tenant/desfile/entries', [
|
||||
'rows' => [$this->rowPayload('NORMAL', 'A', '1', 2, 100)],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors('rows');
|
||||
|
||||
$this->assertDatabaseHas('variantes', ['id' => $reserved->id, 'deleted_at' => null]);
|
||||
}
|
||||
|
||||
/** @return array{Tenant, CatalogItem} */
|
||||
private function configuredEntry(string $tenantCode = 'desfile_pura_tendencia'): array
|
||||
{
|
||||
$tenant = Tenant::query()->create([
|
||||
'codigo' => $tenantCode,
|
||||
'nombre' => 'Desfile',
|
||||
'dominio' => "{$tenantCode}.test",
|
||||
'website_type_code' => 'onticket',
|
||||
]);
|
||||
$menu = Menu::query()->firstOrCreate(
|
||||
['code' => 'adminapp.desfile.entradas'],
|
||||
['label' => 'Entradas', 'route' => '/admin/desfile/entradas'],
|
||||
);
|
||||
$tenant->menues()->attach($menu->code);
|
||||
$entry = CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'slug' => 'entrada',
|
||||
'nombre' => 'Entrada',
|
||||
'precio' => 0,
|
||||
'inventory_policy' => InventoryPolicy::Tracked,
|
||||
'inventory_subject' => InventorySubject::Seat,
|
||||
'has_tickets' => true,
|
||||
]);
|
||||
|
||||
foreach ([
|
||||
'tipo' => ['VIP + LUNCH', 'NORMAL'],
|
||||
'sector' => ['A', 'B', 'C', 'D'],
|
||||
'fila' => array_map('strval', range(1, 5)),
|
||||
'asiento' => array_map('strval', range(1, 100)),
|
||||
] as $code => $options) {
|
||||
$attribute = Attribute::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'type' => FieldType::Select,
|
||||
'is_required' => true,
|
||||
]);
|
||||
foreach ($options as $order => $option) {
|
||||
$attribute->options()->create([
|
||||
'value' => $option,
|
||||
'label' => $option,
|
||||
'sort_order' => $order + 1,
|
||||
]);
|
||||
}
|
||||
$entry->itemAttributes()->create([
|
||||
'attribute_id' => $attribute->id,
|
||||
'sort_order' => $entry->itemAttributes()->count() + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
return [$tenant, $entry];
|
||||
}
|
||||
|
||||
private function createVariant(
|
||||
CatalogItem $entry,
|
||||
string $type,
|
||||
string $sector,
|
||||
string $row,
|
||||
string $seat,
|
||||
int $price,
|
||||
): Variant {
|
||||
$variant = $entry->variants()->create([
|
||||
'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id,
|
||||
'precio' => $price,
|
||||
]);
|
||||
$itemAttributes = $entry->itemAttributes()->with('attribute')->get()->keyBy(
|
||||
fn (ItemAttribute $itemAttribute): string => $itemAttribute->attribute->codigo,
|
||||
);
|
||||
|
||||
foreach (compact('type', 'sector', 'row', 'seat') as $input => $value) {
|
||||
$code = ['type' => 'tipo', 'sector' => 'sector', 'row' => 'fila', 'seat' => 'asiento'][$input];
|
||||
$variant->definitions()->create([
|
||||
'item_attribute_id' => $itemAttributes[$code]->id,
|
||||
'value' => $value,
|
||||
]);
|
||||
}
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
private function rowPayload(
|
||||
string $type,
|
||||
string $sector,
|
||||
string $row,
|
||||
int $maxSeat,
|
||||
int $price,
|
||||
): array {
|
||||
return [
|
||||
...compact('type', 'sector', 'row', 'price'),
|
||||
'max_seat' => $maxSeat,
|
||||
];
|
||||
}
|
||||
|
||||
private function createAdminAppUser(Tenant $tenant): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'rol_codigo' => RoleCode::AdminApp->value,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,16 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
|
||||
);
|
||||
$migration->up();
|
||||
|
||||
$rowAndSeatMigration = require database_path(
|
||||
'migrations/2026_08_14_030000_swap_desfile_row_and_seat_semantics.php'
|
||||
);
|
||||
$rowAndSeatMigration->up();
|
||||
|
||||
$seatOptionsMigration = require database_path(
|
||||
'migrations/2026_08_14_040000_expand_desfile_seat_options.php'
|
||||
);
|
||||
$seatOptionsMigration->up();
|
||||
|
||||
$footerBackgroundMigration = require database_path(
|
||||
'migrations/2026_08_12_060000_set_desfile_footer_background_image.php'
|
||||
);
|
||||
@@ -186,13 +196,22 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
|
||||
$this->assertSame([
|
||||
'tipo' => ['VIP + LUNCH', 'NORMAL'],
|
||||
'sector' => ['A', 'B', 'C', 'D'],
|
||||
'fila' => array_map('strval', range(1, 17)),
|
||||
'asiento' => array_map('strval', range(1, 5)),
|
||||
'fila' => array_map('strval', range(1, 5)),
|
||||
'asiento' => array_map('strval', range(1, 100)),
|
||||
], $attributes->map(fn (object $attribute): array => DB::table('attribute_options')
|
||||
->where('attribute_id', $attribute->id)
|
||||
->orderBy('sort_order')
|
||||
->pluck('value')
|
||||
->all())->all());
|
||||
$this->assertSame(
|
||||
['tipo', 'sector', 'fila', 'asiento'],
|
||||
DB::table('item_attributes')
|
||||
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
|
||||
->where('item_attributes.catalog_item_id', $catalogItem->id)
|
||||
->orderBy('item_attributes.sort_order')
|
||||
->pluck('attribute.codigo')
|
||||
->all(),
|
||||
);
|
||||
|
||||
$variants = DB::table('variantes')->where('catalog_item_id', $catalogItem->id);
|
||||
|
||||
@@ -210,6 +229,11 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
|
||||
->where('reserved_stock', 0)
|
||||
->where('sold_units', 0)
|
||||
->count());
|
||||
$this->assertDatabaseHas('variantes', [
|
||||
'catalog_item_id' => $catalogItem->id,
|
||||
'descripcion' => 'Sector A - Fila 1 - Asiento 17 - VIP + LUNCH',
|
||||
'precio' => 250000,
|
||||
]);
|
||||
|
||||
foreach ([
|
||||
250000 => 34,
|
||||
@@ -228,15 +252,15 @@ class CreateDesfilePuraTendenciaTenantTest extends TestCase
|
||||
|
||||
$this->assertSame(0, $this->variantCountForSelection($catalogItem->id, [
|
||||
'sector' => ['B', 'D'],
|
||||
'fila' => ['17'],
|
||||
'asiento' => ['17'],
|
||||
]));
|
||||
$this->assertSame(66, $this->variantCountForSelection($catalogItem->id, [
|
||||
'tipo' => ['VIP + LUNCH'],
|
||||
'asiento' => ['1'],
|
||||
'fila' => ['1'],
|
||||
]));
|
||||
$this->assertSame(66, $this->variantCountForSelection($catalogItem->id, [
|
||||
'tipo' => ['NORMAL'],
|
||||
'asiento' => ['5'],
|
||||
'fila' => ['5'],
|
||||
]));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user