feat(inventory): add InventorySubject enum and integrate into catalog item management
This commit is contained in:
16
app/Domains/Catalog/Enums/InventorySubject.php
Normal file
16
app/Domains/Catalog/Enums/InventorySubject.php
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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());
|
||||
|
||||
97
tests/Unit/Purchase/InsufficientStockMessageBuilderTest.php
Normal file
97
tests/Unit/Purchase/InsufficientStockMessageBuilderTest.php
Normal 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];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user