Files
shopit-back/app/Domains/Desfile/Services/EntryService.php

303 lines
10 KiB
PHP

<?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);
}
}
}