feat(desfile): implement entry management with image handling and variant synchronization
This commit is contained in:
@@ -133,6 +133,12 @@ class CatalogItem extends Model
|
|||||||
|
|
||||||
/** @return BelongsToMany<Attachment, $this> */
|
/** @return BelongsToMany<Attachment, $this> */
|
||||||
public function attachments(): BelongsToMany
|
public function attachments(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->allAttachments()->wherePivot('is_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<Attachment, $this> */
|
||||||
|
public function allAttachments(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(
|
return $this->belongsToMany(
|
||||||
Attachment::class,
|
Attachment::class,
|
||||||
|
|||||||
@@ -84,6 +84,12 @@ class Variant extends Model
|
|||||||
|
|
||||||
/** @return BelongsToMany<Attachment, $this> */
|
/** @return BelongsToMany<Attachment, $this> */
|
||||||
public function attachments(): BelongsToMany
|
public function attachments(): BelongsToMany
|
||||||
|
{
|
||||||
|
return $this->allAttachments()->wherePivot('is_enabled', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsToMany<Attachment, $this> */
|
||||||
|
public function allAttachments(): BelongsToMany
|
||||||
{
|
{
|
||||||
$relation = $this->belongsToMany(
|
$relation = $this->belongsToMany(
|
||||||
Attachment::class,
|
Attachment::class,
|
||||||
|
|||||||
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\SyncEntryVariantsRequest;
|
||||||
|
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(SyncEntryVariantsRequest $request): EntryResource
|
||||||
|
{
|
||||||
|
return new EntryResource($this->entryService->syncVariants(
|
||||||
|
$request->user()->tenant()->firstOrFail(),
|
||||||
|
$request->validated('variants'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
56
app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php
Normal file
56
app/Domains/Desfile/Requests/SyncEntryVariantsRequest.php
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Desfile\Requests;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Validator;
|
||||||
|
|
||||||
|
class SyncEntryVariantsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, mixed> */
|
||||||
|
public function rules(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'variants' => ['required', 'array', 'min:1', 'max:1000'],
|
||||||
|
'variants.*' => ['required', 'array:id,type,sector,row,seat,price'],
|
||||||
|
'variants.*.id' => ['sometimes', 'integer', 'distinct'],
|
||||||
|
'variants.*.type' => ['required', 'string', 'max:100'],
|
||||||
|
'variants.*.sector' => ['required', 'string', 'max:100'],
|
||||||
|
'variants.*.row' => ['required', 'string', 'max:100'],
|
||||||
|
'variants.*.seat' => ['required', 'string', 'max:100'],
|
||||||
|
'variants.*.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('variants', []) as $index => $variant) {
|
||||||
|
if (! is_array($variant)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$combination = collect(['type', 'sector', 'row', 'seat'])
|
||||||
|
->map(fn (string $field): string => mb_strtolower(trim((string) ($variant[$field] ?? ''))))
|
||||||
|
->implode('|');
|
||||||
|
|
||||||
|
if (isset($combinations[$combination])) {
|
||||||
|
$validator->errors()->add(
|
||||||
|
"variants.{$index}",
|
||||||
|
'La combinación de tipo, sector, fila y asiento 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'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
39
app/Domains/Desfile/Resources/EntryResource.php
Normal file
39
app/Domains/Desfile/Resources/EntryResource.php
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<?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,
|
||||||
|
'variants' => $this->variants->map(function ($variant): array {
|
||||||
|
$values = $variant->selectionValues();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $variant->id,
|
||||||
|
'type' => $values->get('tipo'),
|
||||||
|
'sector' => $values->get('sector'),
|
||||||
|
'row' => $values->get('fila'),
|
||||||
|
'seat' => $values->get('asiento'),
|
||||||
|
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
|
];
|
||||||
|
})->values(),
|
||||||
|
'image' => $image === null ? null : [
|
||||||
|
'key' => $image->key,
|
||||||
|
'filename' => $image->filename,
|
||||||
|
'url' => $image->getTemporaryUrl(1440),
|
||||||
|
'is_enabled' => (bool) $image->pivot->is_enabled,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
285
app/Domains/Desfile/Services/EntryService.php
Normal file
285
app/Domains/Desfile/Services/EntryService.php
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
<?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',
|
||||||
|
];
|
||||||
|
|
||||||
|
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>> $variants
|
||||||
|
*/
|
||||||
|
public function syncVariants(Tenant $tenant, array $variants): CatalogItem
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($tenant, $variants): void {
|
||||||
|
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
|
||||||
|
$itemAttributes = $this->itemAttributes($entry);
|
||||||
|
$existingVariants = $entry->variants()
|
||||||
|
->with(['inventory', 'definitions.itemAttribute.attribute'])
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
$incomingIds = collect($variants)
|
||||||
|
->pluck('id')
|
||||||
|
->filter()
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->values();
|
||||||
|
|
||||||
|
foreach ($existingVariants->whereNotIn('id', $incomingIds) as $variant) {
|
||||||
|
$this->assertVariantCanChangeIdentity($variant, 'variants');
|
||||||
|
$variant->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (array_values($variants) as $index => $data) {
|
||||||
|
$values = $this->resolveValues($itemAttributes, $data, $index);
|
||||||
|
$variant = isset($data['id'])
|
||||||
|
? $existingVariants->firstWhere('id', (int) $data['id'])
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (isset($data['id']) && $variant === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.id" => [
|
||||||
|
'La variante no pertenece al producto Entrada del desfile.',
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($variant === null) {
|
||||||
|
$variant = $entry->variants()->create([
|
||||||
|
'inventory_id' => Inventory::query()->create(['real_stock' => 1])->id,
|
||||||
|
'descripcion' => $this->description($values),
|
||||||
|
'precio' => $data['price'],
|
||||||
|
]);
|
||||||
|
} else {
|
||||||
|
if ($this->identityChanged($variant, $values)) {
|
||||||
|
$this->assertVariantCanChangeIdentity($variant, "variants.{$index}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->update([
|
||||||
|
'descripcion' => $this->description($values),
|
||||||
|
'precio' => $data['price'],
|
||||||
|
]);
|
||||||
|
$variant->definitions()->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
$variant->definitions()->createMany(
|
||||||
|
collect($values)->map(
|
||||||
|
fn (string $value, string $code): array => [
|
||||||
|
'item_attribute_id' => $itemAttributes[$code]->id,
|
||||||
|
'value' => $value,
|
||||||
|
],
|
||||||
|
)->values()->all(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$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([
|
||||||
|
'variants' => [
|
||||||
|
'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 resolveValues(Collection $itemAttributes, array $data, int $index): array
|
||||||
|
{
|
||||||
|
$values = [];
|
||||||
|
|
||||||
|
foreach (self::ATTRIBUTE_MAP as $input => $code) {
|
||||||
|
$requestedValue = trim((string) $data[$input]);
|
||||||
|
$option = $itemAttributes[$code]->attribute->options->first(
|
||||||
|
fn ($candidate): bool => $this->normalize($candidate->value) === $this->normalize($requestedValue),
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($option === null) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
"variants.{$index}.{$input}" => ['La opción seleccionada no es válida.'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
$values[$code] = $option->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $values;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param array<string, string> $values */
|
||||||
|
private function identityChanged(Variant $variant, array $values): bool
|
||||||
|
{
|
||||||
|
$currentValues = $variant->definitions
|
||||||
|
->mapWithKeys(fn ($definition): array => [
|
||||||
|
$definition->itemAttribute?->attribute?->codigo => $definition->value,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return collect($values)->contains(
|
||||||
|
fn (string $value, string $code): bool => $this->normalize((string) $currentValues->get($code))
|
||||||
|
!== $this->normalize($value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
<?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');
|
||||||
|
});
|
||||||
|
|||||||
258
tests/Feature/Desfile/EntryControllerTest.php
Normal file
258
tests/Feature/Desfile/EntryControllerTest.php
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
<?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_and_synchronizes_the_single_entry_product_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.variants.0.id', $existing->id)
|
||||||
|
->assertJsonPath('data.variants.0.type', 'NORMAL')
|
||||||
|
->assertJsonPath('data.variants.0.seat', '1')
|
||||||
|
->assertJsonPath('data.variants.0.price', '100000.00');
|
||||||
|
|
||||||
|
$response = $this->putJson('/api/v1/adminapp/tenant/desfile/entries', [
|
||||||
|
'variants' => [
|
||||||
|
[
|
||||||
|
'id' => $existing->id,
|
||||||
|
'type' => 'NORMAL',
|
||||||
|
'sector' => 'A',
|
||||||
|
'row' => '1',
|
||||||
|
'seat' => '1',
|
||||||
|
'price' => 120000,
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'type' => 'VIP + LUNCH',
|
||||||
|
'sector' => 'B',
|
||||||
|
'row' => '2',
|
||||||
|
'seat' => '3',
|
||||||
|
'price' => 250000,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$response
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonCount(2, 'data.variants')
|
||||||
|
->assertJsonPath('data.variants.0.price', '120000.00')
|
||||||
|
->assertJsonPath('data.variants.1.type', 'VIP + LUNCH');
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('variantes', 2);
|
||||||
|
$this->assertDatabaseHas('catalog_items', [
|
||||||
|
'id' => $entry->id,
|
||||||
|
'precio' => 120000,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $response->json('data.variants.1.id') === null
|
||||||
|
? 0
|
||||||
|
: Variant::query()->findOrFail($response->json('data.variants.1.id'))->inventory_id,
|
||||||
|
'real_stock' => 1,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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_seats_and_cross_tenant_access(): void
|
||||||
|
{
|
||||||
|
[, $entry] = $this->configuredEntry();
|
||||||
|
$foreignVariant = $this->createVariant($entry, 'NORMAL', 'A', '1', '1', 100);
|
||||||
|
[$otherTenant] = $this->configuredEntry('other-desfile');
|
||||||
|
Sanctum::actingAs($this->createAdminAppUser($otherTenant));
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/desfile/entries', [
|
||||||
|
'variants' => [[
|
||||||
|
'id' => $foreignVariant->id,
|
||||||
|
...$this->variantPayload('NORMAL', 'A', '1', '1', 100),
|
||||||
|
]],
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('variants.0.id');
|
||||||
|
|
||||||
|
$payload = [
|
||||||
|
'variants' => [
|
||||||
|
$this->variantPayload('NORMAL', 'A', '1', '1', 100),
|
||||||
|
$this->variantPayload('normal', 'A', '1', '1', 200),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->putJson('/api/v1/adminapp/tenant/desfile/entries', $payload)
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('variants.1');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @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' => ['1', '2'],
|
||||||
|
'asiento' => ['1', '2', '3'],
|
||||||
|
] 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 variantPayload(
|
||||||
|
string $type,
|
||||||
|
string $sector,
|
||||||
|
string $row,
|
||||||
|
string $seat,
|
||||||
|
int $price,
|
||||||
|
): array {
|
||||||
|
return compact('type', 'sector', 'row', 'seat', 'price');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function createAdminAppUser(Tenant $tenant): User
|
||||||
|
{
|
||||||
|
return User::factory()->create([
|
||||||
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user