feat(variants): add minimum and maximum use dates to variants and update related logic

This commit is contained in:
2026-07-27 14:59:07 -03:00
parent 069867522a
commit aa4f3303fc
16 changed files with 391 additions and 28 deletions

View File

@@ -7,6 +7,7 @@ use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy; use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Services\CatalogInventoryService; use App\Domains\Catalog\Services\CatalogInventoryService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -163,6 +164,16 @@ class CatalogItem extends Model
return $this->nombre; return $this->nombre;
} }
public function getMinimumUseDate(): ?CarbonInterface
{
return $this->minimum_use_date;
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date;
}
public function isBundle(): bool public function isBundle(): bool
{ {
return $this->type === CatalogItemType::Bundle; return $this->type === CatalogItemType::Bundle;

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Catalog\Models; namespace App\Domains\Catalog\Models;
use App\Domains\Attachable\Models\Attachment; use App\Domains\Attachable\Models\Attachment;
use Carbon\CarbonInterface;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
@@ -13,6 +14,8 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable([ #[Fillable([
'catalog_item_id', 'catalog_item_id',
'inventory_id', 'inventory_id',
'minimum_use_date',
'maximum_use_date',
])] ])]
class Variant extends Model class Variant extends Model
{ {
@@ -27,6 +30,8 @@ class Variant extends Model
return [ return [
'catalog_item_id' => 'integer', 'catalog_item_id' => 'integer',
'inventory_id' => 'integer', 'inventory_id' => 'integer',
'minimum_use_date' => 'datetime',
'maximum_use_date' => 'datetime',
]; ];
} }
@@ -89,4 +94,16 @@ class Variant extends Model
return $definitions === '' ? $name : "{$name} ({$definitions})"; return $definitions === '' ? $name : "{$name} ({$definitions})";
} }
public function getMinimumUseDate(): ?CarbonInterface
{
return $this->minimum_use_date
?? $this->catalogItem->getMinimumUseDate();
}
public function getMaximumUseDate(): ?CarbonInterface
{
return $this->maximum_use_date
?? $this->catalogItem->getMaximumUseDate();
}
} }

View File

@@ -76,6 +76,13 @@ class StoreCatalogItemRequest extends FormRequest
'variants.*.inventory_id' => ['prohibited'], 'variants.*.inventory_id' => ['prohibited'],
'variants.*.reserved_stock' => ['prohibited'], 'variants.*.reserved_stock' => ['prohibited'],
'variants.*.sold_units' => ['prohibited'], 'variants.*.sold_units' => ['prohibited'],
'variants.*.minimum_use_date' => ['sometimes', 'nullable', 'date'],
'variants.*.maximum_use_date' => [
'sometimes',
'nullable',
'date',
'after_or_equal:variants.*.minimum_use_date',
],
'variants.*.values' => ['sometimes', 'array'], 'variants.*.values' => ['sometimes', 'array'],
'variants.*.values.*' => ['nullable', 'string'], 'variants.*.values.*' => ['nullable', 'string'],
'variants.*.images' => ['sometimes', 'array'], 'variants.*.images' => ['sometimes', 'array'],

View File

@@ -111,6 +111,12 @@ class CatalogItemDetailResource extends JsonResource
return [ return [
'id' => $variant->id, 'id' => $variant->id,
'stock_tecnico' => $this->variantStock($variant), 'stock_tecnico' => $this->variantStock($variant),
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions 'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [ ->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value, $definition->itemAttribute?->attribute?->codigo => $definition->value,

View File

@@ -33,6 +33,12 @@ class CatalogItemResource extends JsonResource
->map(fn ($variant) => [ ->map(fn ($variant) => [
'id' => $variant->id, 'id' => $variant->id,
'real_stock' => $variant->inventory?->real_stock, 'real_stock' => $variant->inventory?->real_stock,
'minimum_use_date' => $variant->minimum_use_date,
'maximum_use_date' => $variant->maximum_use_date,
'effective_minimum_use_date' => $variant->minimum_use_date
?? $this->minimum_use_date,
'effective_maximum_use_date' => $variant->maximum_use_date
?? $this->maximum_use_date,
'values' => $variant->definitions 'values' => $variant->definitions
->mapWithKeys(fn ($definition) => [ ->mapWithKeys(fn ($definition) => [
$definition->itemAttribute?->attribute?->codigo => $definition->value, $definition->itemAttribute?->attribute?->codigo => $definition->value,

View File

@@ -450,7 +450,12 @@ class CatalogService
$inventory = $this->createInventory((int) ($data['real_stock'] ?? 0)); $inventory = $this->createInventory((int) ($data['real_stock'] ?? 0));
$variant = $catalogItem->variants()->create([ $variant = $catalogItem->variants()->create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'minimum_use_date' => $data['minimum_use_date'] ?? null,
'maximum_use_date' => $data['maximum_use_date'] ?? null,
]); ]);
$variant->setRelation('catalogItem', $catalogItem);
$this->validateVariantUseDates($variant, $index);
foreach ($data['values'] ?? [] as $attributeCode => $value) { foreach ($data['values'] ?? [] as $attributeCode => $value) {
$itemAttribute = $itemAttributes[$attributeCode] ?? null; $itemAttribute = $itemAttributes[$attributeCode] ?? null;
@@ -472,6 +477,24 @@ class CatalogService
return $variant; return $variant;
} }
private function validateVariantUseDates(Variant $variant, int $index): void
{
$minimumUseDate = $variant->getMinimumUseDate();
$maximumUseDate = $variant->getMaximumUseDate();
if (
$minimumUseDate !== null
&& $maximumUseDate !== null
&& $maximumUseDate->lessThan($minimumUseDate)
) {
throw ValidationException::withMessages([
"variants.{$index}.maximum_use_date" => [
'La fecha maxima efectiva debe ser posterior o igual a la fecha minima efectiva.',
],
]);
}
}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @param array<int, array<string, mixed>> $variants * @param array<int, array<string, mixed>> $variants

View File

@@ -29,6 +29,15 @@ class TicketGenerationException extends RuntimeException
return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso."); return new self("El producto {$catalogItem->id} alcanzó su fecha máxima de uso.");
} }
public static function variantNotFound(
CatalogItem $catalogItem,
int $variantId,
): self {
return new self(
"La variante {$variantId} no pertenece al producto {$catalogItem->id}.",
);
}
public static function purchaseWithoutUser(Purchase $purchase): self public static function purchaseWithoutUser(Purchase $purchase): self
{ {
return new self("La compra {$purchase->id} no tiene un usuario asociado."); return new self("La compra {$purchase->id} no tiene un usuario asociado.");

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Ticket\Services;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Ticket\Exceptions\TicketGenerationException; use App\Domains\Ticket\Exceptions\TicketGenerationException;
use App\Domains\Ticket\Models\Ticket; use App\Domains\Ticket\Models\Ticket;
use Carbon\CarbonInterface; use Carbon\CarbonInterface;
@@ -29,38 +30,60 @@ class TicketGeneratorService
$now = now(); $now = now();
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $now): Collection { return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $now): Collection {
$catalogItems = $this->resolveCatalogItems($catalogItem, $quantity, $now); $targets = $this->resolveTargets(
$catalogItem,
$quantity,
$sourceVariantId,
$now,
);
return $catalogItems->map(fn (CatalogItem $item): Ticket => Ticket::query()->create([ return $targets->map(function (array $target) use (
$catalogItem,
$sourceVariantId,
$user,
): Ticket {
$item = $target['catalog_item'];
$selectedItem = $target['variant'] ?? $item;
return Ticket::query()->create([
'tenant_code' => $item->tenant_code, 'tenant_code' => $item->tenant_code,
'ticket' => (string) Str::uuid(), 'ticket' => (string) Str::uuid(),
'name' => $item->nombre, 'name' => $item->nombre,
'description' => (string) ($item->descripcion ?? ''), 'description' => (string) ($item->descripcion ?? ''),
'source_catalog_item_id' => $catalogItem->getKey(), 'source_catalog_item_id' => $catalogItem->getKey(),
'source_variant_id' => $sourceVariantId, 'source_variant_id' => $sourceVariantId,
'starts_at' => $item->minimum_use_date, 'starts_at' => $selectedItem->getMinimumUseDate(),
'expires_at' => $item->maximum_use_date, 'expires_at' => $selectedItem->getMaximumUseDate(),
'used_at' => null, 'used_at' => null,
'user_id' => $user->getKey(), 'user_id' => $user->getKey(),
])); ]);
});
}); });
} }
/** /**
* @return Collection<int, CatalogItem> * @return Collection<int, array{catalog_item: CatalogItem, variant: Variant|null}>
*/ */
private function resolveCatalogItems( private function resolveTargets(
CatalogItem $catalogItem, CatalogItem $catalogItem,
int $quantity, int $quantity,
?int $sourceVariantId,
CarbonInterface $now, CarbonInterface $now,
): Collection { ): Collection {
if (! $catalogItem->isBundle()) { if (! $catalogItem->isBundle()) {
$this->validateCatalogItem($catalogItem, $now); $variant = $this->resolveVariant($catalogItem, $sourceVariantId);
$this->validateTarget($catalogItem, $variant, $now);
return Collection::times($quantity, fn (): CatalogItem => $catalogItem); return Collection::times($quantity, fn (): array => [
'catalog_item' => $catalogItem,
'variant' => $variant,
]);
} }
$catalogItem->loadMissing('bundleComponents.catalogItem'); $catalogItem->loadMissing([
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
]);
if ($catalogItem->bundleComponents->isEmpty()) { if ($catalogItem->bundleComponents->isEmpty()) {
throw TicketGenerationException::emptyBundle($catalogItem); throw TicketGenerationException::emptyBundle($catalogItem);
@@ -69,23 +92,56 @@ class TicketGeneratorService
return $catalogItem->bundleComponents return $catalogItem->bundleComponents
->flatMap(function ($component) use ($quantity, $now): Collection { ->flatMap(function ($component) use ($quantity, $now): Collection {
$componentItem = $component->catalogItem; $componentItem = $component->catalogItem;
$this->validateCatalogItem($componentItem, $now); $variant = $component->variant;
$this->validateTarget($componentItem, $variant, $now);
return Collection::times( return Collection::times(
$quantity * $component->quantity, $quantity * $component->quantity,
fn (): CatalogItem => $componentItem, fn (): array => [
'catalog_item' => $componentItem,
'variant' => $variant,
],
); );
}) })
->values(); ->values();
} }
private function validateCatalogItem(CatalogItem $catalogItem, CarbonInterface $now): void private function resolveVariant(
{ CatalogItem $catalogItem,
?int $sourceVariantId,
): ?Variant {
if ($sourceVariantId === null) {
return null;
}
$variant = $catalogItem->variants()
->whereKey($sourceVariantId)
->first();
if ($variant === null) {
throw TicketGenerationException::variantNotFound(
$catalogItem,
$sourceVariantId,
);
}
$variant->setRelation('catalogItem', $catalogItem);
return $variant;
}
private function validateTarget(
CatalogItem $catalogItem,
?Variant $variant,
CarbonInterface $now,
): void {
if (! $catalogItem->has_tickets) { if (! $catalogItem->has_tickets) {
throw TicketGenerationException::ticketsDisabled($catalogItem); throw TicketGenerationException::ticketsDisabled($catalogItem);
} }
if ($catalogItem->maximum_use_date?->lessThanOrEqualTo($now)) { $selectedItem = $variant ?? $catalogItem;
if ($selectedItem->getMaximumUseDate()?->lessThanOrEqualTo($now)) {
throw TicketGenerationException::maximumUseDateReached($catalogItem); throw TicketGenerationException::maximumUseDateReached($catalogItem);
} }
} }

View File

@@ -0,0 +1,26 @@
<?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('variantes', function (Blueprint $table): void {
$table->dateTime('minimum_use_date')->nullable()->after('inventory_id');
$table->dateTime('maximum_use_date')->nullable()->after('minimum_use_date');
});
}
public function down(): void
{
Schema::table('variantes', function (Blueprint $table): void {
$table->dropColumn([
'minimum_use_date',
'maximum_use_date',
]);
});
}
};

View File

@@ -38,6 +38,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
]); ]);
$dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12']; $dates = ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'];
$minimumUseDate = $dates[0].' 00:00:00';
$maximumUseDate = $dates[array_key_last($dates)].' 23:59:59';
$generalAdmission = $this->catalogService->create([ $generalAdmission = $this->catalogService->create([
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
@@ -48,12 +50,14 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'precio' => 10000, 'precio' => 10000,
'inventory_policy' => InventoryPolicy::Unlimited->value, 'inventory_policy' => InventoryPolicy::Unlimited->value,
'has_tickets' => true, 'has_tickets' => true,
'minimum_use_date' => $dates[0].' 00:00:00', 'minimum_use_date' => $minimumUseDate,
'maximum_use_date' => $dates[array_key_last($dates)].' 23:59:59', 'maximum_use_date' => $maximumUseDate,
'attribute_codes' => ['fecha'], 'attribute_codes' => ['fecha'],
'variants' => array_map( 'variants' => array_map(
fn (string $date): array => [ fn (string $date): array => [
'real_stock' => 0, 'real_stock' => 0,
'minimum_use_date' => $date.' 00:00:00',
'maximum_use_date' => $date.' 23:59:59',
'values' => ['fecha' => $date], 'values' => ['fecha' => $date],
], ],
$dates, $dates,
@@ -76,6 +80,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
'descripcion' => $item['descripcion'] ?? $item['nombre'], 'descripcion' => $item['descripcion'] ?? $item['nombre'],
'inventory_policy' => InventoryPolicy::Unlimited->value, 'inventory_policy' => InventoryPolicy::Unlimited->value,
'real_stock' => 0, 'real_stock' => 0,
'minimum_use_date' => $minimumUseDate,
'maximum_use_date' => $maximumUseDate,
...$item, ...$item,
]); ]);
} }

View File

@@ -32,11 +32,14 @@ class CatalogItemControllerTest extends TestCase
'slug' => 'shirt', 'slug' => 'shirt',
'nombre' => 'Shirt', 'nombre' => 'Shirt',
'precio' => 100, 'precio' => 100,
'minimum_use_date' => '2026-08-01 09:00:00',
'maximum_use_date' => '2026-08-31 18:00:00',
'attribute_codes' => [$attribute->codigo], 'attribute_codes' => [$attribute->codigo],
'images' => [$image, $image], 'images' => [$image, $image],
'variants' => [ 'variants' => [
[ [
'real_stock' => 5, 'real_stock' => 5,
'maximum_use_date' => '2026-08-15 18:00:00',
'values' => ['size' => 'M'], 'values' => ['size' => 'M'],
'images' => [$image], 'images' => [$image],
], ],
@@ -48,7 +51,20 @@ class CatalogItemControllerTest extends TestCase
->assertJsonPath('data.nombre', 'Shirt') ->assertJsonPath('data.nombre', 'Shirt')
->assertJsonCount(2, 'data.images') ->assertJsonCount(2, 'data.images')
->assertJsonCount(1, 'data.variants') ->assertJsonCount(1, 'data.variants')
->assertJsonCount(1, 'data.variants.0.images'); ->assertJsonCount(1, 'data.variants.0.images')
->assertJsonPath('data.variants.0.minimum_use_date', null)
->assertJsonPath(
'data.variants.0.maximum_use_date',
fn (string $value): bool => str_starts_with($value, '2026-08-15T18:00:00'),
)
->assertJsonPath(
'data.variants.0.effective_minimum_use_date',
fn (string $value): bool => str_starts_with($value, '2026-08-01T09:00:00'),
)
->assertJsonPath(
'data.variants.0.effective_maximum_use_date',
fn (string $value): bool => str_starts_with($value, '2026-08-15T18:00:00'),
);
$item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail(); $item = CatalogItem::query()->where('slug', 'shirt')->firstOrFail();
$variant = $item->variants()->firstOrFail(); $variant = $item->variants()->firstOrFail();

View File

@@ -174,4 +174,12 @@ class CatalogSchemaTest extends TestCase
'orden' => 3, 'orden' => 3,
]); ]);
} }
public function test_variants_can_override_catalog_item_use_dates(): void
{
$this->assertTrue(Schema::hasColumns('variantes', [
'minimum_use_date',
'maximum_use_date',
]));
}
} }

View File

@@ -13,6 +13,7 @@ use App\Domains\Catalog\Services\CatalogService;
use App\Domains\Shared\Enums\FieldType; use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Carbon;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Tests\TestCase; use Tests\TestCase;
@@ -93,6 +94,73 @@ class CatalogServiceTest extends TestCase
} }
} }
public function test_variant_use_dates_override_or_inherit_catalog_item_dates(): void
{
$attribute = $this->createAttribute('day');
$itemMinimum = Carbon::parse('2026-08-01 09:00:00');
$itemMaximum = Carbon::parse('2026-08-31 18:00:00');
$variantMaximum = Carbon::parse('2026-08-15 18:00:00');
$item = $this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'dated-variants',
'nombre' => 'Dated variants',
'precio' => 100,
'minimum_use_date' => $itemMinimum,
'maximum_use_date' => $itemMaximum,
'attribute_codes' => [$attribute->codigo],
'variants' => [
[
'real_stock' => 5,
'maximum_use_date' => $variantMaximum,
'values' => [$attribute->codigo => 'Saturday'],
],
],
]);
$variant = $item->variants->firstOrFail();
$this->assertNull($variant->minimum_use_date);
$this->assertTrue($variant->maximum_use_date->equalTo($variantMaximum));
$this->assertTrue($variant->getMinimumUseDate()->equalTo($itemMinimum));
$this->assertTrue($variant->getMaximumUseDate()->equalTo($variantMaximum));
}
public function test_it_rejects_an_invalid_effective_variant_use_date_range(): void
{
$attribute = $this->createAttribute('day');
try {
$this->service->create([
'tenant_code' => $this->tenant->codigo,
'slug' => 'invalid-dated-variant',
'nombre' => 'Invalid dated variant',
'precio' => 100,
'minimum_use_date' => '2026-08-10 09:00:00',
'maximum_use_date' => '2026-08-31 18:00:00',
'attribute_codes' => [$attribute->codigo],
'variants' => [
[
'real_stock' => 5,
'maximum_use_date' => '2026-08-09 18:00:00',
'values' => [$attribute->codigo => 'Saturday'],
],
],
]);
$this->fail('A validation exception was not thrown.');
} catch (ValidationException $exception) {
$this->assertArrayHasKey(
'variants.0.maximum_use_date',
$exception->errors(),
);
}
$this->assertDatabaseMissing('catalog_items', [
'slug' => 'invalid-dated-variant',
]);
}
public function test_it_rejects_direct_inventory_together_with_variants(): void public function test_it_rejects_direct_inventory_together_with_variants(): void
{ {
$attribute = $this->createAttribute('size'); $attribute = $this->createAttribute('size');

View File

@@ -80,6 +80,39 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'], ['2026-10-09', '2026-10-10', '2026-10-11', '2026-10-12'],
$generalAdmission->variants->map(fn ($variant) => $variant->definitions->sole()->value)->all() $generalAdmission->variants->map(fn ($variant) => $variant->definitions->sole()->value)->all()
); );
$this->assertSame(
[
['2026-10-09 00:00:00', '2026-10-09 23:59:59'],
['2026-10-10 00:00:00', '2026-10-10 23:59:59'],
['2026-10-11 00:00:00', '2026-10-11 23:59:59'],
['2026-10-12 00:00:00', '2026-10-12 23:59:59'],
],
$generalAdmission->variants
->sortBy(fn ($variant) => $variant->definitions->sole()->value)
->map(fn ($variant): array => [
$variant->minimum_use_date->format('Y-m-d H:i:s'),
$variant->maximum_use_date->format('Y-m-d H:i:s'),
])
->values()
->all()
);
$standardItems = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('type', CatalogItemType::Standard->value)
->get();
$this->assertCount(7, $standardItems);
foreach ($standardItems as $standardItem) {
$this->assertSame(
'2026-10-09 00:00:00',
$standardItem->minimum_use_date->format('Y-m-d H:i:s'),
);
$this->assertSame(
'2026-10-12 23:59:59',
$standardItem->maximum_use_date->format('Y-m-d H:i:s'),
);
}
$allDaysItem = CatalogItem::query() $allDaysItem = CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)

View File

@@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment;
use App\Domains\Auth\Models\User; use App\Domains\Auth\Models\User;
use App\Domains\Catalog\Enums\CatalogItemType; use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Models\CatalogItem; use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Notification\Events\TicketsAvailable; use App\Domains\Notification\Events\TicketsAvailable;
use App\Domains\Purchase\Models\Purchase; use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
@@ -93,6 +94,27 @@ class TicketGeneratorServiceTest extends TestCase
$this->service->generate($item, $this->user); $this->service->generate($item, $this->user);
} }
public function test_variant_dates_override_and_inherit_catalog_item_dates(): void
{
$item = $this->createTicketableItem(
'variant-dates',
now()->subDay(),
now()->addMonth(),
);
$inventory = Inventory::query()->create();
$variant = $item->variants()->create([
'inventory_id' => $inventory->id,
'maximum_use_date' => now()->addWeek(),
]);
$ticket = $this->service
->generate($item, $this->user, sourceVariantId: $variant->id)
->firstOrFail();
$this->assertTrue($ticket->starts_at->equalTo($item->minimum_use_date));
$this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date));
}
public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void
{ {
$first = $this->createTicketableItem('first', maximumUseDate: now()->addDay()); $first = $this->createTicketableItem('first', maximumUseDate: now()->addDay());
@@ -111,6 +133,34 @@ class TicketGeneratorServiceTest extends TestCase
$this->assertCount(2, $tickets->where('name', $second->nombre)); $this->assertCount(2, $tickets->where('name', $second->nombre));
} }
public function test_bundle_component_uses_its_variant_dates(): void
{
$component = $this->createTicketableItem(
'variant-component',
now()->subDay(),
now()->addMonth(),
);
$inventory = Inventory::query()->create();
$variant = $component->variants()->create([
'inventory_id' => $inventory->id,
'minimum_use_date' => now()->addDay(),
'maximum_use_date' => now()->addWeek(),
]);
$bundle = $this->createBundle('variant-bundle');
$bundle->bundleComponents()->create([
'component_catalog_item_id' => $component->id,
'component_variant_id' => $variant->id,
'quantity' => 1,
]);
$ticket = $this->service
->generate($bundle, $this->user)
->firstOrFail();
$this->assertTrue($ticket->starts_at->equalTo($variant->minimum_use_date));
$this->assertTrue($ticket->expires_at->equalTo($variant->maximum_use_date));
}
public function test_bundle_generation_is_rolled_back_when_a_component_is_invalid(): void public function test_bundle_generation_is_rolled_back_when_a_component_is_invalid(): void
{ {
$valid = $this->createTicketableItem('valid'); $valid = $this->createTicketableItem('valid');
@@ -152,13 +202,15 @@ class TicketGeneratorServiceTest extends TestCase
public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void
{ {
$item = $this->createTicketableItem('sourced-ticket'); $item = $this->createTicketableItem('sourced-ticket');
$purchase = $this->createPurchase($item, 1, 1234); $inventory = Inventory::query()->create();
$variant = $item->variants()->create(['inventory_id' => $inventory->id]);
$purchase = $this->createPurchase($item, 1, $variant->id);
$purchase->markAsPaid(); $purchase->markAsPaid();
$this->assertDatabaseHas('tickets', [ $this->assertDatabaseHas('tickets', [
'source_catalog_item_id' => $item->id, 'source_catalog_item_id' => $item->id,
'source_variant_id' => 1234, 'source_variant_id' => $variant->id,
]); ]);
} }

View File

@@ -22,6 +22,7 @@ use App\Domains\Catalog\Models\VariantDefinition;
use App\Domains\Shared\Enums\FieldType; use App\Domains\Shared\Enums\FieldType;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Collection as EloquentCollection; use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use Illuminate\Support\Carbon;
use Tests\TestCase; use Tests\TestCase;
class CatalogModelsTest extends TestCase class CatalogModelsTest extends TestCase
@@ -136,6 +137,24 @@ class CatalogModelsTest extends TestCase
$this->assertSame('catalog_items_attachments', $variant->attachments()->getTable()); $this->assertSame('catalog_items_attachments', $variant->attachments()->getTable());
} }
public function test_variant_use_dates_override_or_inherit_catalog_item_dates(): void
{
$item = new CatalogItem;
$item->minimum_use_date = Carbon::parse('2026-08-01 09:00:00');
$item->maximum_use_date = Carbon::parse('2026-08-31 18:00:00');
$variant = new Variant;
$variant->maximum_use_date = Carbon::parse('2026-08-15 18:00:00');
$variant->setRelation('catalogItem', $item);
$this->assertTrue(
$variant->getMinimumUseDate()->equalTo($item->minimum_use_date),
);
$this->assertTrue(
$variant->getMaximumUseDate()->equalTo($variant->maximum_use_date),
);
}
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void public function test_inventory_maps_stock_without_a_polymorphic_owner(): void
{ {
$inventory = $this->trackedInventory(realStock: 10, reservedStock: 3); $inventory = $this->trackedInventory(realStock: 10, reservedStock: 3);