Compare commits

...

32 Commits

Author SHA1 Message Date
3c646f3560 test(catalog): cover global availability decisions 2026-08-25 09:03:09 -03:00
98bfc0d5e9 feat(catalog): model availability as global decisions 2026-08-25 09:03:04 -03:00
d9635d47ba refactor(catalog): extract visible items query 2026-08-25 09:02:54 -03:00
e27f7bb166 feat(catalog): omit hidden products before pagination 2026-08-25 08:21:01 -03:00
2323994f30 feat(catalog): scope availability policies by subject 2026-08-25 08:21:01 -03:00
a73b628bb4 test(catalog): cover composed availability restrictions 2026-08-25 08:21:01 -03:00
2fd6851b40 feat(catalog): expose availability policies 2026-08-25 08:21:00 -03:00
ceb1d1f1af Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 17:01:55 -03:00
885ab2a6e3 feat(category-visibility): add success messages for category visibility updates 2026-08-24 17:01:46 -03:00
47196a572a feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 17:01:46 -03:00
45bba9516d feat(category-visibility): add success messages for category visibility updates 2026-08-24 17:00:48 -03:00
c6da71894e feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 17:00:48 -03:00
75d3c819d9 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 17:00:48 -03:00
bd6d672df2 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 17:00:48 -03:00
6b06028771 fix(sales): render PDFs in client timezone 2026-08-24 16:42:38 -03:00
0aa423e90e feat(sales): validate PDF timezone parameter 2026-08-24 16:42:38 -03:00
e7bf2f887a Merge branch 'hotfix/change_history_time' 2026-08-24 16:13:19 -03:00
80a02fdc52 feat(sale): add changed_at serialization to SaleModificationResource 2026-08-24 16:12:55 -03:00
4162e2c1cc Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 16:05:15 -03:00
8d01620fa8 feat(sale-modification): add changed_at field serialization to ISO 8601 format 2026-08-24 16:05:10 -03:00
f8b8bab474 feat(category-visibility): add success messages for category visibility updates 2026-08-24 16:05:10 -03:00
53d1415b18 feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 16:05:10 -03:00
6ff9119086 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 16:05:10 -03:00
24e000b423 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 16:05:10 -03:00
c69ebe5f7c feat(sale-modification): add changed_at field serialization to ISO 8601 format 2026-08-24 15:59:00 -03:00
c3a2da607d Merge branch 'dev' into homo 2026-08-24 12:45:51 -03:00
d1c4acc7ae feat(category-visibility): add success messages for category visibility updates 2026-08-24 12:44:37 -03:00
f8c4cc7428 Merge branch 'dev' into homo 2026-08-24 12:36:57 -03:00
21cfbe2a46 feat(purchase): add filtering for multiple statuses in purchase index and order by status then date 2026-08-24 12:35:20 -03:00
abc060ab25 Merge branch 'homo' of https://gitea.quo.ar/tbianchini/shopit-back into homo 2026-08-24 11:32:09 -03:00
80adbd9ca9 feat(category-visibility): implement category visibility controller and service with update functionality 2026-08-24 11:31:26 -03:00
12c10bbe06 feat(catalog): add is_enabled attribute to categories and filter items accordingly 2026-08-24 10:49:14 -03:00
45 changed files with 1066 additions and 178 deletions

View File

@@ -123,6 +123,10 @@ class CatalogController extends Controller
$variantId === null ? null : (int) $variantId,
);
$allowances->attach(collect([$item]), $this->userId($request));
abort_unless($allowances->availability(
$item->availableStock(),
$item->getAttribute('remaining_user_quota'),
)->isVisible(), 404);
return CatalogItemDetailResource::make($item);
}

View File

@@ -0,0 +1,10 @@
<?php
namespace App\Domains\Catalog\Enums;
enum AvailabilityEffect: string
{
case Hide = 'hide';
case Restrict = 'restrict';
case Notice = 'notice';
}

View File

@@ -0,0 +1,11 @@
<?php
namespace App\Domains\Catalog\Enums;
enum CatalogAction: string
{
case SelectVariant = 'select_variant';
case ChangeQuantity = 'change_quantity';
case AddToCart = 'add_to_cart';
case BuyNow = 'buy_now';
}

View File

@@ -15,6 +15,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
'tenant_code',
'categoria_id',
'nombre',
'is_enabled',
])]
class Category extends Model
{
@@ -22,6 +23,10 @@ class Category extends Model
protected $table = 'categorias';
protected $attributes = [
'is_enabled' => true,
];
/**
* @return array<string, string>
*/
@@ -29,6 +34,7 @@ class Category extends Model
{
return [
'categoria_id' => 'integer',
'is_enabled' => 'boolean',
];
}

View File

@@ -38,19 +38,13 @@ class CatalogFeaturedItemResource extends JsonResource
'nombre' => $catalogItem->nombre,
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'maximum_addable_quantity' => $this->maximumAddable(
$availableStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$availableStock,
$remainingUserQuota,
),
'availability' => $this->availability($availableStock, $remainingUserQuota),
'variants' => $catalogItem->variants
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
->map(function (Variant $variant) use ($catalogItem): array {
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory->availableStock();
$availability = $this->availability($variantStock, null);
return [
'id' => $variant->id,
@@ -60,17 +54,11 @@ class CatalogFeaturedItemResource extends JsonResource
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable(
$variantStock,
$remainingUserQuota,
),
'unavailable_message' => $this->unavailableMessage(
$variantStock,
$remainingUserQuota,
),
'availability' => $availability,
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
})
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
->values(),
];
@@ -90,8 +78,7 @@ class CatalogFeaturedItemResource extends JsonResource
'descripcion' => $catalogItem->descripcion,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
'availability' => $this->availability($availableStock, $remainingUserQuota),
];
}
@@ -107,8 +94,7 @@ class CatalogFeaturedItemResource extends JsonResource
'nombre' => $catalogItem->nombre,
'precio' => $catalogItem->precio,
'image' => $this->firstImageUrl($catalogItem),
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
'availability' => $this->availability($availableStock, $remainingUserQuota),
];
}
@@ -122,15 +108,13 @@ class CatalogFeaturedItemResource extends JsonResource
return $attachment?->getTemporaryUrl(1440);
}
private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int
{
/** @return array<string, mixed> */
private function availability(
?int $stock,
?int $remainingUserQuota,
): array {
return app(CatalogItemAllowanceService::class)
->maximumAddableQuantity($stock, $remainingUserQuota);
}
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
{
return app(CatalogItemAllowanceService::class)
->unavailableMessage($stock, $remainingUserQuota);
->availability($stock, $remainingUserQuota)
->toArray();
}
}

View File

@@ -38,20 +38,14 @@ class CatalogItemDetailResource extends JsonResource
'max_units_per_user' => $this->max_units_per_user,
'has_tickets' => $this->has_tickets,
'attributes' => $this->attributesData(),
'maximum_addable_quantity' => $this->when(
$selectedVariant === null,
fn () => $this->maximumAddable($this->availableStock()),
),
'unavailable_message' => $this->when(
$selectedVariant === null,
fn () => $this->unavailableMessage($this->availableStock()),
),
'availability' => $this->availability($this->availableStock()),
'images' => $this->when(
$selectedVariant === null,
fn () => $this->imageUrls($this->attachments),
),
'variants' => $this->variants
->map(fn (Variant $variant): array => $this->variantData($variant))
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
->values(),
'selected_variant' => $this->when(
$selectedVariant !== null,
@@ -164,6 +158,10 @@ class CatalogItemDetailResource extends JsonResource
$values = $variant->selectionOptions($this->itemAttributes);
$eventDates = $variant->selectedEventDates();
$variantStock = $this->variantStock($variant);
$availability = $this->availability(
$variantStock,
false,
);
return [
'id' => $variant->id,
@@ -173,8 +171,7 @@ class CatalogItemDetailResource extends JsonResource
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'availability' => $availability,
'values' => $values,
];
}
@@ -194,19 +191,14 @@ class CatalogItemDetailResource extends JsonResource
: $variant->inventory->availableStock();
}
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
/** @return array<string, mixed> */
private function availability(
?int $stock,
bool $includeUserQuota = true,
): array {
return app(CatalogItemAllowanceService::class)->availability(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
$includeUserQuota ? $this->getAttribute('remaining_user_quota') : null,
)->toArray();
}
}

View File

@@ -28,13 +28,16 @@ class CatalogSearchItemResource extends JsonResource
'descripcion' => $this->descripcion,
'precio' => $this->precio,
'image' => $attachment?->getTemporaryUrl(1440),
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
'unavailable_message' => $this->unavailableMessage($availableStock),
'availability' => $this->availability($availableStock),
'variants' => $this->variants
->map(function (Variant $variant): array {
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock();
$availability = $this->availability(
$variantStock,
false,
);
return [
'id' => $variant->id,
@@ -44,28 +47,23 @@ class CatalogSearchItemResource extends JsonResource
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
'unavailable_message' => $this->unavailableMessage($variantStock),
'availability' => $availability,
'values' => $variant->selectorOptions($this->itemAttributes),
];
})
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
->values(),
];
}
private function maximumAddable(?int $stock): ?int
{
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
/** @return array<string, mixed> */
private function availability(
?int $stock,
bool $includeUserQuota = true,
): array {
return app(CatalogItemAllowanceService::class)->availability(
$stock,
$this->getAttribute('remaining_user_quota'),
);
}
private function unavailableMessage(?int $stock): ?string
{
return app(CatalogItemAllowanceService::class)->unavailableMessage(
$stock,
$this->getAttribute('remaining_user_quota'),
);
$includeUserQuota ? $this->getAttribute('remaining_user_quota') : null,
)->toArray();
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\CatalogAction;
final readonly class AvailabilityDecision
{
/**
* @param list<CatalogAction> $allowedActions
* @param list<array{code: string, message: string}> $reasons
*/
private function __construct(
private bool $visible,
private ?int $maximumQuantity,
private array $allowedActions,
private array $reasons,
) {}
/** @param list<array{code: string, message: string}> $reasons */
public static function hidden(array $reasons): self
{
return new self(false, null, [], $reasons);
}
/**
* @param list<CatalogAction> $allowedActions
* @param list<array{code: string, message: string}> $reasons
*/
public static function visible(
?int $maximumQuantity,
array $allowedActions,
array $reasons,
): self {
return new self(true, $maximumQuantity, $allowedActions, $reasons);
}
public function isVisible(): bool
{
return $this->visible;
}
/** @return array<string, mixed> */
public function toArray(): array
{
if (! $this->visible) {
return [
'state' => 'hidden',
'reasons' => $this->reasons,
];
}
return [
'state' => 'visible',
'maximum_quantity' => $this->maximumQuantity,
'allowed_actions' => array_map(
fn (CatalogAction $action): string => $action->value,
$this->allowedActions,
),
'reasons' => $this->reasons,
];
}
}

View File

@@ -0,0 +1,26 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\AvailabilityEffect;
use App\Domains\Catalog\Enums\CatalogAction;
final class AvailabilityPolicyResolver
{
/** @return array{effect: AvailabilityEffect, denied_actions: list<CatalogAction>} */
public function resolve(string $restrictionCode): array
{
/** @var array{effect?: string, denied_actions?: list<string>} $configured */
$configured = config("catalog.availability.rules.{$restrictionCode}", []);
return [
'effect' => AvailabilityEffect::from(
$configured['effect'] ?? AvailabilityEffect::Notice->value,
),
'denied_actions' => array_map(
fn (string $action): CatalogAction => CatalogAction::from($action),
$configured['denied_actions'] ?? [],
),
];
}
}

View File

@@ -2,6 +2,8 @@
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\AvailabilityEffect;
use App\Domains\Catalog\Enums\CatalogAction;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Collection;
@@ -14,6 +16,7 @@ class CatalogItemAllowanceService
public function __construct(
private readonly UserPurchaseLimitService $purchaseLimits,
private readonly AvailabilityPolicyResolver $policies,
) {}
/** @param Collection<int, CatalogItem> $catalogItems */
@@ -42,16 +45,82 @@ class CatalogItemAllowanceService
return min($availableStock, $remainingUserQuota);
}
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
{
public function availability(
?int $availableStock,
?int $remainingUserQuota,
): AvailabilityDecision {
$reasons = [];
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
return self::USER_QUOTA_REACHED_MESSAGE;
$reasons[] = [
'code' => 'user_quota_reached',
'message' => self::USER_QUOTA_REACHED_MESSAGE,
];
}
if ($availableStock !== null && $availableStock <= 0) {
return self::OUT_OF_STOCK_MESSAGE;
$reasons[] = [
'code' => 'out_of_stock',
'message' => self::OUT_OF_STOCK_MESSAGE,
];
}
return null;
return $this->decision(
$this->maximumAddableQuantity($availableStock, $remainingUserQuota),
$reasons,
);
}
public function purchaseLimitExceededAvailability(
int $maximumQuantity,
string $message,
): AvailabilityDecision {
$reasons = [];
if ($maximumQuantity <= 0) {
$reasons[] = [
'code' => 'user_quota_reached',
'message' => self::USER_QUOTA_REACHED_MESSAGE,
];
} else {
$reasons[] = [
'code' => 'requested_quantity_exceeds_user_quota',
'message' => $message,
];
}
return $this->decision($maximumQuantity, $reasons);
}
/**
* @param list<array{code: string, message: string}> $reasons
*/
private function decision(?int $maximumQuantity, array $reasons): AvailabilityDecision
{
/** @var list<string> $configuredActions */
$configuredActions = config('catalog.availability.default_actions', []);
$allowedActions = collect($configuredActions)
->map(fn (string $action): CatalogAction => CatalogAction::from($action));
foreach ($reasons as $reason) {
$policy = $this->policies->resolve($reason['code']);
if ($policy['effect'] === AvailabilityEffect::Hide) {
return AvailabilityDecision::hidden($reasons);
}
if ($policy['effect'] === AvailabilityEffect::Restrict) {
$deniedActions = $policy['denied_actions'];
$allowedActions = $allowedActions->reject(
fn (CatalogAction $action): bool => in_array($action, $deniedActions, true),
);
}
}
return AvailabilityDecision::visible(
$maximumQuantity,
$allowedActions->values()->all(),
$reasons,
);
}
}

View File

@@ -22,7 +22,10 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class CatalogService
{
public function __construct(protected AttachmentService $attachmentService) {}
public function __construct(
protected AttachmentService $attachmentService,
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
) {}
/**
* @param array<string, mixed> $data
@@ -229,7 +232,7 @@ class CatalogService
$containsPattern = "%{$normalizedTerm}%";
$startsWithPattern = "{$normalizedTerm}%";
$paginator = CatalogItem::query()
$query = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where(function (Builder $query) use ($containsPattern): void {
$query
@@ -257,7 +260,10 @@ class CatalogService
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
]);
$paginator = $this->visibleCatalogItems
->apply($query)
->orderByRaw(
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
[$normalizedTerm, $startsWithPattern],
@@ -277,7 +283,7 @@ class CatalogService
int $perPage,
int $page,
): LengthAwarePaginator {
return CatalogItem::query()
$query = CatalogItem::query()
->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id)
->with([
@@ -291,7 +297,10 @@ class CatalogService
'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem',
])
]);
return $this->visibleCatalogItems
->apply($query)
->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page);
}

View File

@@ -17,6 +17,7 @@ class FeaturedGroupService
/** @return array<array-key, mixed> */
public function __construct(
private readonly CatalogItemAllowanceService $allowances,
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
) {}
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
@@ -49,6 +50,15 @@ class FeaturedGroupService
{
$query = CatalogItem::query()
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
->where(function (Builder $query): void {
$query
->whereDoesntHave('category')
->orWhereHas(
'category',
fn (Builder $categoryQuery): Builder => $categoryQuery
->where('is_enabled', true),
);
})
->with([
'inventory',
'attachments',
@@ -62,6 +72,8 @@ class FeaturedGroupService
'bundleComponents.variant.catalogItem',
]);
$query = $this->visibleCatalogItems->apply($query);
return match ($featuredGroup->source_type) {
FeaturedGroupSource::Manual => $query
->select('catalog_items.*')

View File

@@ -10,6 +10,10 @@ use Illuminate\Support\Collection;
class VariantSelectionService
{
public function __construct(
private readonly CatalogItemAllowanceService $allowances,
) {}
/**
* @param array<string, mixed> $selectedValues
* @return array<string, mixed>
@@ -188,13 +192,17 @@ class VariantSelectionService
/** @return array<string, mixed> */
private function variantData(CatalogItem $catalogItem, Variant $variant): array
{
$availableStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock();
return [
'id' => $variant->id,
'descripcion' => $variant->getDescription(),
'precio' => number_format($variant->getPrice(), 2, '.', ''),
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
? null
: $variant->inventory?->availableStock(),
'availability' => $this->allowances
->availability($availableStock, null)
->toArray(),
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
];
}

View File

@@ -0,0 +1,81 @@
<?php
namespace App\Domains\Catalog\Services;
use App\Domains\Catalog\Enums\AvailabilityEffect;
use App\Domains\Catalog\Enums\CatalogItemType;
use App\Domains\Catalog\Enums\InventoryPolicy;
use App\Domains\Catalog\Models\CatalogItem;
use Illuminate\Database\Eloquent\Builder;
final class VisibleCatalogItemsQuery
{
public function __construct(
private readonly AvailabilityPolicyResolver $policies,
) {}
/** @param Builder<CatalogItem> $query */
public function apply(Builder $query): Builder
{
if ($this->policies->resolve('out_of_stock')['effect'] !== AvailabilityEffect::Hide) {
return $query;
}
return $query->where(function (Builder $query): void {
$query
->where(fn (Builder $query) => $this->applyStandardItemAvailability($query))
->orWhere(fn (Builder $query) => $this->applyBundleAvailability($query));
});
}
/** @param Builder<CatalogItem> $query */
private function applyStandardItemAvailability(Builder $query): Builder
{
return $query
->where('catalog_items.type', CatalogItemType::Standard->value)
->where(function (Builder $query): void {
$query
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
->orWhereHas(
'inventory',
fn (Builder $query): Builder => $query
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock'),
)
->orWhereHas(
'variants.inventory',
fn (Builder $query): Builder => $query
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock'),
);
});
}
/** @param Builder<CatalogItem> $query */
private function applyBundleAvailability(Builder $query): Builder
{
return $query
->where('catalog_items.type', CatalogItemType::Bundle->value)
->whereRaw(<<<'SQL'
NOT EXISTS (
SELECT 1
FROM bundle_components AS availability_components
INNER JOIN catalog_items AS availability_items
ON availability_items.id = availability_components.component_catalog_item_id
LEFT JOIN variantes AS availability_variants
ON availability_variants.id = availability_components.component_variant_id
INNER JOIN inventories AS availability_inventories
ON availability_inventories.id = COALESCE(
availability_variants.inventory_id,
availability_items.inventory_id
)
WHERE availability_components.bundle_catalog_item_id = catalog_items.id
AND availability_items.inventory_policy = ?
GROUP BY availability_inventories.id,
availability_inventories.real_stock,
availability_inventories.reserved_stock
HAVING availability_inventories.real_stock
- availability_inventories.reserved_stock
< SUM(availability_components.quantity)
)
SQL, [InventoryPolicy::Tracked->value]);
}
}

View File

@@ -0,0 +1,52 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Controllers;
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
use App\Domains\FiestaFutbolInfantil\Requests\UpdateCategoryVisibilityRequest;
use App\Domains\FiestaFutbolInfantil\Services\CategoryVisibilityService;
use App\Http\Controllers\Controller;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class CategoryVisibilityController extends Controller
{
public function __construct(private readonly CategoryVisibilityService $visibilityService) {}
public function show(Request $request): JsonResponse
{
$category = $this->category($request);
$tenant = $request->user()->tenant()->firstOrFail();
return response()->json([
'data' => [
'is_enabled' => $this->visibilityService->isEnabled($tenant, $category),
],
]);
}
public function update(UpdateCategoryVisibilityRequest $request): JsonResponse
{
$category = $this->category($request);
$tenant = $request->user()->tenant()->firstOrFail();
$model = $this->visibilityService->update(
$tenant,
$category,
$request->boolean('is_enabled'),
);
return response()->json([
'data' => [
'is_enabled' => $model->is_enabled,
],
'message' => $model->is_enabled
? 'Mostrar en sitio web se activo correctamente'
: 'Mostrar en sitio web se desactivo correctamente',
]);
}
private function category(Request $request): FiestaCategory
{
return FiestaCategory::from((string) $request->route('category'));
}
}

View File

@@ -0,0 +1,21 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Enums;
enum FiestaCategory: string
{
case Entries = 'entries';
case Foods = 'foods';
case Accommodations = 'accommodations';
case Merchandise = 'merchandise';
public function categoryName(): string
{
return match ($this) {
self::Entries => 'Entradas',
self::Foods => 'Comidas',
self::Accommodations => 'Alojamientos',
self::Merchandise => 'Merchandising',
};
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateCategoryVisibilityRequest extends FormRequest
{
/** @return array<string, mixed> */
public function rules(): array
{
return [
'is_enabled' => ['required', 'boolean'],
];
}
}

View File

@@ -0,0 +1,29 @@
<?php
namespace App\Domains\FiestaFutbolInfantil\Services;
use App\Domains\Catalog\Models\Category;
use App\Domains\FiestaFutbolInfantil\Enums\FiestaCategory;
use App\Domains\Tenant\Models\Tenant;
class CategoryVisibilityService
{
public function isEnabled(Tenant $tenant, FiestaCategory $category): bool
{
return Category::query()
->where('tenant_code', $tenant->codigo)
->where('nombre', $category->categoryName())
->value('is_enabled') ?? true;
}
public function update(Tenant $tenant, FiestaCategory $category, bool $isEnabled): Category
{
$model = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo,
'nombre' => $category->categoryName(),
]);
$model->update(['is_enabled' => $isEnabled]);
return $model;
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Domains\FiestaFutbolInfantil\Controllers\AccommodationController;
use App\Domains\FiestaFutbolInfantil\Controllers\CategoryVisibilityController;
use App\Domains\FiestaFutbolInfantil\Controllers\EntryController;
use App\Domains\FiestaFutbolInfantil\Controllers\FoodController;
use App\Domains\FiestaFutbolInfantil\Controllers\MerchandiseController;
@@ -9,6 +10,20 @@ use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
$visibilityRoutes = static function (string $endpoint, string $category, string $menuCode): void {
Route::get("{$endpoint}/visibility", [CategoryVisibilityController::class, 'show'])
->defaults('category', $category)
->middleware("tenant.menu:{$menuCode}");
Route::patch("{$endpoint}/visibility", [CategoryVisibilityController::class, 'update'])
->defaults('category', $category)
->middleware("tenant.menu:{$menuCode}");
};
$visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
$visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
$visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
$visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
Route::get('entries', [EntryController::class, 'index'])
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
->name('adminapp.fiesta-futbol-infantil.entries.index');

View File

@@ -24,13 +24,22 @@ class PurchaseController extends Controller
{
public function index(Request $request, Tenant $tenant): JsonResponse
{
$statusParam = $request->query('status');
$statuses = is_string($statusParam)
? collect(explode(',', $statusParam))
->map(fn (string $status): string => trim($status))
->filter()
->unique()
->values()
->all()
: [];
return PurchaseResource::collection(
Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->where('user_id', $request->user()->id)
->when($request->query('status'), function ($query, $status) {
$query->where('status', $status);
})
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
->orderBy('status')
->latest()
->paginateFromRequest()
)->response();

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
@@ -71,23 +73,25 @@ class SaleController extends Controller
);
}
public function downloadPdf(AdminAppSaleIndexRequest $request): Response
public function downloadPdf(AdminAppSalePdfRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'),
);
}
public function downloadModificationsPdf(Request $request): Response
public function downloadModificationsPdf(AdminAppSaleModificationPdfRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
$request->validated('timezone'),
);
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
use Illuminate\Foundation\Http\FormRequest;
class AdminAppSaleModificationPdfRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Shared\Rules\ValidTimezone;
class AdminAppSalePdfRequest extends AdminAppSaleIndexRequest
{
/** @return array<string, list<string>> */
public function rules(): array
{
return [
...parent::rules(),
'timezone' => ['required', 'string', new ValidTimezone],
];
}
}

View File

@@ -23,6 +23,7 @@ class SaleModificationResource extends JsonResource
'attribute' => $this->attribute,
'old_value' => $this->old_value,
'new_value' => $this->new_value,
'changed_at' => $this->changed_at->utc()->toIso8601String(),
'date' => $this->changed_at->format('Y-m-d'),
'time' => $this->changed_at->format('H:i:s'),
'actor_type' => $this->actor_type->value,

View File

@@ -13,12 +13,14 @@ use Illuminate\Support\Collection;
class AdminAppSalePdfService
{
/** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales): Response
public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): Response
{
$generatedAt = now();
$pdf = Pdf::loadView('pdf.adminapp.sales', [
'tenant' => $tenant,
'sales' => $sales,
'generatedAt' => now(),
'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
'confirmedSalesTotal' => number_format(
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
2,
@@ -30,23 +32,30 @@ class AdminAppSalePdfService
$this->addPageNumbers($pdf);
return $pdf->download(
'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
'ventas_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
);
}
/** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(Tenant $tenant, Collection $modifications): Response
{
public function downloadModifications(
Tenant $tenant,
Collection $modifications,
string $timeZone,
): Response {
$generatedAt = now();
$pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
'tenant' => $tenant,
'modifications' => $modifications,
'generatedAt' => now(),
'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'historial_modificaciones_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
'historial_modificaciones_'.$tenant->codigo.'_'
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
);
}

View File

@@ -0,0 +1,24 @@
<?php
namespace App\Domains\Shared\Rules;
use Closure;
use DateTimeZone;
use Exception;
use Illuminate\Contracts\Validation\ValidationRule;
class ValidTimezone implements ValidationRule
{
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value)) {
return;
}
try {
new DateTimeZone($value);
} catch (Exception) {
$fail(__('validation.timezone'));
}
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
@@ -100,7 +101,11 @@ return Application::configure(basePath: dirname(__DIR__))
'errors' => $exception->errors(),
'catalog_item_id' => $exception->catalogItemId,
'catalog_item_name' => $exception->catalogItemName,
'maximum_addable_quantity' => $exception->maximumAddableQuantity,
'availability' => app(CatalogItemAllowanceService::class)
->purchaseLimitExceededAvailability(
$exception->maximumAddableQuantity,
$exception->getMessage(),
)->toArray(),
], 422);
});
$exceptions->render(function (PurchaseExpiredException $exception, Request $request) {

View File

@@ -2,4 +2,33 @@
return [
'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30),
'availability' => [
'default_actions' => [
'select_variant',
'change_quantity',
'add_to_cart',
'buy_now',
],
'rules' => [
'user_quota_reached' => [
'effect' => 'restrict',
'denied_actions' => [
'select_variant',
'change_quantity',
'add_to_cart',
'buy_now',
],
],
'out_of_stock' => [
'effect' => 'hide',
],
'requested_quantity_exceeds_user_quota' => [
'effect' => 'restrict',
'denied_actions' => [
'add_to_cart',
'buy_now',
],
],
],
],
];

View File

@@ -0,0 +1,22 @@
<?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('categorias', function (Blueprint $table): void {
$table->boolean('is_enabled')->default(true)->after('nombre');
});
}
public function down(): void
{
Schema::table('categorias', function (Blueprint $table): void {
$table->dropColumn('is_enabled');
});
}
};

View File

@@ -37,6 +37,7 @@ return [
'required_with' => 'The :attribute field is required when :values is present.',
'required_without' => 'The :attribute field is required when :values is not present.',
'string' => 'The :attribute must be a string.',
'timezone' => 'The timezone sent by the browser is invalid.',
'unique' => 'The :attribute has already been taken.',
'url' => 'The :attribute must be a valid URL.',
'uuid' => 'The :attribute must be a valid UUID.',
@@ -52,6 +53,7 @@ return [
'password_confirmation' => 'password confirmation',
'telefono' => 'phone number',
'tenant_codigo' => 'tenant',
'timezone' => 'timezone',
'variant_id' => 'variant',
],
];

View File

@@ -37,6 +37,7 @@ return [
'required_with' => 'El campo :attribute es obligatorio cuando :values está presente.',
'required_without' => 'El campo :attribute es obligatorio cuando :values no está presente.',
'string' => ':Attribute debe ser texto.',
'timezone' => 'La zona horaria enviada por el navegador no es válida.',
'unique' => 'El :attribute ya está en uso.',
'url' => ':Attribute debe ser una URL válida.',
'uuid' => ':Attribute debe ser un UUID válido.',
@@ -52,6 +53,7 @@ return [
'password_confirmation' => 'confirmación de contraseña',
'telefono' => 'teléfono',
'tenant_codigo' => 'tenant',
'timezone' => 'zona horaria',
'variant_id' => 'variante',
],
];

View File

@@ -25,7 +25,7 @@
</head>
<body>
<h1>Historial de modificaciones de ventas</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}</p>
<table>
<thead>
@@ -44,8 +44,8 @@
@forelse ($modifications as $modification)
@php($sale = $modification->trackable)
<tr>
<td>{{ $modification->changed_at->format('d/m/Y') }}</td>
<td>{{ $modification->changed_at->format('H:i:s') }}</td>
<td>{{ $modification->changed_at->copy()->timezone($timeZone)->format('d/m/Y') }}</td>
<td>{{ $modification->changed_at->copy()->timezone($timeZone)->format('H:i:s') }}</td>
<td>#{{ $modification->trackable_id }}</td>
<td>{{ $sale?->nombre_apellido ?: 'Sin nombre' }}</td>
<td>{{ $modification->attribute }}</td>

View File

@@ -22,7 +22,7 @@
</head>
<body>
<h1>Historial de ventas</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->copy()->timezone($timeZone)->format('d/m/Y H:i') }}</p>
<div class="summary">
Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong>
@@ -44,7 +44,7 @@
@forelse ($sales as $sale)
<tr>
<td>#{{ $sale->id }}</td>
<td>{{ $sale->created_at?->format('d/m/Y H:i') ?? '-' }}</td>
<td>{{ $sale->created_at?->copy()->timezone($timeZone)->format('d/m/Y H:i') ?? '-' }}</td>
<td>{{ $sale->nombre_apellido ?: 'Sin nombre' }}</td>
<td class="center">{{ (int) ($sale->quantity ?? 0) }}</td>
<td>{{ match ($sale->status) {

View File

@@ -246,7 +246,7 @@ class CartControllerTest extends TestCase
->assertJsonPath('code', 'purchase.limit_exceeded')
->assertJsonPath('catalog_item_id', $item->id)
->assertJsonPath('catalog_item_name', $item->nombre)
->assertJsonPath('maximum_addable_quantity', 1)
->assertJsonPath('availability.maximum_quantity', 1)
->assertJsonPath(
'message',
"Podés agregar hasta 1 unidad más de “{$item->nombre}”.",

View File

@@ -267,7 +267,7 @@ class BundleCatalogItemTest extends TestCase
$this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}")
->assertOk()
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
->assertJsonPath('data.maximum_addable_quantity', 4)
->assertJsonPath('data.availability.maximum_quantity', 4)
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonCount(1, 'data.components')
->assertJsonPath('data.components.0.catalog_item_id', $component->id)

View File

@@ -41,7 +41,7 @@ class CatalogControllerTest extends TestCase
$directItem = $this->createItem($tenant, 'Direct', $directInventory);
$row->featuredItems()->create(['catalog_item_id' => $directItem->id]);
$variantItem = $this->createItem($tenant, 'Variants');
$variantItem = $this->createItem($tenant, 'Variants', withoutInventory: true);
$firstInventory = Inventory::query()->create([
'real_stock' => 5,
'reserved_stock' => 1,
@@ -56,7 +56,7 @@ class CatalogControllerTest extends TestCase
]);
$variantItem->variants()->create(['inventory_id' => $firstInventory->id]);
$variantItem->variants()->create(['inventory_id' => $secondInventory->id]);
$unavailableVariant = $variantItem->variants()->create([
$variantItem->variants()->create([
'inventory_id' => $unavailableInventory->id,
]);
$cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]);
@@ -72,18 +72,14 @@ class CatalogControllerTest extends TestCase
->assertJsonPath('0.items.0.nombre', 'Variants')
->assertJsonPath('0.items.0.descripcion', 'Variants description')
->assertJsonPath('0.items.0.precio', '100.00')
->assertJsonPath('0.items.0.maximum_addable_quantity', 7)
->assertJsonCount(3, '0.items.0.variants')
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3)
->assertJsonPath('0.items.0.variants.2.id', $unavailableVariant->id)
->assertJsonPath('0.items.0.variants.2.maximum_addable_quantity', 0)
->assertJsonPath(
'0.items.0.variants.2.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('0.items.0.availability.state', 'visible')
->assertJsonPath('0.items.0.availability.maximum_quantity', 7)
->assertJsonCount(2, '0.items.0.variants')
->assertJsonPath('0.items.0.variants.0.availability.maximum_quantity', 4)
->assertJsonPath('0.items.0.variants.0.availability.state', 'visible')
->assertJsonPath('0.items.0.variants.1.availability.maximum_quantity', 3)
->assertJsonPath('1.title', 'Row')
->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8)
->assertJsonPath('1.items.data.0.availability.maximum_quantity', 8)
->assertJsonMissingPath('1.items.data.0.stock_tecnico')
->assertJsonCount(0, '1.items.data.0.variants');
}
@@ -98,7 +94,7 @@ class CatalogControllerTest extends TestCase
groupLayout: GroupLayout::Simple,
);
$user = User::factory()->create();
$item = $this->createItem($tenant, 'Limited variants');
$item = $this->createItem($tenant, 'Limited variants', withoutInventory: true);
$item->update(['max_units_per_user' => 3]);
$firstVariant = $item->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id,
@@ -119,21 +115,21 @@ class CatalogControllerTest extends TestCase
$this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 0)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 0)
->assertJsonPath('0.items.0.availability.maximum_quantity', 0)
->assertJsonPath('0.items.0.availability.allowed_actions', [])
->assertJsonPath(
'0.items.0.variants.0.unavailable_message',
'Alcanzaste el cupo máximo permitido para este producto.',
)
->assertJsonPath(
'0.items.0.variants.1.unavailable_message',
'0.items.0.availability.reasons.0.message',
'Alcanzaste el cupo máximo permitido para este producto.',
)
->assertJsonPath('0.items.0.variants.0.availability.maximum_quantity', 8)
->assertJsonPath('0.items.0.variants.1.availability.maximum_quantity', 9)
->assertJsonCount(0, '0.items.0.variants.0.availability.reasons')
->assertJsonCount(0, '0.items.0.variants.1.availability.reasons')
->assertJsonMissingPath('0.items.0.variants.0.stock_tecnico')
->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico');
}
public function test_it_includes_out_of_stock_items_with_an_unavailable_message(): void
public function test_it_hides_out_of_stock_items_before_building_the_group_response(): void
{
$tenant = $this->createTenant('catalog-available-variants');
$group = $this->createGroup(
@@ -143,7 +139,7 @@ class CatalogControllerTest extends TestCase
groupLayout: GroupLayout::SimpleVertical,
);
$unavailableItem = $this->createItem($tenant, 'Unavailable');
$unavailableItem = $this->createItem($tenant, 'Unavailable', withoutInventory: true);
$unavailableInventory = Inventory::query()->create([
'real_stock' => 4,
'reserved_stock' => 4,
@@ -151,7 +147,7 @@ class CatalogControllerTest extends TestCase
$unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]);
$group->featuredItems()->create(['catalog_item_id' => $unavailableItem->id]);
$availableItem = $this->createItem($tenant, 'Available');
$availableItem = $this->createItem($tenant, 'Available', withoutInventory: true);
$availableInventory = Inventory::query()->create([
'real_stock' => 4,
'reserved_stock' => 3,
@@ -161,15 +157,9 @@ class CatalogControllerTest extends TestCase
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonCount(2, '0.items')
->assertJsonPath('0.items.0.nombre', 'Unavailable')
->assertJsonPath('0.items.0.maximum_addable_quantity', 0)
->assertJsonPath(
'0.items.0.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('0.items.1.nombre', 'Available')
->assertJsonPath('0.items.1.unavailable_message', null);
->assertJsonCount(1, '0.items')
->assertJsonPath('0.items.0.nombre', 'Available')
->assertJsonCount(0, '0.items.0.availability.reasons');
}
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void
@@ -186,8 +176,8 @@ class CatalogControllerTest extends TestCase
'order' => 0,
]);
$variantItem = $this->createItem($tenant, 'Variant image');
$variantInventory = Inventory::query()->create();
$variantItem = $this->createItem($tenant, 'Variant image', withoutInventory: true);
$variantInventory = Inventory::query()->create(['real_stock' => 1]);
$variant = $variantItem->variants()->create(['inventory_id' => $variantInventory->id]);
$variantImage = $this->createAttachment('variant');
$variant->attachments()->attach($variantImage, ['orden' => 0]);
@@ -367,6 +357,53 @@ class CatalogControllerTest extends TestCase
->assertJsonPath('1.items.meta.total', 2);
}
public function test_index_excludes_items_from_disabled_categories(): void
{
$tenant = $this->createTenant('catalog-disabled-category');
$group = $this->createGroup(
$tenant,
ProductLayout::Row,
'All products',
groupLayout: GroupLayout::Simple,
);
$enabledCategory = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Enabled',
]);
$disabledCategory = Category::query()->create([
'tenant_code' => $tenant->codigo,
'nombre' => 'Disabled',
'is_enabled' => false,
]);
$enabledItem = $this->createItem($tenant, 'Enabled item');
$enabledItem->category()->associate($enabledCategory)->save();
$disabledItem = $this->createItem($tenant, 'Disabled item');
$disabledItem->category()->associate($disabledCategory)->save();
$uncategorizedItem = $this->createItem($tenant, 'Uncategorized item');
foreach ([$enabledItem, $disabledItem, $uncategorizedItem] as $order => $item) {
$group->featuredItems()->create([
'catalog_item_id' => $item->id,
'order' => $order,
]);
}
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk()
->assertJsonCount(2, '0.items')
->assertJsonPath('0.items.0.nombre', 'Enabled item')
->assertJsonPath('0.items.1.nombre', 'Uncategorized item')
->assertJsonMissing(['nombre' => 'Disabled item']);
$this->getJson(
"/api/tenants/{$tenant->codigo}/catalog/featured-groups/{$group->id}/items"
)
->assertOk()
->assertJsonCount(2)
->assertJsonMissing(['nombre' => 'Disabled item']);
}
private function createGroup(
Tenant $tenant,
ProductLayout $layout,
@@ -387,7 +424,12 @@ class CatalogControllerTest extends TestCase
Tenant $tenant,
string $name,
?Inventory $inventory = null,
bool $withoutInventory = false,
): CatalogItem {
$inventory ??= $withoutInventory
? null
: Inventory::query()->create(['real_stock' => 10]);
return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory?->id,

View File

@@ -40,7 +40,7 @@ class CatalogItemDetailControllerTest extends TestCase
$response
->assertOk()
->assertJsonPath('data.maximum_addable_quantity', 7)
->assertJsonPath('data.availability.maximum_quantity', 7)
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonCount(0, 'data.variants')
->assertJsonCount(1, 'data.images');
@@ -48,7 +48,18 @@ class CatalogItemDetailControllerTest extends TestCase
$this->assertStringContainsString($itemImage->path, $response->json('data.images.0'));
}
public function test_it_lists_unavailable_variants_and_selects_the_first_available_one(): void
public function test_it_does_not_return_an_out_of_stock_product_detail(): void
{
$tenant = $this->createTenant('detail-hidden');
$inventory = Inventory::query()->create(['real_stock' => 0]);
$item = $this->createItem($tenant, 'Hidden item', $inventory);
$this->getJson(
"/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}",
)->assertNotFound();
}
public function test_it_hides_unavailable_variants_and_selects_the_first_available_one(): void
{
Storage::fake('s3');
$tenant = $this->createTenant('detail-default');
@@ -69,16 +80,11 @@ class CatalogItemDetailControllerTest extends TestCase
$response
->assertOk()
->assertJsonCount(2, 'data.variants')
->assertJsonPath('data.variants.0.id', $firstVariant->id)
->assertJsonPath('data.variants.0.maximum_addable_quantity', 0)
->assertJsonPath(
'data.variants.0.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('data.variants.1.id', $secondVariant->id)
->assertJsonCount(1, 'data.variants')
->assertJsonPath('data.variants.0.id', $secondVariant->id)
->assertJsonPath('data.variants.0.availability.state', 'visible')
->assertJsonPath('data.selected_variant.id', $secondVariant->id)
->assertJsonPath('data.selected_variant.maximum_addable_quantity', 6)
->assertJsonPath('data.selected_variant.availability.maximum_quantity', 6)
->assertJsonMissingPath('data.selected_variant.stock_tecnico')
->assertJsonCount(1, 'data.selected_variant.images');
$response
@@ -133,11 +139,11 @@ class CatalogItemDetailControllerTest extends TestCase
$response
->assertOk()
->assertJsonPath('data.variants.0.id', $firstVariant->id)
->assertJsonPath('data.variants.0.maximum_addable_quantity', 4)
->assertJsonPath('data.variants.0.availability.maximum_quantity', 4)
->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.maximum_addable_quantity', 7)
->assertJsonPath('data.variants.1.availability.maximum_quantity', 7)
->assertJsonPath('data.variants.1.values.size.value', 'M')
->assertJsonPath('data.variants.1.values.size.label', 'Medium')
->assertJsonPath('data.attributes.0.codigo', 'size')
@@ -145,7 +151,7 @@ class CatalogItemDetailControllerTest extends TestCase
->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.maximum_addable_quantity', 7)
->assertJsonPath('data.selected_variant.availability.maximum_quantity', 7)
->assertJsonPath('data.selected_variant.values.size.value', 'M')
->assertJsonPath('data.selected_variant.values.size.label', 'Medium')
->assertJsonCount(1, 'data.selected_variant.images');
@@ -185,9 +191,9 @@ class CatalogItemDetailControllerTest extends TestCase
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
->assertOk()
->assertJsonPath('data.selected_variant.id', $variant->id)
->assertJsonPath('data.selected_variant.maximum_addable_quantity', null)
->assertJsonPath('data.selected_variant.availability.maximum_quantity', null)
->assertJsonMissingPath('data.stock_tecnico')
->assertJsonPath('data.variants.0.maximum_addable_quantity', null)
->assertJsonPath('data.variants.0.availability.maximum_quantity', null)
->assertJsonMissingPath('data.selected_variant.stock_tecnico')
->assertJsonMissingPath('data.variants.0.stock_tecnico');
}
@@ -354,6 +360,9 @@ class CatalogItemDetailControllerTest extends TestCase
->assertJsonPath('data.resolved_variant', null)
->assertJsonCount(3, 'data.variants')
->assertJsonPath('data.variants.0.id', $first->id)
->assertJsonPath('data.variants.0.availability.state', 'visible')
->assertJsonPath('data.variants.0.availability.maximum_quantity', 1)
->assertJsonPath('data.variants.0.availability.allowed_actions.2', 'add_to_cart')
->assertJsonPath('data.variants.0.values.sector.value', 'A')
->assertJsonPath('data.variants.0.values.seat.value', '1')
->assertJsonCount(2, 'data.selectors')
@@ -421,7 +430,9 @@ class CatalogItemDetailControllerTest extends TestCase
->assertOk()
->assertJsonPath('data.selected_values.sector', 'VIP')
->assertJsonPath('data.selected_values.seat', 'A-12')
->assertJsonPath('data.resolved_variant.id', $variant->id);
->assertJsonPath('data.resolved_variant.id', $variant->id)
->assertJsonPath('data.resolved_variant.availability.state', 'hidden')
->assertJsonMissingPath('data.resolved_variant.availability.allowed_actions');
}
private function createItem(

View File

@@ -5,6 +5,7 @@ namespace Tests\Feature\Catalog;
use App\Domains\Attachable\Enums\AttachmentType;
use App\Domains\Attachable\Models\Attachment;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -15,6 +16,21 @@ class CatalogSchemaTest extends TestCase
{
use RefreshDatabase;
public function test_categories_are_enabled_by_default(): void
{
$this->assertTrue(Schema::hasColumn('categorias', 'is_enabled'));
$category = Category::query()->create([
'nombre' => 'Enabled category',
]);
$this->assertTrue($category->is_enabled);
$this->assertDatabaseHas('categorias', [
'id' => $category->id,
'is_enabled' => true,
]);
}
public function test_legacy_product_tables_are_replaced_by_catalog_tables(): void
{
$this->assertFalse(Schema::hasTable('productos'));

View File

@@ -62,6 +62,8 @@ class CatalogSearchTest extends TestCase
$this->createCatalogItem($tenant, "Running {$number}");
}
$exactMatch = $this->createCatalogItem($tenant, 'Running');
$outOfStockMatch = $this->createCatalogItem($tenant, 'Running unavailable');
$outOfStockMatch->inventory->update(['real_stock' => 0]);
$this->createCatalogItem($tenant, 'Unrelated');
$this->createCatalogItem($otherTenant, 'Running foreign');
@@ -76,6 +78,7 @@ class CatalogSearchTest extends TestCase
->assertJsonPath('meta.total', 6)
->assertJsonCount(4, 'data')
->assertJsonPath('data.0.id', $exactMatch->id)
->assertJsonMissing(['nombre' => 'Running unavailable'])
->assertJsonMissing(['nombre' => 'Running foreign'])
->assertJsonMissing(['nombre' => 'Unrelated']);
}

View File

@@ -38,6 +38,59 @@ class EntryControllerTest extends TestCase
->assertUnauthorized();
}
public function test_category_visibility_can_be_read_and_updated_for_every_fiesta_menu(): void
{
$tenant = $this->createFiestaTenant();
$categories = [
'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'],
'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'],
'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'],
'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'],
];
foreach ($categories as [$label, $menuCode]) {
$menu = Menu::query()->firstOrCreate(
['code' => $menuCode],
['label' => $label, 'route' => '/admin'],
);
$tenant->menues()->syncWithoutDetaching([$menu->code]);
}
Sanctum::actingAs($this->createAdminAppUser($tenant));
foreach ($categories as $endpoint => [$categoryName]) {
$this->getJson("/api/v1/adminapp/tenant/{$endpoint}/visibility")
->assertOk()
->assertJsonPath('data.is_enabled', true);
$this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [
'is_enabled' => false,
])
->assertOk()
->assertJsonPath('data.is_enabled', false)
->assertJsonPath(
'message',
'Mostrar en sitio web se desactivo correctamente',
);
$this->patchJson("/api/v1/adminapp/tenant/{$endpoint}/visibility", [
'is_enabled' => true,
])
->assertOk()
->assertJsonPath('data.is_enabled', true)
->assertJsonPath(
'message',
'Mostrar en sitio web se activo correctamente',
);
$this->assertDatabaseHas('categorias', [
'tenant_code' => $tenant->codigo,
'nombre' => $categoryName,
'is_enabled' => true,
]);
}
}
public function test_it_creates_multiple_entries_with_dates_and_tracked_inventory(): void
{
$tenant = $this->createFiestaTenant();

View File

@@ -394,7 +394,7 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('code', 'purchase.limit_exceeded')
->assertJsonPath('catalog_item_id', $variant->catalog_item_id)
->assertJsonPath('catalog_item_name', $variant->catalogItem->nombre)
->assertJsonPath('maximum_addable_quantity', 1);
->assertJsonPath('availability.maximum_quantity', 1);
$this->actingAs($otherUser, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [
@@ -1230,6 +1230,52 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('data.0.total', '100.00');
}
public function test_purchase_index_filters_multiple_comma_separated_statuses_and_orders_by_status_ascending_then_date(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$user = User::factory()->create();
$olderPaid = Purchase::query()->create([
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
'status' => Purchase::STATUS_PAID,
'total' => '10.00',
]);
$newerInReview = Purchase::query()->create([
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
'status' => Purchase::STATUS_IN_REVIEW,
'total' => '20.00',
]);
$newerPaid = Purchase::query()->create([
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
'status' => Purchase::STATUS_PAID,
'total' => '30.00',
]);
Purchase::query()->create([
'tenant_codigo' => 'sonder',
'user_id' => $user->id,
'status' => Purchase::STATUS_CANCELLED,
'total' => '40.00',
]);
$olderPaid->forceFill(['created_at' => now()->subDays(3)])->saveQuietly();
$newerInReview->forceFill(['created_at' => now()])->saveQuietly();
$newerPaid->forceFill(['created_at' => now()->subDay()])->saveQuietly();
$this->actingAs($user, 'sanctum')
->getJson('/api/tenants/sonder/compras?status=paid,%20in_review,paid')
->assertOk()
->assertJsonCount(3, 'data')
->assertJsonPath('data.0.id', $newerInReview->id)
->assertJsonPath('data.0.status', Purchase::STATUS_IN_REVIEW)
->assertJsonPath('data.1.id', $newerPaid->id)
->assertJsonPath('data.1.status', Purchase::STATUS_PAID)
->assertJsonPath('data.2.id', $olderPaid->id)
->assertJsonPath('data.2.status', Purchase::STATUS_PAID);
}
public function test_it_rejects_a_cart_from_another_user(): void
{
$this->createTenant('sonder', 'Sonder', 'sonder.com.ar');

View File

@@ -2,42 +2,73 @@
namespace Tests\Unit\Catalog;
use App\Domains\Catalog\Services\AvailabilityPolicyResolver;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use ReflectionClass;
use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Mockery;
use Tests\TestCase;
class CatalogItemAllowanceServiceTest extends TestCase
{
private CatalogItemAllowanceService $service;
protected function setUp(): void
public function test_a_hidden_decision_only_exposes_its_state_and_reasons(): void
{
parent::setUp();
$availability = $this->service()->availability(0, 0)->toArray();
$this->service = (new ReflectionClass(CatalogItemAllowanceService::class))
->newInstanceWithoutConstructor();
}
public function test_it_returns_the_user_quota_message_with_priority_over_stock(): void
{
$this->assertSame('hidden', $availability['state']);
$this->assertSame(
'Alcanzaste el cupo máximo permitido para este producto.',
$this->service->unavailableMessage(0, 0),
['user_quota_reached', 'out_of_stock'],
array_column($availability['reasons'], 'code'),
);
$this->assertArrayNotHasKey('maximum_quantity', $availability);
$this->assertArrayNotHasKey('allowed_actions', $availability);
}
public function test_it_returns_the_out_of_stock_message(): void
public function test_an_available_decision_keeps_all_actions(): void
{
$availability = $this->service()->availability(5, 3)->toArray();
$this->assertSame('visible', $availability['state']);
$this->assertSame(3, $availability['maximum_quantity']);
$this->assertSame([], $availability['reasons']);
$this->assertSame([
'select_variant',
'change_quantity',
'add_to_cart',
'buy_now',
], $availability['allowed_actions']);
}
public function test_reaching_the_user_quota_keeps_the_product_visible_without_actions(): void
{
$availability = $this->service()->availability(5, 0)->toArray();
$this->assertSame('visible', $availability['state']);
$this->assertSame([], $availability['allowed_actions']);
$this->assertSame('user_quota_reached', $availability['reasons'][0]['code']);
}
public function test_exceeding_the_remaining_quota_allows_the_quantity_to_be_corrected(): void
{
$availability = $this->service()
->purchaseLimitExceededAvailability(1, 'Solo podés agregar una unidad.')
->toArray();
$this->assertSame(1, $availability['maximum_quantity']);
$this->assertSame(
'Este producto no tiene stock disponible.',
$this->service->unavailableMessage(0, null),
'requested_quantity_exceeds_user_quota',
$availability['reasons'][0]['code'],
);
$this->assertSame([
'select_variant',
'change_quantity',
], $availability['allowed_actions']);
}
public function test_it_returns_no_message_when_the_item_is_available(): void
private function service(): CatalogItemAllowanceService
{
$this->assertNull($this->service->unavailableMessage(1, null));
$this->assertNull($this->service->unavailableMessage(null, 1));
$this->assertNull($this->service->unavailableMessage(null, null));
return new CatalogItemAllowanceService(
Mockery::mock(UserPurchaseLimitService::class),
new AvailabilityPolicyResolver,
);
}
}

View File

@@ -0,0 +1,55 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Validator;
use Tests\TestCase;
class AdminAppSalePdfRequestTest extends TestCase
{
public function test_pdf_requests_accept_php_timezone_identifiers_including_browser_aliases(): void
{
$requests = [
new AdminAppSalePdfRequest,
new AdminAppSaleModificationPdfRequest,
];
foreach ($requests as $request) {
$this->assertTrue(Validator::make([
'timezone' => 'America/Argentina/Buenos_Aires',
], $request->rules())->passes());
$this->assertTrue(Validator::make([
'timezone' => 'America/Buenos_Aires',
], $request->rules())->passes());
$this->assertFalse(Validator::make([
'timezone' => 'Invalid/Timezone',
], $request->rules())->passes());
$this->assertFalse(Validator::make([], $request->rules())->passes());
}
}
public function test_invalid_timezone_message_uses_the_application_locale(): void
{
$request = new AdminAppSalePdfRequest;
App::setLocale('es');
$spanishValidator = Validator::make(['timezone' => 'Invalid/Timezone'], $request->rules());
$this->assertSame(
'La zona horaria enviada por el navegador no es válida.',
$spanishValidator->errors()->first('timezone'),
);
App::setLocale('en');
$englishValidator = Validator::make(['timezone' => 'Invalid/Timezone'], $request->rules());
$this->assertSame(
'The timezone sent by the browser is invalid.',
$englishValidator->errors()->first('timezone'),
);
}
}

View File

@@ -8,6 +8,7 @@ use App\Domains\Purchase\Models\Purchase;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Tenant\Models\Tenant;
use Barryvdh\DomPDF\ServiceProvider;
use Illuminate\Support\Carbon;
use Tests\TestCase;
class AdminAppSalePdfServiceTest extends TestCase
@@ -17,6 +18,14 @@ class AdminAppSalePdfServiceTest extends TestCase
parent::setUp();
$this->app->register(ServiceProvider::class);
Carbon::setTestNow(Carbon::parse('2026-08-24 17:53:00', 'UTC'));
}
protected function tearDown(): void
{
Carbon::setTestNow();
parent::tearDown();
}
public function test_it_downloads_the_sales_report_as_a_pdf(): void
@@ -24,11 +33,12 @@ class AdminAppSalePdfServiceTest extends TestCase
$response = app(AdminAppSalePdfService::class)->downloadSales(
$this->tenant(),
collect([$this->sale()]),
'America/La_Paz',
);
$this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString(
'attachment; filename=ventas_acme_',
'attachment; filename=ventas_acme_20260824_135300.pdf',
(string) $response->headers->get('content-disposition'),
);
$this->assertStringStartsWith('%PDF', $response->getContent());
@@ -57,16 +67,52 @@ class AdminAppSalePdfServiceTest extends TestCase
$response = app(AdminAppSalePdfService::class)->downloadModifications(
$this->tenant(),
collect([$modification]),
'America/La_Paz',
);
$this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString(
'attachment; filename=historial_modificaciones_acme_',
'attachment; filename=historial_modificaciones_acme_20260824_135300.pdf',
(string) $response->headers->get('content-disposition'),
);
$this->assertStringStartsWith('%PDF', $response->getContent());
}
public function test_it_renders_pdf_dates_in_the_requested_timezone(): void
{
$sale = $this->sale();
$modification = (new ValueChange)->forceFill([
'id' => 1,
'trackable_id' => $sale->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'changed_at' => now(),
'actor_type' => 'system',
]);
$modification->setRelation('trackable', $sale);
$modification->setRelation('user', null);
$salesHtml = view('pdf.adminapp.sales', [
'tenant' => $this->tenant(),
'sales' => collect([$sale]),
'generatedAt' => now(),
'timeZone' => 'America/La_Paz',
'confirmedSalesTotal' => '25000.00',
])->render();
$modificationsHtml = view('pdf.adminapp.sale-modifications', [
'tenant' => $this->tenant(),
'modifications' => collect([$modification]),
'generatedAt' => now(),
'timeZone' => 'America/La_Paz',
])->render();
$this->assertStringContainsString('Generado el 24/08/2026 13:53', $salesHtml);
$this->assertStringContainsString('24/08/2026 13:53', $salesHtml);
$this->assertStringContainsString('Generado el 24/08/2026 13:53', $modificationsHtml);
$this->assertStringContainsString('13:53:00', $modificationsHtml);
}
private function tenant(): Tenant
{
return (new Tenant)->forceFill([

View File

@@ -0,0 +1,33 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use Carbon\CarbonImmutable;
use Illuminate\Http\Request;
use Tests\TestCase;
class SaleModificationResourceTest extends TestCase
{
public function test_it_serializes_the_change_instant_as_utc_iso_8601(): void
{
$modification = (new ValueChange)->forceFill([
'id' => 1,
'trackable_id' => 33,
'attribute' => 'status',
'old_value' => 'pending_payment',
'new_value' => 'cancelled',
'changed_at' => CarbonImmutable::parse('2026-08-24 17:53:00', 'UTC'),
'actor_type' => 'system',
]);
$modification->setRelation('trackable', null);
$modification->setRelation('user', null);
$data = (new SaleModificationResource($modification))->resolve(Request::create('/'));
$this->assertSame('2026-08-24T17:53:00+00:00', $data['changed_at']);
$this->assertSame('2026-08-24', $data['date']);
$this->assertSame('17:53:00', $data['time']);
}
}