Compare commits
10 Commits
4aaa66dc38
...
a59b96dd22
| Author | SHA1 | Date | |
|---|---|---|---|
| a59b96dd22 | |||
| 6354e26620 | |||
| 69320c54d4 | |||
| 5bfc8b9f70 | |||
| 17f4cb6502 | |||
| 6844ab7af9 | |||
| 8542c03466 | |||
| e7c8807a67 | |||
| c6351d706f | |||
| 2693df2cbf |
@@ -54,16 +54,26 @@ class CartController extends Controller
|
||||
Tenant $tenant,
|
||||
CartItem $cartItem,
|
||||
): CartResource {
|
||||
$updatesVariant = $request->exists('variant_id');
|
||||
|
||||
return CartResource::make(
|
||||
$this->cartService->updateItemQuantity(
|
||||
$this->cartService->updateItem(
|
||||
$tenant,
|
||||
$request,
|
||||
$cartItem->getKey(),
|
||||
(int) $request->validated('cantidad'),
|
||||
$updatesVariant
|
||||
? ($request->validated('variant_id') !== null
|
||||
? (int) $request->validated('variant_id')
|
||||
: null)
|
||||
: $cartItem->variant_id,
|
||||
$updatesVariant,
|
||||
)
|
||||
)->additional([
|
||||
'code' => 'cart.quantity_updated',
|
||||
'message' => __('api.cart.quantity_updated'),
|
||||
'code' => $updatesVariant ? 'cart.item_updated' : 'cart.quantity_updated',
|
||||
'message' => $updatesVariant
|
||||
? __('api.cart.item_updated')
|
||||
: __('api.cart.quantity_updated'),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -124,27 +124,85 @@ class Cart extends Model
|
||||
});
|
||||
}
|
||||
|
||||
public function updateItem(int $cartItemId, int $quantity): CartItem
|
||||
{
|
||||
public function updateItem(
|
||||
int $cartItemId,
|
||||
int $quantity,
|
||||
?int $variantId = null,
|
||||
bool $updateVariant = false,
|
||||
): CartItem {
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($cartItemId, $quantity): CartItem {
|
||||
return DB::transaction(function () use (
|
||||
$cartItemId,
|
||||
$quantity,
|
||||
$variantId,
|
||||
$updateVariant,
|
||||
): CartItem {
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('id', $cartItemId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$selectedItem = $this->resolveScopedItem(
|
||||
$currentSelection = $this->resolveScopedItem(
|
||||
$item->catalog_item_id,
|
||||
$item->variant_id,
|
||||
true,
|
||||
);
|
||||
$inventoryService = app(CatalogInventoryService::class);
|
||||
|
||||
if ($updateVariant && $variantId !== $item->variant_id) {
|
||||
$nextSelection = $this->resolveScopedItem(
|
||||
$item->catalog_item_id,
|
||||
$variantId,
|
||||
true,
|
||||
);
|
||||
$otherVariantsQuantity = (int) $this->items()
|
||||
->where('catalog_item_id', $item->catalog_item_id)
|
||||
->whereKeyNot($item->getKey())
|
||||
->sum('cantidad');
|
||||
$this->assertUserPurchaseLimit(
|
||||
$nextSelection,
|
||||
$otherVariantsQuantity + $quantity,
|
||||
);
|
||||
|
||||
$inventoryService->release($currentSelection, $item->cantidad);
|
||||
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
|
||||
]);
|
||||
}
|
||||
|
||||
$targetItem = $this->items()
|
||||
->where('catalog_item_id', $item->catalog_item_id)
|
||||
->where('variant_id', $variantId)
|
||||
->whereKeyNot($item->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
$inventoryService->reserve($nextSelection, $quantity);
|
||||
|
||||
if ($targetItem !== null) {
|
||||
$targetItem->cantidad += $quantity;
|
||||
$targetItem->save();
|
||||
$item->delete();
|
||||
|
||||
return $targetItem->fresh();
|
||||
}
|
||||
|
||||
$item->variant_id = $variantId;
|
||||
$item->cantidad = $quantity;
|
||||
$item->save();
|
||||
|
||||
return $item->fresh();
|
||||
}
|
||||
|
||||
$delta = $quantity - $item->cantidad;
|
||||
|
||||
if ($delta > 0) {
|
||||
@@ -153,12 +211,12 @@ class Cart extends Model
|
||||
->whereKeyNot($item->getKey())
|
||||
->sum('cantidad');
|
||||
$this->assertUserPurchaseLimit(
|
||||
$selectedItem,
|
||||
$currentSelection,
|
||||
$otherVariantsQuantity + $quantity,
|
||||
);
|
||||
}
|
||||
|
||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
|
||||
|
||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||
@@ -171,11 +229,11 @@ class Cart extends Model
|
||||
$item->save();
|
||||
|
||||
if ($delta > 0) {
|
||||
$inventoryService->reserve($selectedItem, $delta);
|
||||
$inventoryService->reserve($currentSelection, $delta);
|
||||
}
|
||||
|
||||
if ($delta < 0) {
|
||||
$inventoryService->release($selectedItem, abs($delta));
|
||||
$inventoryService->release($currentSelection, abs($delta));
|
||||
}
|
||||
|
||||
return $item->fresh();
|
||||
|
||||
@@ -19,7 +19,7 @@ class UpdateCartItemQuantityRequest extends FormRequest
|
||||
return [
|
||||
'cantidad' => ['required', 'integer', 'min:1'],
|
||||
'catalog_item_id' => ['prohibited'],
|
||||
'variant_id' => ['prohibited'],
|
||||
'variant_id' => ['sometimes', 'nullable', 'integer'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
namespace App\Domains\Cart\Resources;
|
||||
|
||||
use App\Domains\Cart\Models\CartItem;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -36,6 +38,16 @@ class CartItemResource extends JsonResource
|
||||
'product' => $selectedItem === null ? null : [
|
||||
'nombre' => $selectedItem->getName(),
|
||||
'imagen' => $imageUrl,
|
||||
'variants' => $this->catalogItem->visibleVariants($this->variant_id)
|
||||
->map(fn (Variant $variant): array => [
|
||||
'id' => $variant->id,
|
||||
'precio' => $this->formatMoney($variant->getPrice()),
|
||||
'stock_tecnico' => $this->catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory->availableStock(),
|
||||
'values' => $variant->selectionOptions($this->catalogItem->itemAttributes),
|
||||
])
|
||||
->values(),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -51,11 +51,17 @@ class CartService
|
||||
];
|
||||
}
|
||||
|
||||
public function updateItemQuantity(Tenant $tenant, Request $request, int $cartItemId, int $quantity): Cart
|
||||
{
|
||||
public function updateItem(
|
||||
Tenant $tenant,
|
||||
Request $request,
|
||||
int $cartItemId,
|
||||
int $quantity,
|
||||
?int $variantId,
|
||||
bool $updateVariant,
|
||||
): Cart {
|
||||
$identity = $this->requireIdentity($request);
|
||||
$cart = $this->findCartOrFail($tenant, $identity);
|
||||
$cart->updateItem($cartItemId, $quantity);
|
||||
$cart->updateItem($cartItemId, $quantity, $variantId, $updateVariant);
|
||||
|
||||
return $this->loadCart($cart);
|
||||
}
|
||||
@@ -101,9 +107,14 @@ class CartService
|
||||
return $cart->fresh()->load([
|
||||
'items.catalogItem.attachments',
|
||||
'items.catalogItem.inventory',
|
||||
'items.catalogItem.itemAttributes.attribute',
|
||||
'items.catalogItem.variants.inventory',
|
||||
'items.catalogItem.variants.definitions.itemAttribute.attribute.options',
|
||||
'items.catalogItem.variants.eventDates',
|
||||
'items.catalogItem.variants.eventDate',
|
||||
'items.variant.attachments',
|
||||
'items.variant.inventory',
|
||||
'items.variant.definitions.itemAttribute.attribute',
|
||||
'items.variant.definitions.itemAttribute.attribute.options',
|
||||
'items.variant.eventDates',
|
||||
'items.variant.eventDate',
|
||||
]);
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Enums;
|
||||
|
||||
enum EventProductType: string
|
||||
{
|
||||
case Entry = 'entrada';
|
||||
case Product = 'producto';
|
||||
|
||||
/** @return list<string> */
|
||||
public static function values(): array
|
||||
{
|
||||
return array_column(self::cases(), 'value');
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace App\Domains\Catalog\Models;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
@@ -16,10 +15,10 @@ use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'event_product_type',
|
||||
'category_id',
|
||||
'brand_id',
|
||||
'inventory_id',
|
||||
@@ -53,7 +52,6 @@ class CatalogItem extends Model
|
||||
'category_id' => 'integer',
|
||||
'brand_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'event_product_type' => EventProductType::class,
|
||||
'type' => CatalogItemType::class,
|
||||
'precio' => 'decimal:2',
|
||||
'inventory_policy' => InventoryPolicy::class,
|
||||
@@ -170,6 +168,16 @@ class CatalogItem extends Model
|
||||
return ($this->availableStock() ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** @return Collection<int, Variant> */
|
||||
public function visibleVariants(?int $includedVariantId = null): Collection
|
||||
{
|
||||
return $this->variants
|
||||
->filter(fn (Variant $variant): bool => ($includedVariantId !== null && $variant->id === $includedVariantId)
|
||||
|| $this->inventory_policy === InventoryPolicy::Unlimited
|
||||
|| ($variant->inventory?->availableStock() ?? 0) > 0)
|
||||
->values();
|
||||
}
|
||||
|
||||
public function getPrice(): float
|
||||
{
|
||||
return (float) $this->precio;
|
||||
|
||||
@@ -12,6 +12,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'catalog_item_id',
|
||||
'attribute_id',
|
||||
'allow_multi_select',
|
||||
'sort_order',
|
||||
])]
|
||||
class ItemAttribute extends Model
|
||||
{
|
||||
@@ -25,6 +26,7 @@ class ItemAttribute extends Model
|
||||
'catalog_item_id' => 'integer',
|
||||
'attribute_id' => 'integer',
|
||||
'allow_multi_select' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,87 @@ class Variant extends Model
|
||||
return $values;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Collection<string, array{value: string, label: string}|array<int, array{value: string, label: string}>>
|
||||
*/
|
||||
public function selectionOptions(?Collection $itemAttributes = null): Collection
|
||||
{
|
||||
$options = $this->definitions
|
||||
->groupBy('item_attribute_id')
|
||||
->mapWithKeys(function (Collection $definitions): array {
|
||||
$itemAttribute = $definitions->first()?->itemAttribute;
|
||||
$attribute = $itemAttribute?->attribute;
|
||||
$attributeCode = $attribute?->codigo;
|
||||
|
||||
if ($attributeCode === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$values = $definitions
|
||||
->pluck('value')
|
||||
->values()
|
||||
->map(function (string $value) use ($attribute): array {
|
||||
$attributeOption = $attribute->options->firstWhere('value', $value);
|
||||
|
||||
return [
|
||||
'value' => $value,
|
||||
'label' => $attributeOption?->label ?? $value,
|
||||
];
|
||||
});
|
||||
|
||||
return [
|
||||
$attributeCode => $itemAttribute->allow_multi_select
|
||||
? $values->all()
|
||||
: $values->first(),
|
||||
];
|
||||
});
|
||||
|
||||
$eventDateOptions = $this->selectedEventDates()
|
||||
->map(fn (EventDate $eventDate): array => [
|
||||
'value' => (string) $eventDate->id,
|
||||
'label' => $eventDate->date->format('d/m/Y'),
|
||||
])
|
||||
->values();
|
||||
|
||||
if ($eventDateOptions->count() === 1) {
|
||||
$options->put('event_date', $eventDateOptions->first());
|
||||
} elseif ($eventDateOptions->isNotEmpty()) {
|
||||
$options->put('event_date', $eventDateOptions->all());
|
||||
}
|
||||
|
||||
if ($itemAttributes === null) {
|
||||
$itemAttributes = $this->definitions
|
||||
->map(fn (VariantDefinition $definition) => $definition->itemAttribute)
|
||||
->filter()
|
||||
->unique('id')
|
||||
->values();
|
||||
|
||||
if ($this->catalogItem !== null) {
|
||||
$itemAttributes = $itemAttributes
|
||||
->merge($this->catalogItem->itemAttributes)
|
||||
->unique('id')
|
||||
->values();
|
||||
}
|
||||
}
|
||||
|
||||
$ordering = $itemAttributes->mapWithKeys(function (ItemAttribute $itemAttribute): array {
|
||||
$attribute = $itemAttribute->attribute;
|
||||
|
||||
return $attribute === null
|
||||
? []
|
||||
: [$attribute->codigo => [$itemAttribute->sort_order, mb_strtolower($attribute->nombre)]];
|
||||
});
|
||||
|
||||
return $options->sortKeysUsing(function (string $left, string $right) use ($ordering): int {
|
||||
[$leftOrder, $leftLabel] = $ordering->get($left, [0, mb_strtolower($left)]);
|
||||
[$rightOrder, $rightLabel] = $ordering->get($right, [0, mb_strtolower($right)]);
|
||||
|
||||
return $leftOrder <=> $rightOrder
|
||||
?: $leftLabel <=> $rightLabel
|
||||
?: $left <=> $right;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return Collection<int, EventDate> */
|
||||
public function selectedEventDates(): Collection
|
||||
{
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Domains\Catalog\Requests;
|
||||
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
@@ -26,11 +25,6 @@ class StoreCatalogItemRequest extends FormRequest
|
||||
return [
|
||||
'tenant_code' => ['prohibited'],
|
||||
'type' => ['sometimes', Rule::enum(CatalogItemType::class)],
|
||||
'event_product_type' => [
|
||||
'sometimes',
|
||||
'nullable',
|
||||
Rule::enum(EventProductType::class),
|
||||
],
|
||||
'category_id' => [
|
||||
'sometimes',
|
||||
'nullable',
|
||||
|
||||
@@ -34,7 +34,7 @@ class CatalogFeaturedItemResource extends JsonResource
|
||||
'validity_time_id' => $catalogItem->validity_time_id,
|
||||
'validity_time' => ValidityTimeResource::make($catalogItem->validityTime),
|
||||
'stock_tecnico' => $catalogItem->availableStock(),
|
||||
'variants' => $catalogItem->variants
|
||||
'variants' => $catalogItem->visibleVariants()
|
||||
->map(fn (Variant $variant): array => [
|
||||
'id' => $variant->id,
|
||||
'event_date_id' => $variant->event_date_id,
|
||||
@@ -46,7 +46,7 @@ class CatalogFeaturedItemResource extends JsonResource
|
||||
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory->availableStock(),
|
||||
'values' => $variant->selectionValues(),
|
||||
'values' => $variant->selectionOptions($catalogItem->itemAttributes),
|
||||
])
|
||||
->values(),
|
||||
];
|
||||
|
||||
@@ -24,7 +24,6 @@ class CatalogItemDetailResource extends JsonResource
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'type' => $this->type->value,
|
||||
'event_product_type' => $this->event_product_type?->value,
|
||||
'category_id' => $this->category_id,
|
||||
'brand_id' => $this->brand_id,
|
||||
'slug' => $this->slug,
|
||||
@@ -49,7 +48,7 @@ class CatalogItemDetailResource extends JsonResource
|
||||
$selectedVariant === null,
|
||||
fn () => $this->imageUrls($this->attachments),
|
||||
),
|
||||
'variants' => $this->variants
|
||||
'variants' => $this->visibleVariants()
|
||||
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||
->values(),
|
||||
'selected_variant' => $this->when(
|
||||
@@ -87,6 +86,7 @@ class CatalogItemDetailResource extends JsonResource
|
||||
'id' => $attribute->id,
|
||||
'codigo' => $attribute->codigo,
|
||||
'nombre' => $attribute->nombre,
|
||||
'sort_order' => $itemAttribute->sort_order,
|
||||
'is_required' => $attribute->is_required,
|
||||
'allow_multi_select' => $itemAttribute->allow_multi_select,
|
||||
'metadata_schema' => $attribute->metadata_schema,
|
||||
@@ -104,9 +104,7 @@ class CatalogItemDetailResource extends JsonResource
|
||||
->map(fn ($eventDate, int $index): array => [
|
||||
'id' => $eventDate->id,
|
||||
'value' => (string) $eventDate->id,
|
||||
'label' => $eventDate->date->format('d/m/Y').' · '
|
||||
.substr($eventDate->time_start, 0, 5).' a '
|
||||
.substr($eventDate->time_end, 0, 5),
|
||||
'label' => $eventDate->date->format('d/m/Y'),
|
||||
'sort_order' => $index,
|
||||
'validity_time_id' => null,
|
||||
'validity_time' => null,
|
||||
@@ -148,7 +146,10 @@ class CatalogItemDetailResource extends JsonResource
|
||||
private function attributesData(): Collection
|
||||
{
|
||||
return $this->itemAttributes
|
||||
->sortBy(fn (ItemAttribute $itemAttribute): int => $itemAttribute->attribute->type === FieldType::EventDate ? 0 : 1)
|
||||
->sort(function (ItemAttribute $left, ItemAttribute $right): int {
|
||||
return $left->sort_order <=> $right->sort_order
|
||||
?: mb_strtolower($left->attribute->nombre) <=> mb_strtolower($right->attribute->nombre);
|
||||
})
|
||||
->map(fn (ItemAttribute $itemAttribute): array => $this->attributeData($itemAttribute))
|
||||
->values();
|
||||
}
|
||||
@@ -156,7 +157,7 @@ class CatalogItemDetailResource extends JsonResource
|
||||
/** @return array<string, mixed> */
|
||||
private function variantData(Variant $variant): array
|
||||
{
|
||||
$values = $variant->selectionValues();
|
||||
$values = $variant->selectionOptions($this->itemAttributes);
|
||||
$eventDates = $variant->selectedEventDates();
|
||||
|
||||
return [
|
||||
|
||||
@@ -16,7 +16,6 @@ class CatalogItemResource extends JsonResource
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'type' => $this->type->value,
|
||||
'event_product_type' => $this->event_product_type?->value,
|
||||
'category_id' => $this->category_id,
|
||||
'brand_id' => $this->brand_id,
|
||||
'slug' => $this->slug,
|
||||
@@ -45,7 +44,7 @@ class CatalogItemResource extends JsonResource
|
||||
'descripcion' => $variant->getDescription(),
|
||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
'real_stock' => $variant->inventory?->real_stock,
|
||||
'values' => $variant->selectionValues(),
|
||||
'values' => $variant->selectionOptions($this->itemAttributes),
|
||||
'images' => $variant->attachments
|
||||
->map(fn ($attachment) => $attachment->getTemporaryUrl(1440))
|
||||
->values(),
|
||||
|
||||
@@ -30,7 +30,7 @@ class CatalogSearchItemResource extends JsonResource
|
||||
'validity_time' => ValidityTimeResource::make($this->validityTime),
|
||||
'image' => $attachment?->getTemporaryUrl(1440),
|
||||
'stock_tecnico' => $this->availableStock(),
|
||||
'variants' => $this->variants
|
||||
'variants' => $this->visibleVariants()
|
||||
->map(fn (Variant $variant): array => [
|
||||
'id' => $variant->id,
|
||||
'event_date_id' => $variant->event_date_id,
|
||||
@@ -42,7 +42,7 @@ class CatalogSearchItemResource extends JsonResource
|
||||
'stock_tecnico' => $this->inventory_policy === InventoryPolicy::Unlimited
|
||||
? null
|
||||
: $variant->inventory?->availableStock(),
|
||||
'values' => $variant->selectionValues(),
|
||||
'values' => $variant->selectionOptions($this->itemAttributes),
|
||||
])
|
||||
->values(),
|
||||
];
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Domains\Catalog\Services;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Attachable\Services\AttachmentService;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
@@ -65,7 +64,6 @@ class CatalogService
|
||||
]);
|
||||
}
|
||||
|
||||
$this->validateEventProductType($data);
|
||||
$this->validateUniqueVariantCombinations($variants, $attributeCodes);
|
||||
|
||||
if ($type === CatalogItemType::Bundle) {
|
||||
@@ -153,7 +151,7 @@ class CatalogService
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
]);
|
||||
@@ -176,15 +174,16 @@ class CatalogService
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions' => fn ($query) => $query->orderBy('id'),
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem.inventory',
|
||||
'bundleComponents.variant.inventory',
|
||||
'bundleComponents.variant.definitions.itemAttribute.attribute',
|
||||
]);
|
||||
|
||||
$visibleVariants = $catalogItem->visibleVariants();
|
||||
$selectedVariant = $variantId === null
|
||||
? $catalogItem->variants->first()
|
||||
: $catalogItem->variants->firstWhere('id', $variantId);
|
||||
? $visibleVariants->first()
|
||||
: $visibleVariants->firstWhere('id', $variantId);
|
||||
|
||||
if ($variantId !== null && $selectedVariant === null) {
|
||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||
@@ -227,11 +226,12 @@ class CatalogService
|
||||
'attachments',
|
||||
'inventory',
|
||||
'validityTime',
|
||||
'itemAttributes.attribute',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
])
|
||||
@@ -261,11 +261,12 @@ class CatalogService
|
||||
'attachments',
|
||||
'inventory',
|
||||
'validityTime',
|
||||
'itemAttributes.attribute',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
])
|
||||
@@ -305,6 +306,39 @@ class CatalogService
|
||||
});
|
||||
}
|
||||
|
||||
public function deleteVariant(Variant $variant): void
|
||||
{
|
||||
DB::transaction(function () use ($variant): void {
|
||||
$variant = Variant::query()
|
||||
->with('attachments')
|
||||
->lockForUpdate()
|
||||
->findOrFail($variant->getKey());
|
||||
$catalogItem = CatalogItem::query()
|
||||
->lockForUpdate()
|
||||
->findOrFail($variant->catalog_item_id);
|
||||
$attachments = $variant->attachments;
|
||||
$inventoryId = $variant->inventory_id;
|
||||
|
||||
$variant->attachments()->detach();
|
||||
$variant->delete();
|
||||
Inventory::query()->whereKey($inventoryId)->delete();
|
||||
|
||||
$minimumPrice = $catalogItem->variants()->min('precio');
|
||||
|
||||
if ($minimumPrice === null) {
|
||||
$this->delete($catalogItem);
|
||||
} else {
|
||||
$catalogItem->update(['precio' => $minimumPrice]);
|
||||
}
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
if (! DB::table('catalog_items_attachments')->where('attachment_id', $attachment->id)->exists()) {
|
||||
$this->attachmentService->delete($attachment);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function createInventory(int $realStock): Inventory
|
||||
{
|
||||
return Inventory::query()->create([
|
||||
@@ -586,22 +620,6 @@ class CatalogService
|
||||
return $variant;
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
private function validateEventProductType(array $data): void
|
||||
{
|
||||
$eventProductType = $data['event_product_type'] ?? null;
|
||||
|
||||
if ($eventProductType === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! in_array($eventProductType, EventProductType::values(), true)) {
|
||||
throw ValidationException::withMessages([
|
||||
'event_product_type' => [__('api.catalog.invalid_event_product_type')],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $variants
|
||||
* @param array<int, string> $attributeCodes
|
||||
|
||||
@@ -41,11 +41,12 @@ class FeaturedGroupService
|
||||
'inventory',
|
||||
'attachments',
|
||||
'validityTime',
|
||||
'itemAttributes.attribute',
|
||||
'variants.inventory',
|
||||
'variants.attachments',
|
||||
'variants.eventDate',
|
||||
'variants.eventDates',
|
||||
'variants.definitions.itemAttribute.attribute',
|
||||
'variants.definitions.itemAttribute.attribute.options',
|
||||
'bundleComponents.catalogItem',
|
||||
'bundleComponents.variant.catalogItem',
|
||||
]);
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\FiestaFutbolInfantil\Resources\AccommodationResource;
|
||||
use App\Domains\FiestaFutbolInfantil\Services\AccommodationService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class AccommodationController extends Controller
|
||||
{
|
||||
@@ -30,4 +31,14 @@ class AccommodationController extends Controller
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $accommodation): Response
|
||||
{
|
||||
$this->accommodationService->delete(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$accommodation,
|
||||
);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class EntryController extends Controller
|
||||
{
|
||||
@@ -34,4 +35,14 @@ class EntryController extends Controller
|
||||
->response()
|
||||
->setStatusCode(200);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $entry): Response
|
||||
{
|
||||
$this->entryService->delete(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$entry,
|
||||
);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\FiestaFutbolInfantil\Resources\FoodResource;
|
||||
use App\Domains\FiestaFutbolInfantil\Services\FoodService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class FoodController extends Controller
|
||||
{
|
||||
@@ -30,4 +31,14 @@ class FoodController extends Controller
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $food): Response
|
||||
{
|
||||
$this->foodService->delete(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$food,
|
||||
);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class MerchandiseController extends Controller
|
||||
{
|
||||
@@ -33,4 +34,14 @@ class MerchandiseController extends Controller
|
||||
->response()
|
||||
->setStatusCode(200);
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $merchandise): Response
|
||||
{
|
||||
$this->merchandiseService->delete(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$merchandise,
|
||||
);
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,11 @@ class UpsertEntriesRequest extends FormRequest
|
||||
Rule::exists('catalog_items', 'id')->where(
|
||||
fn ($query) => $query
|
||||
->where('tenant_code', $tenantCode)
|
||||
->where('event_product_type', 'entrada')
|
||||
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
|
||||
->select('id')
|
||||
->from('categorias')
|
||||
->where('tenant_code', $tenantCode)
|
||||
->where('nombre', 'Entradas'))
|
||||
),
|
||||
],
|
||||
'entries.*.title' => ['required', 'string', 'max:255'],
|
||||
|
||||
@@ -28,7 +28,11 @@ class UpsertMerchandiseRequest extends FormRequest
|
||||
Rule::exists('catalog_items', 'id')->where(
|
||||
fn ($query) => $query
|
||||
->where('tenant_code', $tenantCode)
|
||||
->where('event_product_type', 'producto')
|
||||
->whereIn('category_id', fn ($categoryQuery) => $categoryQuery
|
||||
->select('id')
|
||||
->from('categorias')
|
||||
->where('tenant_code', $tenantCode)
|
||||
->where('nombre', 'Merchandising'))
|
||||
),
|
||||
],
|
||||
'items.*.title' => ['required', 'string', 'max:255'],
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\AttributeOption;
|
||||
@@ -11,6 +10,7 @@ use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -20,6 +20,8 @@ class AccommodationService
|
||||
{
|
||||
private const ATTRIBUTE_CODE = 'tipo_alojamiento';
|
||||
|
||||
public function __construct(private readonly CatalogService $catalogService) {}
|
||||
|
||||
public function current(Tenant $tenant): ?CatalogItem
|
||||
{
|
||||
return CatalogItem::query()
|
||||
@@ -86,6 +88,18 @@ class AccommodationService
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Tenant $tenant, int $accommodationId): void
|
||||
{
|
||||
$variant = Variant::query()
|
||||
->whereKey($accommodationId)
|
||||
->whereHas('catalogItem', fn ($query) => $query
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('slug', 'alojamiento'))
|
||||
->firstOrFail();
|
||||
|
||||
$this->catalogService->deleteVariant($variant);
|
||||
}
|
||||
|
||||
private function attribute(Tenant $tenant): Attribute
|
||||
{
|
||||
$attribute = Attribute::query()
|
||||
@@ -120,9 +134,8 @@ class AccommodationService
|
||||
if ($accommodation !== null) {
|
||||
$accommodation->update([
|
||||
'category_id' => $category->id,
|
||||
'event_product_type' => EventProductType::Product->value,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'has_tickets' => false,
|
||||
'has_tickets' => true,
|
||||
]);
|
||||
|
||||
return $accommodation;
|
||||
@@ -135,9 +148,8 @@ class AccommodationService
|
||||
'descripcion' => 'Alojamiento',
|
||||
'category_id' => $category->id,
|
||||
'precio' => collect($variants)->min('price') ?? 0,
|
||||
'event_product_type' => EventProductType::Product->value,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'has_tickets' => false,
|
||||
'has_tickets' => true,
|
||||
'inventory_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
@@ -24,7 +23,7 @@ class EntryService
|
||||
{
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('event_product_type', EventProductType::Entry->value)
|
||||
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
|
||||
->with([
|
||||
'variants.inventory',
|
||||
'variants.eventDate',
|
||||
@@ -62,7 +61,6 @@ class EntryService
|
||||
'descripcion' => $entry['description'] ?? null,
|
||||
'category_id' => $category->id,
|
||||
'precio' => $entry['price'],
|
||||
'event_product_type' => EventProductType::Entry->value,
|
||||
'has_tickets' => true,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'attribute_codes' => ['event_date'],
|
||||
@@ -76,13 +74,24 @@ class EntryService
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Tenant $tenant, int $entryId): void
|
||||
{
|
||||
$entry = CatalogItem::query()
|
||||
->whereKey($entryId)
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
|
||||
->firstOrFail();
|
||||
|
||||
$this->catalogService->delete($entry);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $entry */
|
||||
private function update(Tenant $tenant, Category $category, array $entry, int $index): CatalogItem
|
||||
{
|
||||
$catalogItem = CatalogItem::query()
|
||||
->whereKey($entry['id'])
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('event_product_type', EventProductType::Entry->value)
|
||||
->whereHas('category', fn ($query) => $query->where('nombre', 'Entradas'))
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\AttributeOption;
|
||||
@@ -11,6 +10,7 @@ use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -20,6 +20,14 @@ class FoodService
|
||||
{
|
||||
private const ATTRIBUTE_CODES = ['event_date', 'horario', 'servicio'];
|
||||
|
||||
private const ATTRIBUTE_SORT_ORDERS = [
|
||||
'event_date' => 1,
|
||||
'horario' => 2,
|
||||
'servicio' => 3,
|
||||
];
|
||||
|
||||
public function __construct(private readonly CatalogService $catalogService) {}
|
||||
|
||||
public function current(Tenant $tenant): ?CatalogItem
|
||||
{
|
||||
return CatalogItem::query()
|
||||
@@ -87,6 +95,18 @@ class FoodService
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Tenant $tenant, int $foodId): void
|
||||
{
|
||||
$variant = Variant::query()
|
||||
->whereKey($foodId)
|
||||
->whereHas('catalogItem', fn ($query) => $query
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('slug', 'comida'))
|
||||
->firstOrFail();
|
||||
|
||||
$this->catalogService->deleteVariant($variant);
|
||||
}
|
||||
|
||||
/** @return Collection<string, Attribute> */
|
||||
private function attributes(Tenant $tenant): Collection
|
||||
{
|
||||
@@ -125,9 +145,8 @@ class FoodService
|
||||
if ($food !== null) {
|
||||
$food->update([
|
||||
'category_id' => $category->id,
|
||||
'event_product_type' => EventProductType::Product->value,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'has_tickets' => false,
|
||||
'has_tickets' => true,
|
||||
]);
|
||||
|
||||
return $food;
|
||||
@@ -140,9 +159,8 @@ class FoodService
|
||||
'descripcion' => 'Comida',
|
||||
'category_id' => $category->id,
|
||||
'precio' => collect($variants)->min('price') ?? 0,
|
||||
'event_product_type' => EventProductType::Product->value,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'has_tickets' => false,
|
||||
'has_tickets' => true,
|
||||
'inventory_id' => null,
|
||||
]);
|
||||
}
|
||||
@@ -154,9 +172,12 @@ class FoodService
|
||||
private function itemAttributes(CatalogItem $food, Collection $attributes): Collection
|
||||
{
|
||||
return $attributes->mapWithKeys(function (Attribute $attribute, string $code) use ($food): array {
|
||||
$itemAttribute = $food->itemAttributes()->firstOrCreate(
|
||||
$itemAttribute = $food->itemAttributes()->updateOrCreate(
|
||||
['attribute_id' => $attribute->id],
|
||||
['allow_multi_select' => false],
|
||||
[
|
||||
'allow_multi_select' => false,
|
||||
'sort_order' => self::ATTRIBUTE_SORT_ORDERS[$code],
|
||||
],
|
||||
);
|
||||
|
||||
return [$code => $itemAttribute];
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\FiestaFutbolInfantil\Services;
|
||||
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\AttributeOption;
|
||||
@@ -11,6 +10,7 @@ use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@@ -21,12 +21,13 @@ class MerchandiseService
|
||||
{
|
||||
private const ATTRIBUTE_CODES = ['color', 'talle'];
|
||||
|
||||
public function __construct(private readonly CatalogService $catalogService) {}
|
||||
|
||||
/** @return Collection<int, CatalogItem> */
|
||||
public function all(Tenant $tenant): Collection
|
||||
{
|
||||
return CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('event_product_type', EventProductType::Product->value)
|
||||
->whereHas('category', fn ($query) => $query->where('nombre', 'Merchandising'))
|
||||
->with([
|
||||
'itemAttributes.attribute.options',
|
||||
@@ -71,9 +72,8 @@ class MerchandiseService
|
||||
'descripcion' => $data['description'] ?? null,
|
||||
'category_id' => $category->id,
|
||||
'max_units_per_user' => (int) $data['max_units_per_user'],
|
||||
'event_product_type' => EventProductType::Product->value,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'has_tickets' => false,
|
||||
'has_tickets' => true,
|
||||
]);
|
||||
|
||||
$itemAttributes = $this->itemAttributes($item, $attributes);
|
||||
@@ -120,6 +120,19 @@ class MerchandiseService
|
||||
});
|
||||
}
|
||||
|
||||
public function delete(Tenant $tenant, int $merchandiseId): void
|
||||
{
|
||||
$variant = Variant::query()
|
||||
->whereKey($merchandiseId)
|
||||
->whereHas('catalogItem', fn ($query) => $query
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereHas('category', fn ($categoryQuery) => $categoryQuery
|
||||
->where('nombre', 'Merchandising')))
|
||||
->firstOrFail();
|
||||
|
||||
$this->catalogService->deleteVariant($variant);
|
||||
}
|
||||
|
||||
/** @return Collection<string, Attribute> */
|
||||
private function attributes(Tenant $tenant): Collection
|
||||
{
|
||||
@@ -153,7 +166,6 @@ class MerchandiseService
|
||||
->whereKey($itemId)
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('category_id', $category->id)
|
||||
->where('event_product_type', EventProductType::Product->value)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
@@ -184,9 +196,8 @@ class MerchandiseService
|
||||
'category_id' => $category->id,
|
||||
'precio' => collect($data['variants'])->min('price') ?? 0,
|
||||
'max_units_per_user' => (int) $data['max_units_per_user'],
|
||||
'event_product_type' => EventProductType::Product->value,
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'has_tickets' => false,
|
||||
'has_tickets' => true,
|
||||
'inventory_id' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -15,22 +15,34 @@ Route::prefix('v1/adminapp/tenant')
|
||||
Route::post('entries', [EntryController::class, 'store'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||
->name('adminapp.fiesta-futbol-infantil.entries.store');
|
||||
Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||
->name('adminapp.fiesta-futbol-infantil.entries.destroy');
|
||||
Route::get('foods', [FoodController::class, 'index'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||
->name('adminapp.fiesta-futbol-infantil.foods.index');
|
||||
Route::post('foods', [FoodController::class, 'store'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||
->name('adminapp.fiesta-futbol-infantil.foods.store');
|
||||
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
||||
Route::get('accommodations', [AccommodationController::class, 'index'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||
->name('adminapp.fiesta-futbol-infantil.accommodations.index');
|
||||
Route::post('accommodations', [AccommodationController::class, 'store'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||
->name('adminapp.fiesta-futbol-infantil.accommodations.store');
|
||||
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
|
||||
Route::get('merchandise', [MerchandiseController::class, 'index'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||
->name('adminapp.fiesta-futbol-infantil.merchandise.index');
|
||||
Route::post('merchandise', [MerchandiseController::class, 'store'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||
->name('adminapp.fiesta-futbol-infantil.merchandise.store');
|
||||
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
|
||||
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Sale\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleResource;
|
||||
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
||||
@@ -30,6 +31,13 @@ class SaleController extends Controller
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Request $request, int $sale): SaleDetailResource
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return new SaleDetailResource($this->saleService->detail($tenant, $sale));
|
||||
}
|
||||
|
||||
public function modifications(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
return SaleModificationResource::collection(
|
||||
|
||||
50
app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php
Normal file
50
app/Domains/Sale/Resources/AdminApp/SaleDetailResource.php
Normal file
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sale\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Purchase */
|
||||
class SaleDetailResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'items' => $this->items->map(fn (PurchaseItem $item): array => [
|
||||
'id' => $item->id,
|
||||
'product' => $item->item_nombre,
|
||||
'event_dates' => $this->eventDates($item),
|
||||
'quantity' => (int) $item->cantidad,
|
||||
'unit_price' => $this->formatMoney($item->precio_unitario),
|
||||
'total' => $this->formatMoney($item->total),
|
||||
])->values(),
|
||||
'total' => $this->formatMoney($this->total),
|
||||
];
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function eventDates(PurchaseItem $item): array
|
||||
{
|
||||
return collect($item->variant_attributes ?? [])
|
||||
->filter(fn (mixed $attribute): bool => is_array($attribute)
|
||||
&& mb_strtolower(trim((string) ($attribute['name'] ?? ''))) === 'fecha')
|
||||
->flatMap(function (array $attribute): array {
|
||||
$value = $attribute['value'] ?? [];
|
||||
|
||||
return is_array($value) ? $value : [$value];
|
||||
})
|
||||
->filter(fn (mixed $date): bool => is_string($date) && $date !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function formatMoney(float|int|string|null $amount): string
|
||||
{
|
||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,14 @@ class AdminAppSaleService
|
||||
->withQueryString();
|
||||
}
|
||||
|
||||
public function detail(Tenant $tenant, int $saleId): Purchase
|
||||
{
|
||||
return Purchase::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->with('items')
|
||||
->findOrFail($saleId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $filters
|
||||
* @return Collection<int, Purchase>
|
||||
|
||||
@@ -10,4 +10,5 @@ Route::prefix('v1/adminapp/tenant')
|
||||
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
|
||||
Route::get('sales/modifications', [SaleController::class, 'modifications']);
|
||||
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
|
||||
Route::get('sales/{sale}', [SaleController::class, 'show'])->whereNumber('sale');
|
||||
});
|
||||
|
||||
@@ -57,7 +57,7 @@ class TicketGeneratorService
|
||||
return Ticket::query()->create([
|
||||
'tenant_code' => $item->tenant_code,
|
||||
'ticket' => (string) Str::uuid(),
|
||||
'name' => $item->nombre,
|
||||
'name' => $this->ticketName($item, $variant, $eventDate),
|
||||
'description' => (string) ($item->descripcion ?? ''),
|
||||
'source_purchase_id' => $sourcePurchaseId,
|
||||
'source_catalog_item_id' => $item->getKey(),
|
||||
@@ -206,6 +206,46 @@ class TicketGeneratorService
|
||||
return $validityTimes->first();
|
||||
}
|
||||
|
||||
private function ticketName(
|
||||
CatalogItem $catalogItem,
|
||||
?Variant $variant,
|
||||
?EventDate $eventDate,
|
||||
): string {
|
||||
if ($variant === null) {
|
||||
return $catalogItem->nombre;
|
||||
}
|
||||
|
||||
$options = $variant->selectionOptions();
|
||||
|
||||
if ($eventDate !== null) {
|
||||
$options->put('event_date', [
|
||||
'value' => (string) $eventDate->getKey(),
|
||||
'label' => $eventDate->date->format('d/m/Y'),
|
||||
]);
|
||||
}
|
||||
|
||||
$properties = $options
|
||||
->flatMap(function (array $option): array {
|
||||
if (array_is_list($option)) {
|
||||
return collect($option)
|
||||
->pluck('label')
|
||||
->filter(fn ($label): bool => is_string($label) && $label !== '')
|
||||
->all();
|
||||
}
|
||||
|
||||
$label = $option['label'] ?? null;
|
||||
|
||||
return is_string($label) && $label !== '' ? [$label] : [];
|
||||
})
|
||||
->values();
|
||||
|
||||
if ($properties->isEmpty()) {
|
||||
return $catalogItem->nombre;
|
||||
}
|
||||
|
||||
return $catalogItem->nombre.' ('.$properties->implode(', ').')';
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a recurring time window into the fixed date and time purchased by
|
||||
* the customer. Equal tickets generated together share the same snapshot.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
@@ -43,9 +42,6 @@ return new class extends Migration
|
||||
->after('tenant_code')
|
||||
->constrained('events')
|
||||
->nullOnDelete();
|
||||
$table->enum('event_product_type', EventProductType::values())
|
||||
->nullable()
|
||||
->after('event_id');
|
||||
});
|
||||
|
||||
Schema::table('variantes', function (Blueprint $table): void {
|
||||
@@ -71,7 +67,6 @@ return new class extends Migration
|
||||
|
||||
Schema::table('catalog_items', function (Blueprint $table): void {
|
||||
$table->dropConstrainedForeignId('event_id');
|
||||
$table->dropColumn('event_product_type');
|
||||
});
|
||||
|
||||
Schema::dropIfExists('event_dates');
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
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('item_attributes', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('sort_order')->default(0)->after('allow_multi_select');
|
||||
});
|
||||
|
||||
$foodItemAttributes = DB::table('item_attributes')
|
||||
->join('catalog_items', 'catalog_items.id', '=', 'item_attributes.catalog_item_id')
|
||||
->join('attribute', 'attribute.id', '=', 'item_attributes.attribute_id')
|
||||
->where('catalog_items.slug', 'comida')
|
||||
->whereIn('attribute.codigo', ['event_date', 'horario', 'servicio'])
|
||||
->select('item_attributes.id', 'attribute.codigo')
|
||||
->get();
|
||||
|
||||
$sortOrders = [
|
||||
'event_date' => 1,
|
||||
'horario' => 2,
|
||||
'servicio' => 3,
|
||||
];
|
||||
|
||||
foreach ($foodItemAttributes as $itemAttribute) {
|
||||
DB::table('item_attributes')
|
||||
->where('id', $itemAttribute->id)
|
||||
->update(['sort_order' => $sortOrders[$itemAttribute->codigo]]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('item_attributes', function (Blueprint $table): void {
|
||||
$table->dropColumn('sort_order');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
DB::table('catalog_items')
|
||||
->where('tenant_code', 'fiesta_futbol_infantil')
|
||||
->update(['has_tickets' => true]);
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
$entryCategoryIds = DB::table('categorias')
|
||||
->select('id')
|
||||
->where('tenant_code', 'fiesta_futbol_infantil')
|
||||
->where('nombre', 'Entradas');
|
||||
|
||||
DB::table('catalog_items')
|
||||
->where('tenant_code', 'fiesta_futbol_infantil')
|
||||
->whereNotIn('category_id', $entryCategoryIds)
|
||||
->update(['has_tickets' => false]);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
<?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
|
||||
{
|
||||
if (! Schema::hasColumn('catalog_items', 'event_product_type')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('catalog_items', function (Blueprint $table): void {
|
||||
$table->dropColumn('event_product_type');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
if (Schema::hasColumn('catalog_items', 'event_product_type')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('catalog_items', function (Blueprint $table): void {
|
||||
$table->enum('event_product_type', ['entrada', 'producto'])
|
||||
->nullable()
|
||||
->after('tenant_code');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Database\Seeders;
|
||||
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
@@ -109,7 +108,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
'nombre' => 'Abono',
|
||||
'category_id' => $categories['entradas']->id,
|
||||
'precio' => 40000,
|
||||
'event_product_type' => EventProductType::Entry->value,
|
||||
'has_tickets' => true,
|
||||
'attribute_codes' => ['event_date'],
|
||||
'multi_select_attribute_codes' => ['event_date'],
|
||||
@@ -123,8 +121,8 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'source_type' => FeaturedGroupSource::All,
|
||||
'category_id' => null,
|
||||
'product_layout' => ProductLayout::ColumnWithCart,
|
||||
'group_layout' => GroupLayout::Simple,
|
||||
'product_layout' => ProductLayout::Row,
|
||||
'group_layout' => GroupLayout::SimpleVertical,
|
||||
'group_name' => 'Productos',
|
||||
'group_order' => 0,
|
||||
]);
|
||||
@@ -143,10 +141,10 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
{
|
||||
return $this->catalogService->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'event_product_type' => EventProductType::Product->value,
|
||||
'descripcion' => $data['nombre'],
|
||||
'inventory_policy' => InventoryPolicy::Unlimited->value,
|
||||
...$data,
|
||||
'has_tickets' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@ return [
|
||||
'cart' => [
|
||||
'item_added' => 'Product added to cart.',
|
||||
'quantity_updated' => 'Product quantity updated.',
|
||||
'item_updated' => 'Product updated.',
|
||||
'item_removed' => 'Product removed from cart.',
|
||||
'positive_quantity' => 'The quantity must be greater than zero.',
|
||||
'insufficient_stock' => 'There is not enough stock for the requested product. Maximum available: :max.',
|
||||
@@ -97,7 +98,6 @@ return [
|
||||
'event_date_selection_required' => 'At least one event date must be selected.',
|
||||
'single_event_date_required' => 'Exactly one event date must be selected.',
|
||||
'event_date_wrong_tenant' => 'Every event date must belong to the catalog item tenant.',
|
||||
'invalid_event_product_type' => 'The event product type is invalid.',
|
||||
'duplicate_variant_combination' => 'The variant combination must be unique.',
|
||||
'multi_value_required' => 'At least one value must be selected.',
|
||||
'single_value_required' => 'Exactly one value must be selected.',
|
||||
|
||||
@@ -24,6 +24,7 @@ return [
|
||||
'cart' => [
|
||||
'item_added' => 'Producto agregado al carrito.',
|
||||
'quantity_updated' => 'Cantidad de producto actualizada.',
|
||||
'item_updated' => 'Producto actualizado.',
|
||||
'item_removed' => 'Producto eliminado del carrito.',
|
||||
'positive_quantity' => 'La cantidad debe ser mayor a cero.',
|
||||
'insufficient_stock' => 'Stock insuficiente para el producto solicitado. Máximo disponible: :max.',
|
||||
@@ -97,7 +98,6 @@ return [
|
||||
'event_date_selection_required' => 'Debe seleccionar al menos una fecha de evento.',
|
||||
'single_event_date_required' => 'Debe seleccionar exactamente una fecha de evento.',
|
||||
'event_date_wrong_tenant' => 'Todas las fechas del evento deben pertenecer al tenant del ítem de catálogo.',
|
||||
'invalid_event_product_type' => 'El tipo de producto de evento no es válido.',
|
||||
'duplicate_variant_combination' => 'La combinación de la variante debe ser única.',
|
||||
'multi_value_required' => 'Debe seleccionar al menos un valor.',
|
||||
'single_value_required' => 'Debe seleccionar exactamente un valor.',
|
||||
|
||||
@@ -256,6 +256,110 @@ class CartControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_changes_an_item_variant_and_moves_the_stock_reservation(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00');
|
||||
$secondInventory = Inventory::query()->create(['real_stock' => 2]);
|
||||
$secondVariant = $item->variants()->create([
|
||||
'inventory_id' => $secondInventory->id,
|
||||
'precio' => '20.00',
|
||||
]);
|
||||
$unavailableInventory = Inventory::query()->create(['real_stock' => 0]);
|
||||
$unavailableVariant = $item->variants()->create([
|
||||
'inventory_id' => $unavailableInventory->id,
|
||||
'precio' => '25.00',
|
||||
]);
|
||||
$createResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||
'catalog_item_id' => $item->id,
|
||||
'variant_id' => $firstVariant->id,
|
||||
'cantidad' => 2,
|
||||
]);
|
||||
$guestToken = $createResponse->getCookie('guest_token', false)?->getValue();
|
||||
$cartItemId = $createResponse->json('data.items.0.id');
|
||||
|
||||
$this->call(
|
||||
'PATCH',
|
||||
"/api/tenants/acme/cart/items/{$cartItemId}",
|
||||
[],
|
||||
['guest_token' => $guestToken],
|
||||
[],
|
||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]),
|
||||
)
|
||||
->assertOk()
|
||||
->assertJsonPath('data.items.0.variant_id', $secondVariant->id)
|
||||
->assertJsonPath('data.items.0.precio_unitario', '20.00')
|
||||
->assertJsonCount(2, 'data.items.0.product.variants')
|
||||
->assertJsonPath('data.items.0.product.variants.0.id', $firstVariant->id)
|
||||
->assertJsonPath('data.items.0.product.variants.1.id', $secondVariant->id)
|
||||
->assertJsonPath('data.items.0.product.variants.1.stock_tecnico', 0)
|
||||
->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0]);
|
||||
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $firstVariant->inventory_id,
|
||||
'reserved_stock' => 0,
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $secondInventory->id,
|
||||
'reserved_stock' => 2,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_changing_to_a_variant_already_in_the_cart_merges_both_rows(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '15.00');
|
||||
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
|
||||
$secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]);
|
||||
|
||||
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||
'catalog_item_id' => $item->id,
|
||||
'variant_id' => $firstVariant->id,
|
||||
'cantidad' => 2,
|
||||
]);
|
||||
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
|
||||
$firstCartItemId = $firstResponse->json('data.items.0.id');
|
||||
|
||||
$this->call(
|
||||
'POST',
|
||||
'/api/tenants/acme/cart/items',
|
||||
[],
|
||||
['guest_token' => $guestToken],
|
||||
[],
|
||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
||||
json_encode([
|
||||
'catalog_item_id' => $item->id,
|
||||
'variant_id' => $secondVariant->id,
|
||||
'cantidad' => 3,
|
||||
]),
|
||||
)->assertOk();
|
||||
|
||||
$this->call(
|
||||
'PATCH',
|
||||
"/api/tenants/acme/cart/items/{$firstCartItemId}",
|
||||
[],
|
||||
['guest_token' => $guestToken],
|
||||
[],
|
||||
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
||||
json_encode(['cantidad' => 2, 'variant_id' => $secondVariant->id]),
|
||||
)
|
||||
->assertOk()
|
||||
->assertJsonCount(1, 'data.items')
|
||||
->assertJsonPath('data.items.0.variant_id', $secondVariant->id)
|
||||
->assertJsonPath('data.items.0.cantidad', 5);
|
||||
|
||||
$this->assertDatabaseCount('carrito_items', 1);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $firstVariant->inventory_id,
|
||||
'reserved_stock' => 0,
|
||||
]);
|
||||
$this->assertDatabaseHas('inventories', [
|
||||
'id' => $secondInventory->id,
|
||||
'reserved_stock' => 5,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_requires_a_variant_when_the_item_has_variant_inventory(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
|
||||
@@ -48,8 +48,15 @@ class CatalogControllerTest extends TestCase
|
||||
'real_stock' => 4,
|
||||
'reserved_stock' => 1,
|
||||
]);
|
||||
$unavailableInventory = Inventory::query()->create([
|
||||
'real_stock' => 2,
|
||||
'reserved_stock' => 2,
|
||||
]);
|
||||
$variantItem->variants()->create(['inventory_id' => $firstInventory->id]);
|
||||
$variantItem->variants()->create(['inventory_id' => $secondInventory->id]);
|
||||
$unavailableVariant = $variantItem->variants()->create([
|
||||
'inventory_id' => $unavailableInventory->id,
|
||||
]);
|
||||
$cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]);
|
||||
|
||||
$response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog");
|
||||
@@ -67,6 +74,7 @@ class CatalogControllerTest extends TestCase
|
||||
->assertJsonCount(2, '0.items.0.variants')
|
||||
->assertJsonPath('0.items.0.variants.0.stock_tecnico', 4)
|
||||
->assertJsonPath('0.items.0.variants.1.stock_tecnico', 3)
|
||||
->assertJsonMissing(['id' => $unavailableVariant->id, 'stock_tecnico' => 0])
|
||||
->assertJsonPath('1.title', 'Row')
|
||||
->assertJsonPath('1.items.data.0.stock_tecnico', 8)
|
||||
->assertJsonCount(0, '1.items.data.0.variants');
|
||||
|
||||
@@ -45,7 +45,7 @@ class CatalogItemDetailControllerTest extends TestCase
|
||||
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0'));
|
||||
}
|
||||
|
||||
public function test_it_selects_the_first_variant_and_returns_its_images_by_default(): void
|
||||
public function test_it_filters_unavailable_variants_and_selects_the_first_available_one(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
$tenant = $this->createTenant('detail-default');
|
||||
@@ -66,14 +66,21 @@ class CatalogItemDetailControllerTest extends TestCase
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.selected_variant.id', $firstVariant->id)
|
||||
->assertJsonPath('data.selected_variant.stock_tecnico', 0)
|
||||
->assertJsonCount(1, 'data.variants')
|
||||
->assertJsonPath('data.variants.0.id', $secondVariant->id)
|
||||
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
|
||||
->assertJsonPath('data.selected_variant.stock_tecnico', 6)
|
||||
->assertJsonCount(1, 'data.selected_variant.images');
|
||||
$response
|
||||
->assertJsonMissingPath('data.stock_tecnico')
|
||||
->assertJsonMissingPath('data.images');
|
||||
$this->assertStringContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
|
||||
$this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0'));
|
||||
$this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
|
||||
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
|
||||
|
||||
$this->getJson(
|
||||
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}?variant_id={$firstVariant->id}"
|
||||
)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
|
||||
@@ -117,17 +124,20 @@ class CatalogItemDetailControllerTest extends TestCase
|
||||
->assertOk()
|
||||
->assertJsonPath('data.variants.0.id', $firstVariant->id)
|
||||
->assertJsonPath('data.variants.0.stock_tecnico', 4)
|
||||
->assertJsonPath('data.variants.0.values.size', 'S')
|
||||
->assertJsonPath('data.variants.0.values.size.value', 'S')
|
||||
->assertJsonPath('data.variants.0.values.size.label', 'Small')
|
||||
->assertJsonPath('data.variants.1.id', $secondVariant->id)
|
||||
->assertJsonPath('data.variants.1.stock_tecnico', 7)
|
||||
->assertJsonPath('data.variants.1.values.size', 'M')
|
||||
->assertJsonPath('data.variants.1.values.size.value', 'M')
|
||||
->assertJsonPath('data.variants.1.values.size.label', 'Medium')
|
||||
->assertJsonPath('data.attributes.0.codigo', 'size')
|
||||
->assertJsonPath('data.attributes.0.options.0.value', 'S')
|
||||
->assertJsonPath('data.attributes.0.options.1.value', 'M')
|
||||
->assertJsonCount(2, 'data.attributes.0.options')
|
||||
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
|
||||
->assertJsonPath('data.selected_variant.stock_tecnico', 7)
|
||||
->assertJsonPath('data.selected_variant.values.size', 'M')
|
||||
->assertJsonPath('data.selected_variant.values.size.value', 'M')
|
||||
->assertJsonPath('data.selected_variant.values.size.label', 'Medium')
|
||||
->assertJsonCount(1, 'data.selected_variant.images');
|
||||
$response
|
||||
->assertJsonMissingPath('data.stock_tecnico')
|
||||
@@ -207,14 +217,63 @@ class CatalogItemDetailControllerTest extends TestCase
|
||||
->assertJsonCount(2, 'data.attributes.0.options')
|
||||
->assertJsonPath('data.attributes.0.options.0.id', $eventDate->id)
|
||||
->assertJsonPath('data.attributes.0.options.0.value', (string) $eventDate->id)
|
||||
->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026 · 09:00 a 18:00')
|
||||
->assertJsonPath('data.attributes.0.options.0.label', '09/10/2026')
|
||||
->assertJsonPath('data.attributes.0.options.1.id', $unusedEventDate->id)
|
||||
->assertJsonPath('data.attributes.0.options.1.value', (string) $unusedEventDate->id)
|
||||
->assertJsonPath('data.variants.0.values.event_date', (string) $eventDate->id);
|
||||
->assertJsonPath('data.variants.0.values.event_date.value', (string) $eventDate->id)
|
||||
->assertJsonPath(
|
||||
'data.variants.0.values.event_date.label',
|
||||
'09/10/2026',
|
||||
);
|
||||
|
||||
$this->assertDatabaseCount('attribute_options', 0);
|
||||
}
|
||||
|
||||
public function test_it_orders_item_attributes_by_sort_order_and_then_attribute_label(): void
|
||||
{
|
||||
$tenant = $this->createTenant('detail-attribute-order');
|
||||
$item = $this->createItem($tenant, 'Ordered attributes');
|
||||
$variant = $this->createVariant($item, 10, 0);
|
||||
|
||||
$attributes = collect([
|
||||
['code' => 'zeta', 'label' => 'Zeta', 'sort_order' => 2],
|
||||
['code' => 'priority', 'label' => 'Priority', 'sort_order' => 1],
|
||||
['code' => 'alpha', 'label' => 'Alpha', 'sort_order' => 2],
|
||||
])->mapWithKeys(function (array $data) use ($tenant, $item): array {
|
||||
$attribute = Attribute::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'codigo' => $data['code'],
|
||||
'nombre' => $data['label'],
|
||||
'type' => FieldType::String,
|
||||
]);
|
||||
$itemAttribute = $item->itemAttributes()->create([
|
||||
'attribute_id' => $attribute->id,
|
||||
'sort_order' => $data['sort_order'],
|
||||
]);
|
||||
|
||||
return [$data['code'] => $itemAttribute];
|
||||
});
|
||||
|
||||
foreach (['zeta', 'priority', 'alpha'] as $code) {
|
||||
$variant->definitions()->create([
|
||||
'item_attribute_id' => $attributes[$code]->id,
|
||||
'value' => $code,
|
||||
]);
|
||||
}
|
||||
|
||||
$response = $this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.attributes.0.codigo', 'priority')
|
||||
->assertJsonPath('data.attributes.0.sort_order', 1)
|
||||
->assertJsonPath('data.attributes.1.codigo', 'alpha')
|
||||
->assertJsonPath('data.attributes.2.codigo', 'zeta');
|
||||
|
||||
$this->assertSame(
|
||||
['priority', 'alpha', 'zeta'],
|
||||
array_keys($response->json('data.variants.0.values')),
|
||||
);
|
||||
}
|
||||
|
||||
private function createItem(
|
||||
Tenant $tenant,
|
||||
string $name,
|
||||
|
||||
@@ -24,6 +24,7 @@ class CatalogSchemaTest extends TestCase
|
||||
$this->assertFalse(Schema::hasTable('bundle_items'));
|
||||
$this->assertTrue(Schema::hasTable('variantes'));
|
||||
$this->assertTrue(Schema::hasTable('item_attributes'));
|
||||
$this->assertTrue(Schema::hasColumn('item_attributes', 'sort_order'));
|
||||
$this->assertTrue(Schema::hasTable('variant_values'));
|
||||
}
|
||||
|
||||
@@ -32,7 +33,6 @@ class CatalogSchemaTest extends TestCase
|
||||
$this->assertEqualsCanonicalizing([
|
||||
'id',
|
||||
'tenant_code',
|
||||
'event_product_type',
|
||||
'category_id',
|
||||
'brand_id',
|
||||
'inventory_id',
|
||||
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
@@ -75,6 +76,7 @@ class AccommodationControllerTest extends TestCase
|
||||
'label' => 'Carpa',
|
||||
]);
|
||||
$this->assertDatabaseCount('catalog_items', 1);
|
||||
$this->assertTrue(CatalogItem::query()->sole()->has_tickets);
|
||||
$this->assertDatabaseCount('variantes', 2);
|
||||
$this->assertDatabaseCount('inventories', 2);
|
||||
$this->assertDatabaseCount('variant_values', 2);
|
||||
@@ -125,6 +127,25 @@ class AccommodationControllerTest extends TestCase
|
||||
$this->assertDatabaseCount('variantes', 2);
|
||||
}
|
||||
|
||||
public function test_it_deletes_an_accommodation_and_its_empty_product(): void
|
||||
{
|
||||
[$tenant] = $this->configuredTenant();
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$accommodationId = $this->postJson('/api/v1/adminapp/tenant/accommodations', [
|
||||
'variants' => [
|
||||
$this->variantPayload('Carpa', null, 100, 35000),
|
||||
],
|
||||
])->assertOk()->json('data.variants.0.id');
|
||||
|
||||
$this->deleteJson("/api/v1/adminapp/tenant/accommodations/{$accommodationId}")
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertDatabaseCount('catalog_items', 0);
|
||||
$this->assertDatabaseCount('variantes', 0);
|
||||
$this->assertDatabaseCount('inventories', 0);
|
||||
}
|
||||
|
||||
public function test_it_rejects_duplicate_normalized_titles(): void
|
||||
{
|
||||
[$tenant] = $this->configuredTenant();
|
||||
|
||||
@@ -100,7 +100,6 @@ class EntryControllerTest extends TestCase
|
||||
CatalogItem::query()->each(function (CatalogItem $entry) use ($tenant, $entryCategory): void {
|
||||
$this->assertSame($tenant->codigo, $entry->tenant_code);
|
||||
$this->assertSame($entryCategory->id, $entry->category_id);
|
||||
$this->assertSame('entrada', $entry->event_product_type->value);
|
||||
$this->assertSame('tracked', $entry->inventory_policy->value);
|
||||
$this->assertTrue($entry->has_tickets);
|
||||
$this->assertNull($entry->inventory_id);
|
||||
@@ -168,6 +167,35 @@ class EntryControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_deletes_an_entry_and_its_inventory(): void
|
||||
{
|
||||
$tenant = $this->createFiestaTenant();
|
||||
$this->createEventDateAttribute($tenant);
|
||||
$eventDate = $tenant->eventDates()->create([
|
||||
'date' => '2026-10-09',
|
||||
'time_start' => '00:00',
|
||||
'time_end' => '23:59',
|
||||
]);
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$entryId = $this->postJson('/api/v1/adminapp/tenant/entries', [
|
||||
'entries' => [[
|
||||
'title' => 'Entrada diaria',
|
||||
'description' => null,
|
||||
'event_date_ids' => [$eventDate->id],
|
||||
'stock' => 100,
|
||||
'price' => 5000,
|
||||
]],
|
||||
])->assertOk()->json('data.0.id');
|
||||
|
||||
$this->deleteJson("/api/v1/adminapp/tenant/entries/{$entryId}")
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertDatabaseCount('catalog_items', 0);
|
||||
$this->assertDatabaseCount('variantes', 0);
|
||||
$this->assertDatabaseCount('inventories', 0);
|
||||
}
|
||||
|
||||
public function test_dates_must_belong_to_the_authenticated_tenant(): void
|
||||
{
|
||||
$tenant = $this->createFiestaTenant();
|
||||
@@ -195,6 +223,46 @@ class EntryControllerTest extends TestCase
|
||||
$this->assertDatabaseCount('catalog_items', 0);
|
||||
}
|
||||
|
||||
public function test_entries_are_identified_by_the_entries_category(): void
|
||||
{
|
||||
$tenant = $this->createFiestaTenant();
|
||||
$this->createEventDateAttribute($tenant);
|
||||
$eventDate = $tenant->eventDates()->create([
|
||||
'date' => '2026-10-09',
|
||||
'time_start' => '00:00',
|
||||
'time_end' => '23:59',
|
||||
]);
|
||||
$merchandisingCategory = Category::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'nombre' => 'Merchandising',
|
||||
]);
|
||||
$merchandise = CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'category_id' => $merchandisingCategory->id,
|
||||
'slug' => 'camiseta',
|
||||
'nombre' => 'Camiseta',
|
||||
'precio' => 100,
|
||||
]);
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$this->getJson('/api/v1/adminapp/tenant/entries')
|
||||
->assertOk()
|
||||
->assertJsonCount(0, 'data');
|
||||
|
||||
$this->postJson('/api/v1/adminapp/tenant/entries', [
|
||||
'entries' => [[
|
||||
'id' => $merchandise->id,
|
||||
'title' => 'Entrada inválida',
|
||||
'description' => null,
|
||||
'event_date_ids' => [$eventDate->id],
|
||||
'stock' => 10,
|
||||
'price' => 100,
|
||||
]],
|
||||
])
|
||||
->assertUnprocessable()
|
||||
->assertJsonValidationErrors(['entries.0.id']);
|
||||
}
|
||||
|
||||
public function test_the_endpoint_is_only_available_for_fiesta_futbol_infantil(): void
|
||||
{
|
||||
$tenant = $this->createTenant('other');
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Tests\Feature\FiestaFutbolInfantil;
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
use App\Domains\Menu\Models\Menu;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
@@ -68,10 +69,27 @@ class FoodControllerTest extends TestCase
|
||||
$this->assertDatabaseCount('inventories', 2);
|
||||
$this->assertDatabaseCount('variant_event_dates', 2);
|
||||
$this->assertDatabaseCount('variant_values', 4);
|
||||
$food = CatalogItem::query()->where('slug', 'comida')->sole();
|
||||
$this->assertDatabaseHas('item_attributes', [
|
||||
'catalog_item_id' => $food->id,
|
||||
'attribute_id' => Attribute::query()->where('codigo', 'event_date')->sole()->id,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
$this->assertDatabaseHas('item_attributes', [
|
||||
'catalog_item_id' => $food->id,
|
||||
'attribute_id' => Attribute::query()->where('codigo', 'horario')->sole()->id,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
$this->assertDatabaseHas('item_attributes', [
|
||||
'catalog_item_id' => $food->id,
|
||||
'attribute_id' => Attribute::query()->where('codigo', 'servicio')->sole()->id,
|
||||
'sort_order' => 3,
|
||||
]);
|
||||
$this->assertDatabaseHas('catalog_items', [
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'slug' => 'comida',
|
||||
'nombre' => 'Comida',
|
||||
'has_tickets' => true,
|
||||
'category_id' => Category::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('nombre', 'Comidas')
|
||||
@@ -123,6 +141,39 @@ class FoodControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_deletes_food_records_and_removes_the_empty_product(): void
|
||||
{
|
||||
[$tenant, $firstDate, $secondDate] = $this->configuredTenant();
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$created = $this->postJson('/api/v1/adminapp/tenant/foods', [
|
||||
'variants' => [
|
||||
$this->variantPayload($firstDate->id, 'Almuerzo', 'Comedor', 100, 10000),
|
||||
$this->variantPayload($secondDate->id, 'Cena', 'Vianda', 50, 8000),
|
||||
],
|
||||
])->assertOk();
|
||||
$firstId = $created->json('data.variants.0.id');
|
||||
$secondId = $created->json('data.variants.1.id');
|
||||
|
||||
$this->deleteJson("/api/v1/adminapp/tenant/foods/{$secondId}")
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertDatabaseHas('catalog_items', [
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'slug' => 'comida',
|
||||
'precio' => 10000,
|
||||
]);
|
||||
$this->assertDatabaseCount('variantes', 1);
|
||||
$this->assertDatabaseCount('inventories', 1);
|
||||
|
||||
$this->deleteJson("/api/v1/adminapp/tenant/foods/{$firstId}")
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertDatabaseCount('catalog_items', 0);
|
||||
$this->assertDatabaseCount('variantes', 0);
|
||||
$this->assertDatabaseCount('inventories', 0);
|
||||
}
|
||||
|
||||
public function test_it_rejects_duplicate_combinations(): void
|
||||
{
|
||||
[$tenant, $firstDate] = $this->configuredTenant();
|
||||
|
||||
@@ -90,6 +90,7 @@ class MerchandiseControllerTest extends TestCase
|
||||
'precio' => 95000,
|
||||
'max_units_per_user' => 3,
|
||||
'inventory_policy' => 'tracked',
|
||||
'has_tickets' => true,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -139,6 +140,40 @@ class MerchandiseControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_deletes_merchandise_records_and_removes_the_empty_item(): void
|
||||
{
|
||||
[$tenant] = $this->configuredTenant();
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$created = $this->postJson('/api/v1/adminapp/tenant/merchandise', [
|
||||
'items' => [
|
||||
$this->itemPayload('Camiseta', 3, [
|
||||
$this->variantPayload('Verde', 'S', 100, 10000),
|
||||
$this->variantPayload('Blanco', 'M', 50, 15000),
|
||||
]),
|
||||
],
|
||||
])->assertOk();
|
||||
$itemId = $created->json('data.0.id');
|
||||
$firstId = $created->json('data.0.variants.0.id');
|
||||
$secondId = $created->json('data.0.variants.1.id');
|
||||
|
||||
$this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$firstId}")
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertDatabaseHas('catalog_items', [
|
||||
'id' => $itemId,
|
||||
'precio' => 15000,
|
||||
]);
|
||||
$this->assertDatabaseCount('variantes', 1);
|
||||
|
||||
$this->deleteJson("/api/v1/adminapp/tenant/merchandise/{$secondId}")
|
||||
->assertNoContent();
|
||||
|
||||
$this->assertDatabaseMissing('catalog_items', ['id' => $itemId]);
|
||||
$this->assertDatabaseCount('variantes', 0);
|
||||
$this->assertDatabaseCount('inventories', 0);
|
||||
}
|
||||
|
||||
public function test_it_rejects_duplicate_color_and_size_combinations(): void
|
||||
{
|
||||
[$tenant] = $this->configuredTenant();
|
||||
|
||||
102
tests/Feature/Sale/AdminAppSaleControllerTest.php
Normal file
102
tests/Feature/Sale/AdminAppSaleControllerTest.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Sale;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Models\PurchaseItem;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use Database\Seeders\AuthorizationSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminAppSaleControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->seed(AuthorizationSeeder::class);
|
||||
WebsiteType::query()->create(['codigo' => 'onticket', 'nombre' => 'OnTicket']);
|
||||
}
|
||||
|
||||
public function test_authentication_is_required_to_read_a_sale_detail(): void
|
||||
{
|
||||
$this->getJson('/api/v1/adminapp/tenant/sales/1')->assertUnauthorized();
|
||||
}
|
||||
|
||||
public function test_an_adminapp_user_can_read_a_sale_detail_from_its_tenant(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$purchase = Purchase::query()->create([
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'status' => Purchase::STATUS_PAID,
|
||||
'total' => '40000.00',
|
||||
'nombre_apellido' => 'Ada Lovelace',
|
||||
]);
|
||||
PurchaseItem::query()->create([
|
||||
'compra_id' => $purchase->id,
|
||||
'source_catalog_item_id' => 10,
|
||||
'nombre' => 'Comida',
|
||||
'item_nombre' => 'Comida',
|
||||
'variant_attributes' => [
|
||||
['name' => 'Fecha', 'value' => ['2026-10-12']],
|
||||
['name' => 'Servicio', 'value' => 'Almuerzo'],
|
||||
],
|
||||
'cantidad' => 2,
|
||||
'precio_unitario' => '10000.00',
|
||||
'total' => '20000.00',
|
||||
]);
|
||||
|
||||
$this->getJson("/api/v1/adminapp/tenant/sales/{$purchase->id}")
|
||||
->assertOk()
|
||||
->assertJsonPath('data.id', $purchase->id)
|
||||
->assertJsonPath('data.items.0.product', 'Comida')
|
||||
->assertJsonPath('data.items.0.event_dates.0', '2026-10-12')
|
||||
->assertJsonPath('data.items.0.quantity', 2)
|
||||
->assertJsonPath('data.items.0.unit_price', '10000.00')
|
||||
->assertJsonPath('data.items.0.total', '20000.00')
|
||||
->assertJsonPath('data.total', '40000.00');
|
||||
}
|
||||
|
||||
public function test_an_adminapp_user_cannot_read_a_sale_from_another_tenant(): void
|
||||
{
|
||||
$tenant = $this->createTenant('acme');
|
||||
$otherTenant = $this->createTenant('other');
|
||||
Sanctum::actingAs($this->createAdminAppUser($tenant));
|
||||
|
||||
$foreignPurchase = Purchase::query()->create([
|
||||
'tenant_codigo' => $otherTenant->codigo,
|
||||
'status' => Purchase::STATUS_PAID,
|
||||
'total' => '10000.00',
|
||||
]);
|
||||
|
||||
$this->getJson("/api/v1/adminapp/tenant/sales/{$foreignPurchase->id}")
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
private function createTenant(string $code): Tenant
|
||||
{
|
||||
return Tenant::query()->create([
|
||||
'codigo' => $code,
|
||||
'nombre' => ucfirst($code),
|
||||
'dominio' => "{$code}.test",
|
||||
'website_type_code' => 'onticket',
|
||||
]);
|
||||
}
|
||||
|
||||
private function createAdminAppUser(Tenant $tenant): User
|
||||
{
|
||||
return User::factory()->create([
|
||||
'rol_codigo' => RoleCode::AdminApp->value,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ namespace Tests\Feature\Seeders;
|
||||
use App\Domains\Attachable\Enums\AttachmentType;
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Catalog\Models\Attribute;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Category;
|
||||
@@ -56,6 +58,12 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
||||
['abono', 'alojamiento', 'camiseta', 'comida'],
|
||||
CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('slug')->pluck('slug')->all(),
|
||||
);
|
||||
$this->assertTrue(
|
||||
CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->get()
|
||||
->every(fn (CatalogItem $item): bool => $item->has_tickets),
|
||||
);
|
||||
|
||||
$expectedVariantCounts = [
|
||||
'camiseta' => 12,
|
||||
@@ -113,6 +121,8 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
||||
|
||||
$featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole();
|
||||
$this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type);
|
||||
$this->assertSame(ProductLayout::Row, $featuredGroup->product_layout);
|
||||
$this->assertSame(GroupLayout::SimpleVertical, $featuredGroup->group_layout);
|
||||
$this->assertNull($featuredGroup->category_id);
|
||||
}
|
||||
|
||||
|
||||
@@ -84,6 +84,52 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
$this->service->generate($item->fresh(), $this->user);
|
||||
}
|
||||
|
||||
public function test_variant_properties_are_appended_to_the_ticket_name(): void
|
||||
{
|
||||
$item = $this->createTicketableItem('shirt');
|
||||
$color = Attribute::query()->create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'codigo' => 'color',
|
||||
'nombre' => 'Color',
|
||||
'type' => FieldType::Select,
|
||||
]);
|
||||
$color->options()->create([
|
||||
'value' => 'black',
|
||||
'label' => 'Negro',
|
||||
]);
|
||||
$size = Attribute::query()->create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'codigo' => 'size',
|
||||
'nombre' => 'Talle',
|
||||
'type' => FieldType::Select,
|
||||
]);
|
||||
$size->options()->create([
|
||||
'value' => 'xl',
|
||||
'label' => 'XL',
|
||||
]);
|
||||
$itemColor = $item->itemAttributes()->create([
|
||||
'attribute_id' => $color->id,
|
||||
'sort_order' => 1,
|
||||
]);
|
||||
$itemSize = $item->itemAttributes()->create([
|
||||
'attribute_id' => $size->id,
|
||||
'sort_order' => 2,
|
||||
]);
|
||||
$variant = $item->variants()->create([
|
||||
'inventory_id' => Inventory::query()->create()->id,
|
||||
]);
|
||||
$variant->definitions()->createMany([
|
||||
['item_attribute_id' => $itemColor->id, 'value' => 'black'],
|
||||
['item_attribute_id' => $itemSize->id, 'value' => 'xl'],
|
||||
]);
|
||||
|
||||
$ticket = $this->service
|
||||
->generate($item, $this->user, 1, $variant->id)
|
||||
->sole();
|
||||
|
||||
$this->assertSame('Shirt (Negro, XL)', $ticket->name);
|
||||
}
|
||||
|
||||
public function test_it_generates_tickets_for_every_bundle_component_and_quantity(): void
|
||||
{
|
||||
$first = $this->createTicketableItem('first');
|
||||
@@ -257,6 +303,10 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
['2026-08-20 00:00:00', '2026-08-21 00:00:00'],
|
||||
$tickets->map(fn ($ticket): string => $ticket->validityTime->fixed_starts_at->format('Y-m-d H:i:s'))->all(),
|
||||
);
|
||||
$this->assertSame(
|
||||
['Multi-date-pass (20/08/2026)', 'Multi-date-pass (21/08/2026)'],
|
||||
$tickets->pluck('name')->all(),
|
||||
);
|
||||
$this->assertSame([$variant->id], $tickets->pluck('source_variant_id')->unique()->values()->all());
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Tests\Unit\Catalog;
|
||||
|
||||
use App\Domains\Attachable\Models\Attachment;
|
||||
use App\Domains\Catalog\Enums\CatalogItemType;
|
||||
use App\Domains\Catalog\Enums\EventProductType;
|
||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
||||
use App\Domains\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||
@@ -76,7 +75,6 @@ class CatalogModelsTest extends TestCase
|
||||
'category_id' => '10',
|
||||
'brand_id' => '20',
|
||||
'inventory_id' => '30',
|
||||
'event_product_type' => EventProductType::Entry->value,
|
||||
'type' => CatalogItemType::Standard->value,
|
||||
'precio' => '12.50',
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
@@ -88,7 +86,6 @@ class CatalogModelsTest extends TestCase
|
||||
$this->assertSame(10, $item->category_id);
|
||||
$this->assertSame(20, $item->brand_id);
|
||||
$this->assertSame(30, $item->inventory_id);
|
||||
$this->assertSame(EventProductType::Entry, $item->event_product_type);
|
||||
$this->assertSame(CatalogItemType::Standard, $item->type);
|
||||
$this->assertSame('12.50', $item->precio);
|
||||
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
|
||||
@@ -186,6 +183,80 @@ class CatalogModelsTest extends TestCase
|
||||
$this->assertSame('2026-10-09', $variant->eventDate->date->format('Y-m-d'));
|
||||
}
|
||||
|
||||
public function test_variant_exposes_selection_values_with_api_labels(): void
|
||||
{
|
||||
$attribute = new Attribute;
|
||||
$attribute->codigo = 'size';
|
||||
$attribute->setRelation('options', new EloquentCollection([
|
||||
new AttributeOption(['value' => 'M', 'label' => 'Medium']),
|
||||
]));
|
||||
|
||||
$itemAttribute = new ItemAttribute;
|
||||
$itemAttribute->allow_multi_select = false;
|
||||
$itemAttribute->setRelation('attribute', $attribute);
|
||||
|
||||
$definition = new VariantDefinition(['value' => 'M']);
|
||||
$definition->item_attribute_id = 1;
|
||||
$definition->setRelation('itemAttribute', $itemAttribute);
|
||||
|
||||
$eventDate = new EventDate([
|
||||
'date' => '2026-10-09',
|
||||
'time_start' => '09:00:00',
|
||||
'time_end' => '18:00:00',
|
||||
]);
|
||||
$eventDate->id = 20;
|
||||
|
||||
$variant = new Variant;
|
||||
$variant->setRelation('definitions', new EloquentCollection([$definition]));
|
||||
$variant->setRelation('eventDates', new EloquentCollection([$eventDate]));
|
||||
|
||||
$this->assertSame(
|
||||
['value' => 'M', 'label' => 'Medium'],
|
||||
$variant->selectionOptions()->get('size'),
|
||||
);
|
||||
$this->assertSame(
|
||||
['value' => '20', 'label' => '09/10/2026'],
|
||||
$variant->selectionOptions()->get('event_date'),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_variant_orders_selection_options_by_item_order_and_attribute_label(): void
|
||||
{
|
||||
$definitions = collect([
|
||||
['id' => 1, 'code' => 'zeta', 'label' => 'Zeta', 'sort_order' => 2],
|
||||
['id' => 2, 'code' => 'priority', 'label' => 'Priority', 'sort_order' => 1],
|
||||
['id' => 3, 'code' => 'alpha', 'label' => 'Alpha', 'sort_order' => 2],
|
||||
])->map(function (array $data): VariantDefinition {
|
||||
$attribute = new Attribute([
|
||||
'codigo' => $data['code'],
|
||||
'nombre' => $data['label'],
|
||||
]);
|
||||
$attribute->setRelation('options', new EloquentCollection);
|
||||
|
||||
$itemAttribute = new ItemAttribute([
|
||||
'sort_order' => $data['sort_order'],
|
||||
'allow_multi_select' => false,
|
||||
]);
|
||||
$itemAttribute->id = $data['id'];
|
||||
$itemAttribute->setRelation('attribute', $attribute);
|
||||
|
||||
$definition = new VariantDefinition(['value' => $data['code']]);
|
||||
$definition->item_attribute_id = $itemAttribute->id;
|
||||
$definition->setRelation('itemAttribute', $itemAttribute);
|
||||
|
||||
return $definition;
|
||||
});
|
||||
|
||||
$variant = new Variant;
|
||||
$variant->setRelation('definitions', new EloquentCollection($definitions));
|
||||
$variant->setRelation('eventDates', new EloquentCollection);
|
||||
|
||||
$this->assertSame(
|
||||
['priority', 'alpha', 'zeta'],
|
||||
$variant->selectionOptions()->keys()->all(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_inventory_maps_stock_without_a_polymorphic_owner(): void
|
||||
{
|
||||
$inventory = $this->trackedInventory(realStock: 10, reservedStock: 3);
|
||||
@@ -212,6 +283,27 @@ class CatalogModelsTest extends TestCase
|
||||
$this->assertTrue($item->isAvailable());
|
||||
}
|
||||
|
||||
public function test_catalog_item_only_exposes_available_tracked_variants(): void
|
||||
{
|
||||
$unavailable = (new Variant)->setRelation('inventory', $this->trackedInventory(3, 3));
|
||||
$available = (new Variant)->setRelation('inventory', $this->trackedInventory(5, 2));
|
||||
$unavailable->id = 10;
|
||||
$available->id = 20;
|
||||
$item = new CatalogItem;
|
||||
$item->inventory_policy = InventoryPolicy::Tracked;
|
||||
$item->setRelation('variants', new EloquentCollection([$unavailable, $available]));
|
||||
|
||||
$this->assertSame([$available], $item->visibleVariants()->all());
|
||||
$this->assertSame(
|
||||
[$unavailable, $available],
|
||||
$item->visibleVariants($unavailable->id)->all(),
|
||||
);
|
||||
|
||||
$item->inventory_policy = InventoryPolicy::Unlimited;
|
||||
|
||||
$this->assertSame([$unavailable, $available], $item->visibleVariants()->all());
|
||||
}
|
||||
|
||||
public function test_catalog_item_prioritizes_its_inventory_over_variants(): void
|
||||
{
|
||||
$item = new CatalogItem;
|
||||
|
||||
Reference in New Issue
Block a user