feat(inventory): add InventorySubject enum and integrate into catalog item management

This commit is contained in:
2026-08-12 14:42:30 -03:00
parent 5b089e71b2
commit 8e94cf7856
13 changed files with 280 additions and 6 deletions

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\Catalog\Enums;
enum InventorySubject: string
{
case Product = 'product';
case Seat = 'seat';
case Ticket = 'ticket';
/** @return array<int, string> */
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@@ -5,6 +5,7 @@ namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
@@ -30,6 +31,7 @@ use Illuminate\Support\Collection;
'descripcion',
'precio',
'inventory_policy',
'inventory_subject',
'max_units_per_user',
'has_tickets',
'ticket_generation_policy',
@@ -46,6 +48,7 @@ class CatalogItem extends Model
protected $attributes = [
'type' => CatalogItemType::Standard->value,
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Product->value,
'has_tickets' => false,
'ticket_generation_policy' => TicketGenerationPolicy::PerEventDate->value,
];
@@ -59,6 +62,7 @@ class CatalogItem extends Model
'type' => CatalogItemType::class,
'precio' => 'decimal:2',
'inventory_policy' => InventoryPolicy::class,
'inventory_subject' => InventorySubject::class,
'max_units_per_user' => 'integer',
'has_tickets' => 'boolean',
'ticket_generation_policy' => TicketGenerationPolicy::class,
@@ -208,6 +212,11 @@ class CatalogItem extends Model
return $this->nombre;
}
public function getSelectionLabel(): string
{
return $this->getName();
}
public function getDescription(): ?string
{
return $this->descripcion;

View File

@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Lang;
use Illuminate\Support\Str;
#[Fillable([
'catalog_item_id',
@@ -113,6 +115,42 @@ class Variant extends Model
return $this->catalogItem->nombre;
}
public function getSelectionLabel(): string
{
$this->loadMissing([
'catalogItem.itemAttributes.attribute.options',
'definitions.itemAttribute.attribute.options',
'eventDates',
'eventDate',
]);
$itemAttributes = $this->catalogItem->itemAttributes;
$label = $this->selectionOptions($itemAttributes)
->map(function (array $option, string $attributeCode) use ($itemAttributes): ?string {
$itemAttribute = $itemAttributes->first(
fn (ItemAttribute $candidate): bool => $candidate->attribute?->codigo === $attributeCode,
);
$translationKey = "api.catalog.attribute_labels.{$attributeCode}";
$attributeName = Lang::has($translationKey)
? __($translationKey)
: ($itemAttribute?->attribute?->nombre ?? Str::headline($attributeCode));
$selectedOptions = array_is_list($option) ? $option : [$option];
$selectedLabels = collect($selectedOptions)
->pluck('label')
->filter()
->implode(', ');
return $selectedLabels === ''
? null
: "{$attributeName} {$selectedLabels}";
})
->filter()
->implode(' · ');
return $label !== '' ? $label : $this->getName();
}
/** @return Collection<string, string|array<int, string>> */
public function selectionValues(): Collection
{

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Catalog\Requests;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Shared\Rules\ImageOrBase64Rule;
use App\Domains\Ticket\Enums\TicketGenerationPolicy;
use Illuminate\Foundation\Http\FormRequest;
@@ -54,6 +55,7 @@ class StoreCatalogItemRequest extends FormRequest
'descripcion' => ['sometimes', 'nullable', 'string'],
'precio' => ['required', 'numeric', 'min:0'],
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
'inventory_subject' => ['sometimes', Rule::enum(InventorySubject::class)],
'max_units_per_user' => ['sometimes', 'nullable', 'integer', 'min:1'],
'has_tickets' => [Rule::prohibitedIf($isBundle), 'sometimes', 'boolean'],
'ticket_generation_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(TicketGenerationPolicy::class)],

View File

@@ -33,6 +33,7 @@ class CatalogItemDetailResource extends JsonResource
'category' => $this->category?->nombre,
'brand' => $this->brand?->nombre,
'inventory_policy' => $this->inventory_policy?->value,
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'ticket_generation_policy' => $this->ticket_generation_policy->value,

View File

@@ -23,6 +23,7 @@ class CatalogItemResource extends JsonResource
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'inventory_policy' => $this->inventory_policy?->value,
'inventory_subject' => $this->inventory_subject->value,
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'ticket_generation_policy' => $this->ticket_generation_policy->value,

View File

@@ -0,0 +1,43 @@
<?php
namespace App\Domains\Purchase\Services\Checkout;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
class InsufficientStockMessageBuilder
{
public function build(
CatalogItem $catalogItem,
CatalogItem|Variant $selection,
int $availableQuantity,
): string {
return match ($catalogItem->inventory_subject) {
InventorySubject::Seat => __('api.purchase.stock.seat_unavailable', [
'selection' => $selection->getSelectionLabel(),
]),
InventorySubject::Ticket => __('api.purchase.stock.ticket_unavailable', [
'selection' => $selection->getSelectionLabel(),
]),
InventorySubject::Product => __('api.purchase.stock.product_unavailable', [
'selection' => $this->productSelectionLabel($catalogItem, $selection),
'max' => $availableQuantity,
]),
};
}
private function productSelectionLabel(
CatalogItem $catalogItem,
CatalogItem|Variant $selection,
): string {
if ($selection instanceof CatalogItem) {
return $selection->getSelectionLabel();
}
return __('api.purchase.stock.product_selection', [
'product' => $catalogItem->getName(),
'selection' => $selection->getSelectionLabel(),
]);
}
}

View File

@@ -22,6 +22,7 @@ class StartCheckoutService
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly CatalogSelectionResolver $selections,
private readonly PurchaseItemSnapshotFactory $snapshots,
private readonly InsufficientStockMessageBuilder $stockMessages,
) {}
/** @param array<string, mixed> $purchaseData */
@@ -128,17 +129,25 @@ class StartCheckoutService
if ($availableQuantity !== null && $availableQuantity < $line['quantity']) {
throw ValidationException::withMessages([
"{$line['field']}.cantidad" => __('api.purchase.direct_items_max_stock', [
'max' => $availableQuantity,
]),
"{$line['field']}.cantidad" => $this->stockMessages->build(
$line['catalog_item'],
$line['selection'],
$availableQuantity,
),
]);
}
try {
$this->inventory->reserve($line['selection'], $line['quantity']);
} catch (\InvalidArgumentException) {
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
throw ValidationException::withMessages([
"{$line['field']}.cantidad" => __('api.purchase.insufficient_stock'),
"{$line['field']}.cantidad" => $this->stockMessages->build(
$line['catalog_item'],
$line['selection'],
$availableQuantity,
),
]);
}
}

View File

@@ -0,0 +1,31 @@
<?php
use App\Domains\Catalog\Enums\InventorySubject;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->enum('inventory_subject', InventorySubject::values())
->default(InventorySubject::Product->value)
->after('inventory_policy');
});
DB::table('catalog_items')
->where('tenant_code', 'desfile_pura_tendencia')
->where('slug', 'entrada')
->update(['inventory_subject' => InventorySubject::Seat->value]);
}
public function down(): void
{
Schema::table('catalog_items', function (Blueprint $table): void {
$table->dropColumn('inventory_subject');
});
}
};

View File

@@ -42,7 +42,12 @@ return [
'cannot_confirm' => 'A cancelled, rejected, or expired purchase cannot be confirmed.',
'inconsistent_reservation' => 'The purchase has an inconsistent stock reservation.',
'paid_cannot_cancel' => 'A paid purchase cannot be cancelled.',
'direct_items_max_stock' => 'There is not enough stock. Maximum available: :max.',
'stock' => [
'seat_unavailable' => 'Seat :selection is no longer available.',
'ticket_unavailable' => 'Ticket :selection is no longer available.',
'product_unavailable' => 'There is not enough stock for :selection. Maximum available: :max.',
'product_selection' => ':product (:selection)',
],
'empty_cart' => 'The selected cart does not contain items.',
'catalog_item_missing' => 'One or more catalog items could not be loaded.',
'catalog_item_wrong_tenant' => 'One or more catalog items do not belong to the tenant.',
@@ -80,6 +85,13 @@ return [
'test_sent' => 'Test email sent successfully.',
],
'catalog' => [
'attribute_labels' => [
'tipo' => 'Type',
'sector' => 'Sector',
'fila' => 'Row',
'asiento' => 'Seat',
'event_date' => 'Date',
],
'standard_with_components' => 'A standard item cannot have components.',
'duplicate_component' => 'The component is duplicated.',
'component_wrong_tenant' => 'The item does not belong to the bundle tenant.',

View File

@@ -42,7 +42,12 @@ return [
'cannot_confirm' => 'Una compra cancelada, rechazada o vencida no se puede confirmar.',
'inconsistent_reservation' => 'La compra tiene una reserva de stock inconsistente.',
'paid_cannot_cancel' => 'Una compra pagada no se puede cancelar.',
'direct_items_max_stock' => 'Stock insuficiente. Máximo disponible: :max.',
'stock' => [
'seat_unavailable' => 'El asiento :selection ya no está disponible.',
'ticket_unavailable' => 'La entrada :selection ya no está disponible.',
'product_unavailable' => 'No hay stock suficiente de :selection. Máximo disponible: :max.',
'product_selection' => ':product (:selection)',
],
'empty_cart' => 'El carrito seleccionado no contiene productos.',
'catalog_item_missing' => 'No se pudieron cargar uno o más productos del catálogo.',
'catalog_item_wrong_tenant' => 'Uno o más productos no pertenecen al tenant.',
@@ -80,6 +85,13 @@ return [
'test_sent' => 'Correo de prueba enviado correctamente.',
],
'catalog' => [
'attribute_labels' => [
'tipo' => 'Tipo',
'sector' => 'Sector',
'fila' => 'Fila',
'asiento' => 'Asiento',
'event_date' => 'Fecha',
],
'standard_with_components' => 'Un ítem standard no puede tener componentes.',
'duplicate_component' => 'El componente está duplicado.',
'component_wrong_tenant' => 'El ítem no pertenece al tenant del bundle.',

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\FeaturedGroupSource;
use App\Domains\Catalog\Enums\GroupLayout;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Enums\ProductLayout;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
@@ -89,6 +90,7 @@ class CatalogModelsTest extends TestCase
'type' => CatalogItemType::Standard->value,
'precio' => '12.50',
'inventory_policy' => InventoryPolicy::Tracked->value,
'inventory_subject' => InventorySubject::Seat->value,
'has_tickets' => 1,
]);
@@ -100,6 +102,7 @@ class CatalogModelsTest extends TestCase
$this->assertSame(CatalogItemType::Standard, $item->type);
$this->assertSame('12.50', $item->precio);
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
$this->assertSame(InventorySubject::Seat, $item->inventory_subject);
$this->assertTrue($item->has_tickets);
$this->assertInstanceOf(Tenant::class, $item->tenant()->getRelated());
$this->assertInstanceOf(Category::class, $item->category()->getRelated());

View File

@@ -0,0 +1,97 @@
<?php
namespace Tests\Unit\Purchase;
use App\Domains\Catalog\Enums\InventorySubject;
use App\Domains\Catalog\Models\Attribute;
use App\Domains\Catalog\Models\AttributeOption;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\ItemAttribute;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Catalog\Models\VariantDefinition;
use App\Domains\Purchase\Services\Checkout\InsufficientStockMessageBuilder;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Facades\App;
use Tests\TestCase;
class InsufficientStockMessageBuilderTest extends TestCase
{
public function test_it_builds_a_localized_seat_message_from_the_variant_selection(): void
{
[$catalogItem, $variant] = $this->seatSelection();
$builder = app(InsufficientStockMessageBuilder::class);
App::setLocale('es');
$this->assertSame(
'Sector A · Fila 2 · Asiento 3',
$variant->getSelectionLabel(),
);
$this->assertSame(
'El asiento Sector A · Fila 2 · Asiento 3 ya no está disponible.',
$builder->build($catalogItem, $variant, 0),
);
App::setLocale('en');
$this->assertSame(
'Sector A · Row 2 · Seat 3',
$variant->getSelectionLabel(),
);
$this->assertSame(
'Seat Sector A · Row 2 · Seat 3 is no longer available.',
$builder->build($catalogItem, $variant, 0),
);
}
/** @return array{CatalogItem, Variant} */
private function seatSelection(): array
{
$catalogItem = new CatalogItem([
'nombre' => 'Entrada',
'inventory_subject' => InventorySubject::Seat->value,
]);
$itemAttributes = collect([
['id' => 1, 'code' => 'sector', 'name' => 'Sector', 'value' => 'A'],
['id' => 2, 'code' => 'fila', 'name' => 'Fila', 'value' => '2'],
['id' => 3, 'code' => 'asiento', 'name' => 'Asiento', 'value' => '3'],
])->map(function (array $data): ItemAttribute {
$attribute = new Attribute([
'codigo' => $data['code'],
'nombre' => $data['name'],
]);
$attribute->setRelation('options', new EloquentCollection([
new AttributeOption([
'value' => $data['value'],
'label' => $data['value'],
]),
]));
$itemAttribute = new ItemAttribute([
'sort_order' => $data['id'],
'allow_multi_select' => false,
]);
$itemAttribute->id = $data['id'];
$itemAttribute->setRelation('attribute', $attribute);
return $itemAttribute;
})->values();
$catalogItem->setRelation('itemAttributes', new EloquentCollection($itemAttributes));
$definitions = $itemAttributes->map(function (ItemAttribute $itemAttribute): VariantDefinition {
$value = $itemAttribute->attribute->options->first()->value;
$definition = new VariantDefinition(['value' => $value]);
$definition->item_attribute_id = $itemAttribute->id;
$definition->setRelation('itemAttribute', $itemAttribute);
return $definition;
});
$variant = new Variant;
$variant->setRelation('catalogItem', $catalogItem);
$variant->setRelation('definitions', new EloquentCollection($definitions));
$variant->setRelation('eventDates', new EloquentCollection);
$variant->setRelation('eventDate', null);
return [$catalogItem, $variant];
}
}