feat(variants): add minimum and maximum use dates to variants and update related logic
This commit is contained in:
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|||||||
@@ -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 (
|
||||||
'tenant_code' => $item->tenant_code,
|
$catalogItem,
|
||||||
'ticket' => (string) Str::uuid(),
|
$sourceVariantId,
|
||||||
'name' => $item->nombre,
|
$user,
|
||||||
'description' => (string) ($item->descripcion ?? ''),
|
): Ticket {
|
||||||
'source_catalog_item_id' => $catalogItem->getKey(),
|
$item = $target['catalog_item'];
|
||||||
'source_variant_id' => $sourceVariantId,
|
$selectedItem = $target['variant'] ?? $item;
|
||||||
'starts_at' => $item->minimum_use_date,
|
|
||||||
'expires_at' => $item->maximum_use_date,
|
return Ticket::query()->create([
|
||||||
'used_at' => null,
|
'tenant_code' => $item->tenant_code,
|
||||||
'user_id' => $user->getKey(),
|
'ticket' => (string) Str::uuid(),
|
||||||
]));
|
'name' => $item->nombre,
|
||||||
|
'description' => (string) ($item->descripcion ?? ''),
|
||||||
|
'source_catalog_item_id' => $catalogItem->getKey(),
|
||||||
|
'source_variant_id' => $sourceVariantId,
|
||||||
|
'starts_at' => $selectedItem->getMinimumUseDate(),
|
||||||
|
'expires_at' => $selectedItem->getMaximumUseDate(),
|
||||||
|
'used_at' => null,
|
||||||
|
'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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user