Compare commits

...

27 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
37 changed files with 741 additions and 177 deletions

View File

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

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

View File

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

View File

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

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; 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\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Services\UserPurchaseLimitService; use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -14,6 +16,7 @@ class CatalogItemAllowanceService
public function __construct( public function __construct(
private readonly UserPurchaseLimitService $purchaseLimits, private readonly UserPurchaseLimitService $purchaseLimits,
private readonly AvailabilityPolicyResolver $policies,
) {} ) {}
/** @param Collection<int, CatalogItem> $catalogItems */ /** @param Collection<int, CatalogItem> $catalogItems */
@@ -42,16 +45,82 @@ class CatalogItemAllowanceService
return min($availableStock, $remainingUserQuota); 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) { 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) { 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 class CatalogService
{ {
public function __construct(protected AttachmentService $attachmentService) {} public function __construct(
protected AttachmentService $attachmentService,
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
) {}
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
@@ -229,7 +232,7 @@ class CatalogService
$containsPattern = "%{$normalizedTerm}%"; $containsPattern = "%{$normalizedTerm}%";
$startsWithPattern = "{$normalizedTerm}%"; $startsWithPattern = "{$normalizedTerm}%";
$paginator = CatalogItem::query() $query = CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where(function (Builder $query) use ($containsPattern): void { ->where(function (Builder $query) use ($containsPattern): void {
$query $query
@@ -257,7 +260,10 @@ class CatalogService
'variants.definitions.itemAttribute.attribute.options', 'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem', 'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem', 'bundleComponents.variant.catalogItem',
]) ]);
$paginator = $this->visibleCatalogItems
->apply($query)
->orderByRaw( ->orderByRaw(
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END', 'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
[$normalizedTerm, $startsWithPattern], [$normalizedTerm, $startsWithPattern],
@@ -277,7 +283,7 @@ class CatalogService
int $perPage, int $perPage,
int $page, int $page,
): LengthAwarePaginator { ): LengthAwarePaginator {
return CatalogItem::query() $query = CatalogItem::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('category_id', $category->id) ->where('category_id', $category->id)
->with([ ->with([
@@ -291,7 +297,10 @@ class CatalogService
'variants.definitions.itemAttribute.attribute.options', 'variants.definitions.itemAttribute.attribute.options',
'bundleComponents.catalogItem', 'bundleComponents.catalogItem',
'bundleComponents.variant.catalogItem', 'bundleComponents.variant.catalogItem',
]) ]);
return $this->visibleCatalogItems
->apply($query)
->orderBy('nombre') ->orderBy('nombre')
->paginate(perPage: $perPage, pageName: 'page', page: $page); ->paginate(perPage: $perPage, pageName: 'page', page: $page);
} }

View File

@@ -17,6 +17,7 @@ class FeaturedGroupService
/** @return array<array-key, mixed> */ /** @return array<array-key, mixed> */
public function __construct( public function __construct(
private readonly CatalogItemAllowanceService $allowances, private readonly CatalogItemAllowanceService $allowances,
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
) {} ) {}
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
@@ -71,6 +72,8 @@ class FeaturedGroupService
'bundleComponents.variant.catalogItem', 'bundleComponents.variant.catalogItem',
]); ]);
$query = $this->visibleCatalogItems->apply($query);
return match ($featuredGroup->source_type) { return match ($featuredGroup->source_type) {
FeaturedGroupSource::Manual => $query FeaturedGroupSource::Manual => $query
->select('catalog_items.*') ->select('catalog_items.*')

View File

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

@@ -39,6 +39,9 @@ class CategoryVisibilityController extends Controller
'data' => [ 'data' => [
'is_enabled' => $model->is_enabled, 'is_enabled' => $model->is_enabled,
], ],
'message' => $model->is_enabled
? 'Mostrar en sitio web se activo correctamente'
: 'Mostrar en sitio web se desactivo correctamente',
]); ]);
} }

View File

@@ -3,6 +3,8 @@
namespace App\Domains\Sale\Controllers\AdminApp; namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest; 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\SaleDetailResource;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource; use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource; 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(); $tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadSales( return $this->salePdfService->downloadSales(
$tenant, $tenant,
$this->saleService->salesForExport($tenant, $request->validated()), $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(); $tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications( return $this->salePdfService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($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, 'attribute' => $this->attribute,
'old_value' => $this->old_value, 'old_value' => $this->old_value,
'new_value' => $this->new_value, 'new_value' => $this->new_value,
'changed_at' => $this->changed_at->utc()->toIso8601String(),
'date' => $this->changed_at->format('Y-m-d'), 'date' => $this->changed_at->format('Y-m-d'),
'time' => $this->changed_at->format('H:i:s'), 'time' => $this->changed_at->format('H:i:s'),
'actor_type' => $this->actor_type->value, 'actor_type' => $this->actor_type->value,

View File

@@ -13,12 +13,14 @@ use Illuminate\Support\Collection;
class AdminAppSalePdfService class AdminAppSalePdfService
{ {
/** @param Collection<int, Purchase> $sales */ /** @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', [ $pdf = Pdf::loadView('pdf.adminapp.sales', [
'tenant' => $tenant, 'tenant' => $tenant,
'sales' => $sales, 'sales' => $sales,
'generatedAt' => now(), 'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
'confirmedSalesTotal' => number_format( 'confirmedSalesTotal' => number_format(
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'), (float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
2, 2,
@@ -30,23 +32,30 @@ class AdminAppSalePdfService
$this->addPageNumbers($pdf); $this->addPageNumbers($pdf);
return $pdf->download( 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 */ /** @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', [ $pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
'tenant' => $tenant, 'tenant' => $tenant,
'modifications' => $modifications, 'modifications' => $modifications,
'generatedAt' => now(), 'generatedAt' => $generatedAt,
'timeZone' => $timeZone,
])->setPaper('a4', 'landscape'); ])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf); $this->addPageNumbers($pdf);
return $pdf->download( 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 <?php
use App\Domains\Auth\Exceptions\AccountLockedException; use App\Domains\Auth\Exceptions\AccountLockedException;
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use App\Domains\Purchase\Exceptions\InsufficientStockException; use App\Domains\Purchase\Exceptions\InsufficientStockException;
use App\Domains\Purchase\Exceptions\PurchaseExpiredException; use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException; use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
@@ -100,7 +101,11 @@ return Application::configure(basePath: dirname(__DIR__))
'errors' => $exception->errors(), 'errors' => $exception->errors(),
'catalog_item_id' => $exception->catalogItemId, 'catalog_item_id' => $exception->catalogItemId,
'catalog_item_name' => $exception->catalogItemName, 'catalog_item_name' => $exception->catalogItemName,
'maximum_addable_quantity' => $exception->maximumAddableQuantity, 'availability' => app(CatalogItemAllowanceService::class)
->purchaseLimitExceededAvailability(
$exception->maximumAddableQuantity,
$exception->getMessage(),
)->toArray(),
], 422); ], 422);
}); });
$exceptions->render(function (PurchaseExpiredException $exception, Request $request) { $exceptions->render(function (PurchaseExpiredException $exception, Request $request) {

View File

@@ -2,4 +2,33 @@
return [ return [
'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30), '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

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@
</head> </head>
<body> <body>
<h1>Historial de ventas</h1> <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"> <div class="summary">
Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong> Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong>
@@ -44,7 +44,7 @@
@forelse ($sales as $sale) @forelse ($sales as $sale)
<tr> <tr>
<td>#{{ $sale->id }}</td> <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>{{ $sale->nombre_apellido ?: 'Sin nombre' }}</td>
<td class="center">{{ (int) ($sale->quantity ?? 0) }}</td> <td class="center">{{ (int) ($sale->quantity ?? 0) }}</td>
<td>{{ match ($sale->status) { <td>{{ match ($sale->status) {

View File

@@ -246,7 +246,7 @@ class CartControllerTest extends TestCase
->assertJsonPath('code', 'purchase.limit_exceeded') ->assertJsonPath('code', 'purchase.limit_exceeded')
->assertJsonPath('catalog_item_id', $item->id) ->assertJsonPath('catalog_item_id', $item->id)
->assertJsonPath('catalog_item_name', $item->nombre) ->assertJsonPath('catalog_item_name', $item->nombre)
->assertJsonPath('maximum_addable_quantity', 1) ->assertJsonPath('availability.maximum_quantity', 1)
->assertJsonPath( ->assertJsonPath(
'message', 'message',
"Podés agregar hasta 1 unidad más de “{$item->nombre}”.", "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}") $this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}")
->assertOk() ->assertOk()
->assertJsonPath('data.type', CatalogItemType::Bundle->value) ->assertJsonPath('data.type', CatalogItemType::Bundle->value)
->assertJsonPath('data.maximum_addable_quantity', 4) ->assertJsonPath('data.availability.maximum_quantity', 4)
->assertJsonMissingPath('data.stock_tecnico') ->assertJsonMissingPath('data.stock_tecnico')
->assertJsonCount(1, 'data.components') ->assertJsonCount(1, 'data.components')
->assertJsonPath('data.components.0.catalog_item_id', $component->id) ->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); $directItem = $this->createItem($tenant, 'Direct', $directInventory);
$row->featuredItems()->create(['catalog_item_id' => $directItem->id]); $row->featuredItems()->create(['catalog_item_id' => $directItem->id]);
$variantItem = $this->createItem($tenant, 'Variants'); $variantItem = $this->createItem($tenant, 'Variants', withoutInventory: true);
$firstInventory = Inventory::query()->create([ $firstInventory = Inventory::query()->create([
'real_stock' => 5, 'real_stock' => 5,
'reserved_stock' => 1, 'reserved_stock' => 1,
@@ -56,7 +56,7 @@ class CatalogControllerTest extends TestCase
]); ]);
$variantItem->variants()->create(['inventory_id' => $firstInventory->id]); $variantItem->variants()->create(['inventory_id' => $firstInventory->id]);
$variantItem->variants()->create(['inventory_id' => $secondInventory->id]); $variantItem->variants()->create(['inventory_id' => $secondInventory->id]);
$unavailableVariant = $variantItem->variants()->create([ $variantItem->variants()->create([
'inventory_id' => $unavailableInventory->id, 'inventory_id' => $unavailableInventory->id,
]); ]);
$cart->featuredItems()->create(['catalog_item_id' => $variantItem->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.nombre', 'Variants')
->assertJsonPath('0.items.0.descripcion', 'Variants description') ->assertJsonPath('0.items.0.descripcion', 'Variants description')
->assertJsonPath('0.items.0.precio', '100.00') ->assertJsonPath('0.items.0.precio', '100.00')
->assertJsonPath('0.items.0.maximum_addable_quantity', 7) ->assertJsonPath('0.items.0.availability.state', 'visible')
->assertJsonCount(3, '0.items.0.variants') ->assertJsonPath('0.items.0.availability.maximum_quantity', 7)
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4) ->assertJsonCount(2, '0.items.0.variants')
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3) ->assertJsonPath('0.items.0.variants.0.availability.maximum_quantity', 4)
->assertJsonPath('0.items.0.variants.2.id', $unavailableVariant->id) ->assertJsonPath('0.items.0.variants.0.availability.state', 'visible')
->assertJsonPath('0.items.0.variants.2.maximum_addable_quantity', 0) ->assertJsonPath('0.items.0.variants.1.availability.maximum_quantity', 3)
->assertJsonPath(
'0.items.0.variants.2.unavailable_message',
'Este producto no tiene stock disponible.',
)
->assertJsonPath('1.title', 'Row') ->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') ->assertJsonMissingPath('1.items.data.0.stock_tecnico')
->assertJsonCount(0, '1.items.data.0.variants'); ->assertJsonCount(0, '1.items.data.0.variants');
} }
@@ -98,7 +94,7 @@ class CatalogControllerTest extends TestCase
groupLayout: GroupLayout::Simple, groupLayout: GroupLayout::Simple,
); );
$user = User::factory()->create(); $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]); $item->update(['max_units_per_user' => 3]);
$firstVariant = $item->variants()->create([ $firstVariant = $item->variants()->create([
'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id, 'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id,
@@ -119,21 +115,21 @@ class CatalogControllerTest extends TestCase
$this->actingAs($user, 'sanctum') $this->actingAs($user, 'sanctum')
->getJson("/api/tenants/{$tenant->codigo}/catalog") ->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk() ->assertOk()
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 0) ->assertJsonPath('0.items.0.availability.maximum_quantity', 0)
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 0) ->assertJsonPath('0.items.0.availability.allowed_actions', [])
->assertJsonPath( ->assertJsonPath(
'0.items.0.variants.0.unavailable_message', '0.items.0.availability.reasons.0.message',
'Alcanzaste el cupo máximo permitido para este producto.',
)
->assertJsonPath(
'0.items.0.variants.1.unavailable_message',
'Alcanzaste el cupo máximo permitido para este producto.', '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.0.stock_tecnico')
->assertJsonMissingPath('0.items.0.variants.1.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'); $tenant = $this->createTenant('catalog-available-variants');
$group = $this->createGroup( $group = $this->createGroup(
@@ -143,7 +139,7 @@ class CatalogControllerTest extends TestCase
groupLayout: GroupLayout::SimpleVertical, groupLayout: GroupLayout::SimpleVertical,
); );
$unavailableItem = $this->createItem($tenant, 'Unavailable'); $unavailableItem = $this->createItem($tenant, 'Unavailable', withoutInventory: true);
$unavailableInventory = Inventory::query()->create([ $unavailableInventory = Inventory::query()->create([
'real_stock' => 4, 'real_stock' => 4,
'reserved_stock' => 4, 'reserved_stock' => 4,
@@ -151,7 +147,7 @@ class CatalogControllerTest extends TestCase
$unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]); $unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]);
$group->featuredItems()->create(['catalog_item_id' => $unavailableItem->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([ $availableInventory = Inventory::query()->create([
'real_stock' => 4, 'real_stock' => 4,
'reserved_stock' => 3, 'reserved_stock' => 3,
@@ -161,15 +157,9 @@ class CatalogControllerTest extends TestCase
$this->getJson("/api/tenants/{$tenant->codigo}/catalog") $this->getJson("/api/tenants/{$tenant->codigo}/catalog")
->assertOk() ->assertOk()
->assertJsonCount(2, '0.items') ->assertJsonCount(1, '0.items')
->assertJsonPath('0.items.0.nombre', 'Unavailable') ->assertJsonPath('0.items.0.nombre', 'Available')
->assertJsonPath('0.items.0.maximum_addable_quantity', 0) ->assertJsonCount(0, '0.items.0.availability.reasons');
->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);
} }
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void 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, 'order' => 0,
]); ]);
$variantItem = $this->createItem($tenant, 'Variant image'); $variantItem = $this->createItem($tenant, 'Variant image', withoutInventory: true);
$variantInventory = Inventory::query()->create(); $variantInventory = Inventory::query()->create(['real_stock' => 1]);
$variant = $variantItem->variants()->create(['inventory_id' => $variantInventory->id]); $variant = $variantItem->variants()->create(['inventory_id' => $variantInventory->id]);
$variantImage = $this->createAttachment('variant'); $variantImage = $this->createAttachment('variant');
$variant->attachments()->attach($variantImage, ['orden' => 0]); $variant->attachments()->attach($variantImage, ['orden' => 0]);
@@ -434,7 +424,12 @@ class CatalogControllerTest extends TestCase
Tenant $tenant, Tenant $tenant,
string $name, string $name,
?Inventory $inventory = null, ?Inventory $inventory = null,
bool $withoutInventory = false,
): CatalogItem { ): CatalogItem {
$inventory ??= $withoutInventory
? null
: Inventory::query()->create(['real_stock' => 10]);
return CatalogItem::query()->create([ return CatalogItem::query()->create([
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
'inventory_id' => $inventory?->id, 'inventory_id' => $inventory?->id,

View File

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

View File

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

View File

@@ -67,12 +67,26 @@ class EntryControllerTest extends TestCase
'is_enabled' => false, 'is_enabled' => false,
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.is_enabled', false); ->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', [ $this->assertDatabaseHas('categorias', [
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
'nombre' => $categoryName, 'nombre' => $categoryName,
'is_enabled' => false, 'is_enabled' => true,
]); ]);
} }
} }

View File

@@ -394,7 +394,7 @@ class StorePurchaseTest extends TestCase
->assertJsonPath('code', 'purchase.limit_exceeded') ->assertJsonPath('code', 'purchase.limit_exceeded')
->assertJsonPath('catalog_item_id', $variant->catalog_item_id) ->assertJsonPath('catalog_item_id', $variant->catalog_item_id)
->assertJsonPath('catalog_item_name', $variant->catalogItem->nombre) ->assertJsonPath('catalog_item_name', $variant->catalogItem->nombre)
->assertJsonPath('maximum_addable_quantity', 1); ->assertJsonPath('availability.maximum_quantity', 1);
$this->actingAs($otherUser, 'sanctum') $this->actingAs($otherUser, 'sanctum')
->postJson('/api/tenants/sonder/compras/start-checkout', [ ->postJson('/api/tenants/sonder/compras/start-checkout', [

View File

@@ -2,42 +2,73 @@
namespace Tests\Unit\Catalog; namespace Tests\Unit\Catalog;
use App\Domains\Catalog\Services\AvailabilityPolicyResolver;
use App\Domains\Catalog\Services\CatalogItemAllowanceService; use App\Domains\Catalog\Services\CatalogItemAllowanceService;
use ReflectionClass; use App\Domains\Purchase\Services\UserPurchaseLimitService;
use Mockery;
use Tests\TestCase; use Tests\TestCase;
class CatalogItemAllowanceServiceTest extends TestCase class CatalogItemAllowanceServiceTest extends TestCase
{ {
private CatalogItemAllowanceService $service; public function test_a_hidden_decision_only_exposes_its_state_and_reasons(): void
protected function setUp(): void
{ {
parent::setUp(); $availability = $this->service()->availability(0, 0)->toArray();
$this->service = (new ReflectionClass(CatalogItemAllowanceService::class)) $this->assertSame('hidden', $availability['state']);
->newInstanceWithoutConstructor(); $this->assertSame(
['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_user_quota_message_with_priority_over_stock(): 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( $this->assertSame(
'Alcanzaste el cupo máximo permitido para este producto.', 'requested_quantity_exceeds_user_quota',
$this->service->unavailableMessage(0, 0), $availability['reasons'][0]['code'],
);
$this->assertSame([
'select_variant',
'change_quantity',
], $availability['allowed_actions']);
}
private function service(): CatalogItemAllowanceService
{
return new CatalogItemAllowanceService(
Mockery::mock(UserPurchaseLimitService::class),
new AvailabilityPolicyResolver,
); );
} }
public function test_it_returns_the_out_of_stock_message(): void
{
$this->assertSame(
'Este producto no tiene stock disponible.',
$this->service->unavailableMessage(0, null),
);
}
public function test_it_returns_no_message_when_the_item_is_available(): void
{
$this->assertNull($this->service->unavailableMessage(1, null));
$this->assertNull($this->service->unavailableMessage(null, 1));
$this->assertNull($this->service->unavailableMessage(null, null));
}
} }

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\Sale\Services\AdminAppSalePdfService;
use App\Domains\Tenant\Models\Tenant; use App\Domains\Tenant\Models\Tenant;
use Barryvdh\DomPDF\ServiceProvider; use Barryvdh\DomPDF\ServiceProvider;
use Illuminate\Support\Carbon;
use Tests\TestCase; use Tests\TestCase;
class AdminAppSalePdfServiceTest extends TestCase class AdminAppSalePdfServiceTest extends TestCase
@@ -17,6 +18,14 @@ class AdminAppSalePdfServiceTest extends TestCase
parent::setUp(); parent::setUp();
$this->app->register(ServiceProvider::class); $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 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( $response = app(AdminAppSalePdfService::class)->downloadSales(
$this->tenant(), $this->tenant(),
collect([$this->sale()]), collect([$this->sale()]),
'America/La_Paz',
); );
$this->assertSame('application/pdf', $response->headers->get('content-type')); $this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString( $this->assertStringContainsString(
'attachment; filename=ventas_acme_', 'attachment; filename=ventas_acme_20260824_135300.pdf',
(string) $response->headers->get('content-disposition'), (string) $response->headers->get('content-disposition'),
); );
$this->assertStringStartsWith('%PDF', $response->getContent()); $this->assertStringStartsWith('%PDF', $response->getContent());
@@ -57,16 +67,52 @@ class AdminAppSalePdfServiceTest extends TestCase
$response = app(AdminAppSalePdfService::class)->downloadModifications( $response = app(AdminAppSalePdfService::class)->downloadModifications(
$this->tenant(), $this->tenant(),
collect([$modification]), collect([$modification]),
'America/La_Paz',
); );
$this->assertSame('application/pdf', $response->headers->get('content-type')); $this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString( $this->assertStringContainsString(
'attachment; filename=historial_modificaciones_acme_', 'attachment; filename=historial_modificaciones_acme_20260824_135300.pdf',
(string) $response->headers->get('content-disposition'), (string) $response->headers->get('content-disposition'),
); );
$this->assertStringStartsWith('%PDF', $response->getContent()); $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 private function tenant(): Tenant
{ {
return (new Tenant)->forceFill([ 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']);
}
}