Compare commits
7 Commits
homo
...
feature/al
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c646f3560 | |||
| 98bfc0d5e9 | |||
| d9635d47ba | |||
| e27f7bb166 | |||
| 2323994f30 | |||
| a73b628bb4 | |||
| 2fd6851b40 |
@@ -41,8 +41,6 @@ TELEPAGOS_LOG_LEVEL=info
|
||||
TELEPAGOS_LOG_DAYS=30
|
||||
COMMANDS_LOG_LEVEL=info
|
||||
COMMANDS_LOG_DAYS=30
|
||||
EMAILS_LOG_LEVEL=info
|
||||
EMAILS_LOG_DAYS=30
|
||||
|
||||
DB_CONNECTION=mysql
|
||||
DB_HOST=127.0.0.1
|
||||
|
||||
@@ -30,7 +30,6 @@ class AdminAppBootstrapResource extends JsonResource
|
||||
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ class AdminAppBootstrapService
|
||||
{
|
||||
return [
|
||||
'website_type' => WebsiteType::query()
|
||||
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||
->with(['siteLogo', 'footerLogo'])
|
||||
->where('dominio', $domain)
|
||||
->firstOrFail(),
|
||||
];
|
||||
|
||||
@@ -11,7 +11,7 @@ class ScannerBootstrapService
|
||||
{
|
||||
return [
|
||||
'website_type' => WebsiteType::query()
|
||||
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||
->with(['siteLogo', 'footerLogo'])
|
||||
->where('scanner_domain', $domain)
|
||||
->firstOrFail(),
|
||||
];
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
10
app/Domains/Catalog/Enums/AvailabilityEffect.php
Normal file
10
app/Domains/Catalog/Enums/AvailabilityEffect.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Catalog\Enums;
|
||||
|
||||
enum AvailabilityEffect: string
|
||||
{
|
||||
case Hide = 'hide';
|
||||
case Restrict = 'restrict';
|
||||
case Notice = 'notice';
|
||||
}
|
||||
11
app/Domains/Catalog/Enums/CatalogAction.php
Normal file
11
app/Domains/Catalog/Enums/CatalogAction.php
Normal 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';
|
||||
}
|
||||
@@ -27,7 +27,6 @@ use Illuminate\Support\Collection;
|
||||
'type',
|
||||
'slug',
|
||||
'nombre',
|
||||
'group_order',
|
||||
'descripcion',
|
||||
'precio',
|
||||
'inventory_policy',
|
||||
@@ -48,7 +47,6 @@ class CatalogItem extends Model
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'inventory_subject' => InventorySubject::Product->value,
|
||||
'has_tickets' => false,
|
||||
'group_order' => 0,
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
@@ -58,7 +56,6 @@ class CatalogItem extends Model
|
||||
'brand_id' => 'integer',
|
||||
'inventory_id' => 'integer',
|
||||
'type' => CatalogItemType::class,
|
||||
'group_order' => 'integer',
|
||||
'precio' => 'decimal:2',
|
||||
'inventory_policy' => InventoryPolicy::class,
|
||||
'inventory_subject' => InventorySubject::class,
|
||||
@@ -175,29 +172,17 @@ class CatalogItem extends Model
|
||||
}
|
||||
|
||||
/** @param Builder<CatalogItem> $query */
|
||||
public function scopeWhereAvailable(Builder $query): Builder
|
||||
public function scopeWhereVariantsAvailable(Builder $query): Builder
|
||||
{
|
||||
return $query->where(function (Builder $query): void {
|
||||
$query
|
||||
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||
->whereDoesntHave('variants')
|
||||
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||
->orWhereHas(
|
||||
'variants.inventory',
|
||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||
)
|
||||
->orWhere(function (Builder $directItemQuery): void {
|
||||
$directItemQuery
|
||||
->whereDoesntHave('variants')
|
||||
->where(function (Builder $inventoryQuery): void {
|
||||
$inventoryQuery
|
||||
->whereNull('catalog_items.inventory_id')
|
||||
->orWhereHas(
|
||||
'inventory',
|
||||
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
|
||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
||||
);
|
||||
});
|
||||
});
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ class StoreCatalogItemRequest extends FormRequest
|
||||
),
|
||||
],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'group_order' => ['sometimes', 'integer', 'min:0'],
|
||||
'descripcion' => ['sometimes', 'nullable', 'string'],
|
||||
'precio' => ['required', 'numeric', 'min:0'],
|
||||
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
'variants' => $catalogItem->visibleVariants()
|
||||
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
|
||||
'availability' => $this->availability($availableStock, $remainingUserQuota),
|
||||
'variants' => $catalogItem->variants
|
||||
->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,30 +94,27 @@ 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),
|
||||
];
|
||||
}
|
||||
|
||||
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
||||
{
|
||||
$attachment = $catalogItem->attachments->first()
|
||||
?? $catalogItem->visibleVariants()
|
||||
?? $catalogItem->variants
|
||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||
->first();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,9 +16,8 @@ class CatalogSearchItemResource extends JsonResource
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$availableStock = $this->availableStock();
|
||||
$visibleVariants = $this->visibleVariants();
|
||||
$attachment = $this->attachments->first()
|
||||
?? $visibleVariants
|
||||
?? $this->variants
|
||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||
->first();
|
||||
|
||||
@@ -29,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),
|
||||
'variants' => $visibleVariants
|
||||
'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,
|
||||
@@ -45,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();
|
||||
}
|
||||
}
|
||||
|
||||
63
app/Domains/Catalog/Services/AvailabilityDecision.php
Normal file
63
app/Domains/Catalog/Services/AvailabilityDecision.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
26
app/Domains/Catalog/Services/AvailabilityPolicyResolver.php
Normal file
26
app/Domains/Catalog/Services/AvailabilityPolicyResolver.php
Normal 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'] ?? [],
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -205,13 +208,6 @@ class CatalogService
|
||||
]);
|
||||
|
||||
$visibleVariants = $catalogItem->visibleVariants();
|
||||
if ($catalogItem->type === CatalogItemType::Standard
|
||||
&& ($catalogItem->inventory_id !== null || $catalogItem->variants->isNotEmpty())
|
||||
&& ! $catalogItem->isAvailable()) {
|
||||
throw new NotFoundHttpException('Catalog item is out of stock.');
|
||||
}
|
||||
|
||||
$catalogItem->setRelation('variants', $visibleVariants);
|
||||
$selectedVariant = $variantId === null
|
||||
? $visibleVariants->first()
|
||||
: $visibleVariants->firstWhere('id', $variantId);
|
||||
@@ -236,9 +232,8 @@ class CatalogService
|
||||
$containsPattern = "%{$normalizedTerm}%";
|
||||
$startsWithPattern = "{$normalizedTerm}%";
|
||||
|
||||
$paginator = CatalogItem::query()
|
||||
$query = CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->whereAvailable()
|
||||
->where(function (Builder $query) use ($containsPattern): void {
|
||||
$query
|
||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||
@@ -265,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],
|
||||
@@ -285,10 +283,9 @@ class CatalogService
|
||||
int $perPage,
|
||||
int $page,
|
||||
): LengthAwarePaginator {
|
||||
return CatalogItem::query()
|
||||
$query = CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('category_id', $category->id)
|
||||
->whereAvailable()
|
||||
->with([
|
||||
'attachments',
|
||||
'inventory',
|
||||
@@ -300,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);
|
||||
}
|
||||
|
||||
@@ -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,7 +50,6 @@ class FeaturedGroupService
|
||||
{
|
||||
$query = CatalogItem::query()
|
||||
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||
->whereAvailable()
|
||||
->where(function (Builder $query): void {
|
||||
$query
|
||||
->whereDoesntHave('category')
|
||||
@@ -72,6 +72,8 @@ class FeaturedGroupService
|
||||
'bundleComponents.variant.catalogItem',
|
||||
]);
|
||||
|
||||
$query = $this->visibleCatalogItems->apply($query);
|
||||
|
||||
return match ($featuredGroup->source_type) {
|
||||
FeaturedGroupSource::Manual => $query
|
||||
->select('catalog_items.*')
|
||||
@@ -82,9 +84,7 @@ class FeaturedGroupService
|
||||
FeaturedGroupSource::Category => $query
|
||||
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||
->orderBy('catalog_items.id'),
|
||||
FeaturedGroupSource::All => $query
|
||||
->orderBy('catalog_items.group_order')
|
||||
->orderBy('catalog_items.id'),
|
||||
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
];
|
||||
}
|
||||
|
||||
81
app/Domains/Catalog/Services/VisibleCatalogItemsQuery.php
Normal file
81
app/Domains/Catalog/Services/VisibleCatalogItemsQuery.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,6 @@
|
||||
namespace App\Domains\Integration\Services;
|
||||
|
||||
use App\Domains\Client\Models\Client;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Tenant\Models\WebsiteType;
|
||||
use Exception;
|
||||
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
||||
use Illuminate\Contracts\Mail\Mailer;
|
||||
@@ -72,35 +70,24 @@ class MailService extends BaseIntegrationService
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{data: string, name: string, mime: string}> $attachments
|
||||
*/
|
||||
public function send(
|
||||
string|array $recipient,
|
||||
string $subject,
|
||||
string $content,
|
||||
Tenant|WebsiteType|null $brand = null,
|
||||
array $attachments = [],
|
||||
): void {
|
||||
public function send(string|array $recipient, string $subject, string $content): void
|
||||
{
|
||||
if (! $this->mailer || ! $this->tenant) {
|
||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||
}
|
||||
|
||||
$brand ??= $this->tenant;
|
||||
$branding = $this->brandingFor($brand);
|
||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||
|
||||
$html = Blade::render(
|
||||
<<<'BLADE'
|
||||
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
{!! $content !!}
|
||||
</x-mail.branded-layout>
|
||||
BLADE,
|
||||
[
|
||||
'branding' => $branding,
|
||||
'headerLogoUrl' => $brand instanceof WebsiteType
|
||||
? $brand->siteLogo?->getTemporaryUrl(1440)
|
||||
: $brand->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
|
||||
'tenant' => $this->tenant,
|
||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||
'content' => $content,
|
||||
],
|
||||
);
|
||||
@@ -109,14 +96,6 @@ class MailService extends BaseIntegrationService
|
||||
->subject($subject)
|
||||
->html($html);
|
||||
|
||||
foreach ($attachments as $attachment) {
|
||||
$mail->attachData(
|
||||
$attachment['data'],
|
||||
$attachment['name'],
|
||||
['mime' => $attachment['mime']],
|
||||
);
|
||||
}
|
||||
|
||||
$this->mailer->to($recipient)->send($mail);
|
||||
}
|
||||
|
||||
@@ -127,36 +106,6 @@ class MailService extends BaseIntegrationService
|
||||
: (string) config('mail.default');
|
||||
}
|
||||
|
||||
/** @return array{name: string, primary_color: string, body_color: string, background_color: string, surface_color: string, header_bg_color: string, footer_bg_color: string} */
|
||||
private function brandingFor(Tenant|WebsiteType $brand): array
|
||||
{
|
||||
if ($brand instanceof WebsiteType) {
|
||||
$brand->loadMissing(['siteLogo', 'footerLogo']);
|
||||
|
||||
return [
|
||||
'name' => $brand->nombre,
|
||||
'primary_color' => $brand->primary_color ?? '#FF7006',
|
||||
'body_color' => $brand->body_color ?? '#666666',
|
||||
'background_color' => $brand->background_color ?? '#f8f8f8',
|
||||
'surface_color' => $brand->surface_color ?? '#ffffff',
|
||||
'header_bg_color' => $brand->surface_color ?? '#ffffff',
|
||||
'footer_bg_color' => $brand->login_header_footer_color ?? '#838383',
|
||||
];
|
||||
}
|
||||
|
||||
$brand->loadMissing(['headerLogo', 'footerLogo']);
|
||||
|
||||
return [
|
||||
'name' => $brand->nombre,
|
||||
'primary_color' => $brand->primary_color ?? '#6376f3',
|
||||
'body_color' => '#334155',
|
||||
'background_color' => '#f1f5f9',
|
||||
'surface_color' => '#ffffff',
|
||||
'header_bg_color' => $brand->header_bg_color ?? '#ffffff',
|
||||
'footer_bg_color' => $brand->footer_bg_color ?? '#334155',
|
||||
];
|
||||
}
|
||||
|
||||
public function onSetup(): void
|
||||
{
|
||||
if (! $this->mailer || ! $this->clientContext) {
|
||||
|
||||
@@ -28,21 +28,10 @@ class TestMail extends Mailable
|
||||
{
|
||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
||||
|
||||
$branding = [
|
||||
'name' => $this->tenant->nombre,
|
||||
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
|
||||
'body_color' => '#334155',
|
||||
'background_color' => '#f1f5f9',
|
||||
'surface_color' => '#ffffff',
|
||||
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
|
||||
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
|
||||
];
|
||||
|
||||
return new Content(
|
||||
view: 'mail.test',
|
||||
with: [
|
||||
'tenant' => $this->tenant,
|
||||
'branding' => $branding,
|
||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||
],
|
||||
|
||||
20
app/Domains/Notification/Events/TicketsAvailable.php
Normal file
20
app/Domains/Notification/Events/TicketsAvailable.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Events;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
class TicketsAvailable
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
/**
|
||||
* @param array<int, int> $ticketIds
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly Purchase $purchase,
|
||||
public readonly array $ticketIds,
|
||||
) {}
|
||||
}
|
||||
@@ -6,6 +6,8 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||
{
|
||||
@@ -20,10 +22,21 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||
|
||||
public function handle(PasswordResetRequested $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||
$event->attemptId,
|
||||
$event->tenantCode,
|
||||
$event->channel,
|
||||
);
|
||||
try {
|
||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||
$event->attemptId,
|
||||
$event->tenantCode,
|
||||
$event->channel,
|
||||
);
|
||||
} catch (Throwable $exception) {
|
||||
Log::error('Failed to send password reset email.', [
|
||||
'attempt_id' => $event->attemptId,
|
||||
'tenant_code' => $event->tenantCode,
|
||||
'channel' => $event->channel,
|
||||
'exception' => $exception,
|
||||
]);
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
|
||||
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
|
||||
@@ -20,6 +20,6 @@ class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
|
||||
|
||||
public function handle(PurchasePaid $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
|
||||
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Notification\Listeners;
|
||||
|
||||
use App\Domains\Notification\Events\TicketsAvailable;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
|
||||
class SendTicketsAvailableEmail implements ShouldQueueAfterCommit
|
||||
{
|
||||
use InteractsWithQueue;
|
||||
|
||||
public string $queue = 'emails';
|
||||
|
||||
public int $tries = 3;
|
||||
|
||||
/** @var array<int, int> */
|
||||
public array $backoff = [30, 120, 300];
|
||||
|
||||
public function handle(TicketsAvailable $event): void
|
||||
{
|
||||
app(NotificationMailService::class)->sendTicketsAvailable($event->purchase->getKey(), $event->ticketIds);
|
||||
}
|
||||
}
|
||||
@@ -9,43 +9,28 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use App\Domains\Ticket\Services\TicketPdfService;
|
||||
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
||||
use Closure;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Throwable;
|
||||
|
||||
class NotificationMailService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly MailService $mailService,
|
||||
private readonly TicketPdfService $ticketPdfService,
|
||||
) {}
|
||||
|
||||
public function sendWelcome(int $userId, string $tenantCode): void
|
||||
{
|
||||
$this->sendLogged('welcome', [
|
||||
'user_id' => $userId,
|
||||
'tenant_code' => $tenantCode,
|
||||
], function () use ($userId, $tenantCode): array {
|
||||
$tenant = Tenant::query()->with('websiteType')->where('codigo', $tenantCode)->firstOrFail();
|
||||
$user = User::query()->findOrFail($userId);
|
||||
$brand = $tenant->websiteType ?? $tenant;
|
||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
||||
$user = User::query()->findOrFail($userId);
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$user->email,
|
||||
"Bienvenido a {$brand->nombre}",
|
||||
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
|
||||
$brand,
|
||||
);
|
||||
|
||||
return [
|
||||
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
|
||||
];
|
||||
});
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$user->email,
|
||||
"Bienvenido a {$tenant->nombre}",
|
||||
view('mail.notifications.welcome', compact('tenant', 'user'))->render(),
|
||||
);
|
||||
}
|
||||
|
||||
public function sendPasswordResetCode(
|
||||
@@ -53,161 +38,95 @@ class NotificationMailService
|
||||
string $tenantCode,
|
||||
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
||||
): void {
|
||||
$context = [
|
||||
'attempt_id' => $attemptId,
|
||||
'tenant_code' => $tenantCode,
|
||||
'channel' => $channel,
|
||||
];
|
||||
$tenant = Tenant::query()
|
||||
->with('websiteType')
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
$attempt = ResetPasswordAttempt::query()
|
||||
->with('user')
|
||||
->findOrFail($attemptId);
|
||||
|
||||
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
|
||||
$tenant = Tenant::query()
|
||||
->with('websiteType')
|
||||
->where('codigo', $tenantCode)
|
||||
->firstOrFail();
|
||||
$attempt = ResetPasswordAttempt::query()
|
||||
->with('user')
|
||||
->findOrFail($attemptId);
|
||||
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
|
||||
'attempt_id' => $attemptId,
|
||||
'tenant_code' => $tenantCode,
|
||||
'attempt_status' => $attempt->status,
|
||||
]);
|
||||
|
||||
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
||||
$this->logSkipped('password_reset', array_merge($context, [
|
||||
'reason' => 'attempt_not_pending',
|
||||
'attempt_status' => $attempt->status,
|
||||
'user_id' => $attempt->user_id,
|
||||
]));
|
||||
return;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
$recoveryDomain = match ($channel) {
|
||||
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
|
||||
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||
default => $tenant->dominio,
|
||||
};
|
||||
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||
&& $tenant->base_path !== '/'
|
||||
? $tenant->base_path
|
||||
: '';
|
||||
$recoveryQuery = ['email' => $attempt->user->email];
|
||||
if (
|
||||
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
||||
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
|
||||
) {
|
||||
$recoveryQuery['code'] = $attempt->codigo;
|
||||
}
|
||||
$recoveryUrl = $recoveryDomain === null
|
||||
? null
|
||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||
|
||||
$recoveryDomain = match ($channel) {
|
||||
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
|
||||
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||
default => $tenant->dominio,
|
||||
};
|
||||
$recoveryBasePath = $channel === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||
&& $tenant->base_path !== '/'
|
||||
? $tenant->base_path
|
||||
: '';
|
||||
$recoveryQuery = ['email' => $attempt->user->email];
|
||||
if (
|
||||
$channel === PasswordResetRequested::CHANNEL_SCANNER
|
||||
&& $attempt->reason === ResetPasswordAttempt::REASON_STAFF_CREATED
|
||||
) {
|
||||
$recoveryQuery['code'] = $attempt->codigo;
|
||||
}
|
||||
$recoveryUrl = $recoveryDomain === null
|
||||
? null
|
||||
: 'https://'.$recoveryDomain.$recoveryBasePath.'/recuperar-contrasena/codigo?'.http_build_query($recoveryQuery);
|
||||
$brand = $tenant->websiteType ?? $tenant;
|
||||
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$attempt->user->email,
|
||||
"Código para recuperar tu contraseña - {$brand->nombre}",
|
||||
view('mail.notifications.password-reset', [
|
||||
'attempt' => $attempt,
|
||||
'recoveryUrl' => $recoveryUrl,
|
||||
'brand' => $brand,
|
||||
])->render(),
|
||||
$brand,
|
||||
);
|
||||
|
||||
return [
|
||||
'user_id' => $attempt->user_id,
|
||||
'recovery_domain_available' => $recoveryDomain !== null,
|
||||
];
|
||||
});
|
||||
$this->mailService
|
||||
->forTenant($tenantCode)
|
||||
->send(
|
||||
$attempt->user->email,
|
||||
"Código para recuperar tu contraseña - {$tenant->nombre}",
|
||||
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(),
|
||||
);
|
||||
}
|
||||
|
||||
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||
public function sendPurchasePaid(int $purchaseId): void
|
||||
{
|
||||
$context = ['purchase_id' => $purchaseId];
|
||||
$purchase = Purchase::query()
|
||||
->with(['tenant', 'user', 'items'])
|
||||
->findOrFail($purchaseId);
|
||||
|
||||
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
|
||||
$purchase = Purchase::query()
|
||||
->with(['tenant', 'user', 'items'])
|
||||
->find($purchaseId);
|
||||
$this->mailService
|
||||
->forTenant($purchase->tenant_codigo)
|
||||
->send(
|
||||
$this->recipientFor($purchase),
|
||||
"Pago confirmado - Compra #{$purchase->getKey()}",
|
||||
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
|
||||
);
|
||||
}
|
||||
|
||||
if ($purchase === null) {
|
||||
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
||||
'reason' => 'purchase_not_found',
|
||||
'missing_model' => Purchase::class,
|
||||
]));
|
||||
/** @param array<int, int> $ticketIds */
|
||||
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
|
||||
{
|
||||
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId);
|
||||
/** @var Collection<int, Ticket> $tickets */
|
||||
$tickets = Ticket::query()
|
||||
->where('tenant_code', $purchase->tenant_codigo)
|
||||
->where('user_id', $purchase->user_id)
|
||||
->whereKey($ticketIds)
|
||||
->with(TicketPresentationResolver::RELATIONS)
|
||||
->get();
|
||||
|
||||
return null;
|
||||
}
|
||||
if ($tickets->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @var Collection<int, Ticket> $tickets */
|
||||
$tickets = Ticket::query()
|
||||
->where('source_purchase_id', $purchase->getKey())
|
||||
->where('tenant_code', $purchase->tenant_codigo)
|
||||
->with(TicketPresentationResolver::RELATIONS)
|
||||
->get();
|
||||
$attachments = $tickets->isEmpty()
|
||||
? []
|
||||
: [[
|
||||
'data' => $this->ticketPdfService->contents($purchase->tenant, $tickets),
|
||||
'name' => $this->ticketPdfService->filename($tickets),
|
||||
'mime' => 'application/pdf',
|
||||
]];
|
||||
|
||||
$this->mailService
|
||||
->forTenant($purchase->tenant_codigo)
|
||||
->send(
|
||||
$this->recipientFor($purchase),
|
||||
"Compra confirmada - Compra #{$purchase->getKey()}",
|
||||
view('mail.notifications.purchase-confirmed', compact('purchase', 'tickets'))->render(),
|
||||
attachments: $attachments,
|
||||
);
|
||||
|
||||
return [
|
||||
'tenant_code' => $purchase->tenant_codigo,
|
||||
'user_id' => $purchase->user_id,
|
||||
'purchase_status' => $purchase->status,
|
||||
'purchase_item_count' => $purchase->items->count(),
|
||||
'ticket_count' => $tickets->count(),
|
||||
'ticket_ids' => $tickets->modelKeys(),
|
||||
];
|
||||
});
|
||||
$this->mailService
|
||||
->forTenant($purchase->tenant_codigo)
|
||||
->send(
|
||||
$this->recipientFor($purchase),
|
||||
'Tus tickets ya están disponibles',
|
||||
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
|
||||
);
|
||||
}
|
||||
|
||||
private function recipientFor(Purchase $purchase): string
|
||||
{
|
||||
return (string) ($purchase->email ?: $purchase->user?->email);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @param Closure(): (array<string, mixed>|null) $send
|
||||
*/
|
||||
private function sendLogged(string $emailType, array $context, Closure $send): void
|
||||
{
|
||||
try {
|
||||
$resultContext = $send();
|
||||
|
||||
if ($resultContext === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
Log::channel('emails')->info('Notification email sent.', array_merge($context, $resultContext, [
|
||||
'email_type' => $emailType,
|
||||
'mailer' => $this->mailService->mailerName(),
|
||||
]));
|
||||
} catch (Throwable $exception) {
|
||||
Log::channel('emails')->error('Notification email delivery failed.', array_merge($context, [
|
||||
'email_type' => $emailType,
|
||||
'exception' => $exception,
|
||||
]));
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
private function logSkipped(string $emailType, array $context): void
|
||||
{
|
||||
Log::channel('emails')->warning('Notification email skipped.', array_merge($context, [
|
||||
'email_type' => $emailType,
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,11 +8,12 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
|
||||
|
||||
- `UserRegistered`: dispara el correo de bienvenida.
|
||||
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
||||
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
|
||||
- `PurchasePaid`: envía la confirmación de pago.
|
||||
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
|
||||
|
||||
## Componentes
|
||||
|
||||
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail`, `SendPurchasePaidEmail` y `SendTicketsAvailableEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||
|
||||
## API y dependencias
|
||||
|
||||
@@ -22,6 +23,4 @@ No expone rutas HTTP. Consume datos de `Auth`, `Tenant`, `Purchase` y `Ticket`,
|
||||
|
||||
- Los listeners reciben identificadores y vuelven a cargar los modelos, evitando transportar entidades obsoletas.
|
||||
- La recuperación no se envía si el intento dejó de estar pendiente.
|
||||
- Los correos de cuenta (bienvenida y recuperación de contraseña) usan la identidad visual del `WebsiteType` asociado al tenant, con fallback al tenant si no tiene uno configurado.
|
||||
- El correo transaccional de compra confirmada usa la identidad visual del tenant y adjunta un único PDF cuando la compra generó tickets.
|
||||
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Purchase\Events;
|
||||
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use Illuminate\Foundation\Events\Dispatchable;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
@@ -9,5 +10,5 @@ class PurchasePaid
|
||||
{
|
||||
use Dispatchable, SerializesModels;
|
||||
|
||||
public function __construct(public readonly int $purchaseId) {}
|
||||
public function __construct(public readonly Purchase $purchase) {}
|
||||
}
|
||||
|
||||
@@ -193,7 +193,7 @@ class Purchase extends Model
|
||||
'status' => self::STATUS_PAID,
|
||||
]);
|
||||
|
||||
PurchasePaid::dispatch($this->getKey());
|
||||
PurchasePaid::dispatch($this);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,21 +9,18 @@ use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleResource;
|
||||
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
|
||||
use App\Domains\Sale\Services\AdminAppSaleExcelService;
|
||||
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
||||
use App\Domains\Sale\Services\AdminAppSaleService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class SaleController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
protected AdminAppSaleService $saleService,
|
||||
protected AdminAppSalePdfService $salePdfService,
|
||||
protected AdminAppSaleExcelService $saleExcelService,
|
||||
) {}
|
||||
|
||||
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
|
||||
@@ -97,27 +94,4 @@ class SaleController extends Controller
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
|
||||
public function downloadExcel(AdminAppSalePdfRequest $request): StreamedResponse
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return $this->saleExcelService->downloadSales(
|
||||
$tenant,
|
||||
$this->saleService->salesForExport($tenant, $request->validated()),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
|
||||
public function downloadModificationsExcel(
|
||||
AdminAppSaleModificationPdfRequest $request,
|
||||
): StreamedResponse {
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return $this->saleExcelService->downloadModifications(
|
||||
$tenant,
|
||||
$this->saleService->modificationsForExport($tenant),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,211 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Sale\Services;
|
||||
|
||||
use App\Domains\Logging\Models\ValueChange;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class AdminAppSaleExcelService
|
||||
{
|
||||
/** @param Collection<int, Purchase> $sales */
|
||||
public function downloadSales(Tenant $tenant, Collection $sales, string $timeZone): StreamedResponse
|
||||
{
|
||||
$generatedAt = now();
|
||||
$spreadsheet = $this->spreadsheet($tenant, 'Historial de ventas');
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Ventas');
|
||||
$sheet->fromArray([
|
||||
'ID',
|
||||
'Fecha',
|
||||
'Cliente',
|
||||
'Cantidad',
|
||||
'Estado',
|
||||
'Importe',
|
||||
'Tickets',
|
||||
], null, 'A1');
|
||||
|
||||
foreach ($sales->values() as $index => $sale) {
|
||||
$row = $index + 2;
|
||||
$sheet->setCellValueExplicit("A{$row}", '#'.$sale->id, DataType::TYPE_STRING);
|
||||
if ($sale->created_at) {
|
||||
$sheet->setCellValue(
|
||||
"B{$row}",
|
||||
Date::dateTimeToExcel($sale->created_at->copy()->timezone($timeZone)),
|
||||
);
|
||||
}
|
||||
$sheet->setCellValueExplicit(
|
||||
"C{$row}",
|
||||
$sale->nombre_apellido ?: 'Sin nombre',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValue("D{$row}", (int) ($sale->quantity ?? 0));
|
||||
$sheet->setCellValue("E{$row}", $this->saleStatus($sale->status));
|
||||
$sheet->setCellValue("F{$row}", (float) $sale->total);
|
||||
$sheet->setCellValue("G{$row}", (int) ($sale->tickets_count ?? 0));
|
||||
}
|
||||
|
||||
$lastRow = max(2, $sales->count() + 1);
|
||||
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
|
||||
$sheet->getStyle("F2:F{$lastRow}")->getNumberFormat()->setFormatCode('$ #,##0.00');
|
||||
$this->formatSheet($spreadsheet, 'A1:G1', "A1:G{$lastRow}", [
|
||||
'A' => 13,
|
||||
'B' => 20,
|
||||
'C' => 32,
|
||||
'D' => 12,
|
||||
'E' => 22,
|
||||
'F' => 16,
|
||||
'G' => 12,
|
||||
]);
|
||||
|
||||
return $this->download(
|
||||
$spreadsheet,
|
||||
'ventas_'.$tenant->codigo.'_'
|
||||
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
/** @param Collection<int, ValueChange> $modifications */
|
||||
public function downloadModifications(
|
||||
Tenant $tenant,
|
||||
Collection $modifications,
|
||||
string $timeZone,
|
||||
): StreamedResponse {
|
||||
$generatedAt = now();
|
||||
$spreadsheet = $this->spreadsheet($tenant, 'Historial de modificaciones de ventas');
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->setTitle('Modificaciones');
|
||||
$sheet->fromArray([
|
||||
'Fecha',
|
||||
'Hora',
|
||||
'Venta',
|
||||
'Cliente',
|
||||
'Campo',
|
||||
'Valor anterior',
|
||||
'Valor nuevo',
|
||||
'Modificado por',
|
||||
], null, 'A1');
|
||||
|
||||
foreach ($modifications->values() as $index => $modification) {
|
||||
$row = $index + 2;
|
||||
$changedAt = $modification->changed_at->copy()->timezone($timeZone);
|
||||
$sale = $modification->trackable;
|
||||
$sheet->setCellValue("A{$row}", Date::dateTimeToExcel($changedAt));
|
||||
$sheet->setCellValue("B{$row}", Date::dateTimeToExcel($changedAt));
|
||||
$sheet->setCellValueExplicit(
|
||||
"C{$row}",
|
||||
'#'.$modification->trackable_id,
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"D{$row}",
|
||||
$sale?->nombre_apellido ?: 'Sin nombre',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"E{$row}",
|
||||
$modification->attribute,
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"F{$row}",
|
||||
$modification->old_value ?? '-',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"G{$row}",
|
||||
$modification->new_value ?? '-',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
$sheet->setCellValueExplicit(
|
||||
"H{$row}",
|
||||
$modification->user?->nombre_apellido ?? 'Sistema',
|
||||
DataType::TYPE_STRING,
|
||||
);
|
||||
}
|
||||
|
||||
$lastRow = max(2, $modifications->count() + 1);
|
||||
$sheet->getStyle("A2:A{$lastRow}")->getNumberFormat()->setFormatCode('dd/mm/yyyy');
|
||||
$sheet->getStyle("B2:B{$lastRow}")->getNumberFormat()->setFormatCode('hh:mm:ss');
|
||||
$this->formatSheet($spreadsheet, 'A1:H1', "A1:H{$lastRow}", [
|
||||
'A' => 14,
|
||||
'B' => 12,
|
||||
'C' => 13,
|
||||
'D' => 32,
|
||||
'E' => 20,
|
||||
'F' => 24,
|
||||
'G' => 24,
|
||||
'H' => 28,
|
||||
]);
|
||||
|
||||
return $this->download(
|
||||
$spreadsheet,
|
||||
'historial_modificaciones_'.$tenant->codigo.'_'
|
||||
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx',
|
||||
);
|
||||
}
|
||||
|
||||
private function spreadsheet(Tenant $tenant, string $title): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet;
|
||||
$spreadsheet->getProperties()
|
||||
->setCreator('Shopit')
|
||||
->setTitle($title)
|
||||
->setSubject($tenant->nombre);
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
/** @param array<string, int> $widths */
|
||||
private function formatSheet(
|
||||
Spreadsheet $spreadsheet,
|
||||
string $headerRange,
|
||||
string $filterRange,
|
||||
array $widths,
|
||||
): void {
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$sheet->getStyle($headerRange)->applyFromArray([
|
||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
||||
'fill' => [
|
||||
'fillType' => Fill::FILL_SOLID,
|
||||
'startColor' => ['rgb' => '26382E'],
|
||||
],
|
||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
||||
]);
|
||||
$sheet->getRowDimension(1)->setRowHeight(24);
|
||||
$sheet->freezePane('A2');
|
||||
$sheet->setAutoFilter($filterRange);
|
||||
|
||||
foreach ($widths as $column => $width) {
|
||||
$sheet->getColumnDimension($column)->setWidth($width);
|
||||
}
|
||||
}
|
||||
|
||||
private function download(Spreadsheet $spreadsheet, string $filename): StreamedResponse
|
||||
{
|
||||
return response()->streamDownload(function () use ($spreadsheet): void {
|
||||
(new Xlsx($spreadsheet))->save('php://output');
|
||||
$spreadsheet->disconnectWorksheets();
|
||||
}, $filename, [
|
||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
]);
|
||||
}
|
||||
|
||||
private function saleStatus(string $status): string
|
||||
{
|
||||
return match ($status) {
|
||||
Purchase::STATUS_PAID => 'Confirmado',
|
||||
Purchase::STATUS_CREATED => 'Por completar datos',
|
||||
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW => 'Esperando pago',
|
||||
default => 'Anulado',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
||||
|
||||
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
||||
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
||||
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
|
||||
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
|
||||
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
||||
- `SaleController`: entrada HTTP del panel.
|
||||
@@ -17,8 +16,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
||||
|
||||
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
|
||||
|
||||
- `GET /sales`, `GET /sales/pdf` y `GET /sales/excel`.
|
||||
- `GET /sales/modifications`, `GET /sales/modifications/pdf` y `GET /sales/modifications/excel`.
|
||||
- `GET /sales` y `GET /sales/pdf`.
|
||||
- `GET /sales/modifications` y `GET /sales/modifications/pdf`.
|
||||
|
||||
## Dependencias
|
||||
|
||||
@@ -26,4 +25,4 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
||||
|
||||
## Consideraciones
|
||||
|
||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla y PDF.
|
||||
|
||||
@@ -8,10 +8,8 @@ Route::prefix('v1/adminapp/tenant')
|
||||
->group(function (): void {
|
||||
Route::get('sales', [SaleController::class, 'index']);
|
||||
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
|
||||
Route::get('sales/excel', [SaleController::class, 'downloadExcel']);
|
||||
Route::get('sales/modifications', [SaleController::class, 'modifications']);
|
||||
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
|
||||
Route::get('sales/modifications/excel', [SaleController::class, 'downloadModificationsExcel']);
|
||||
Route::post('sales/{sale}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
|
||||
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
|
||||
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');
|
||||
|
||||
@@ -82,7 +82,6 @@ class WebsiteTypeService
|
||||
&& $previousLogo->id !== $websiteType->site_logo
|
||||
&& $previousLogo->id !== $websiteType->footer_logo
|
||||
&& $previousLogo->id !== $websiteType->favicon_id
|
||||
&& ! $this->isReferencedByWebsiteType($previousLogo)
|
||||
) {
|
||||
$this->attachmentService->delete($previousLogo);
|
||||
}
|
||||
@@ -91,16 +90,4 @@ class WebsiteTypeService
|
||||
return $websiteType;
|
||||
});
|
||||
}
|
||||
|
||||
private function isReferencedByWebsiteType(Attachment $attachment): bool
|
||||
{
|
||||
return WebsiteType::query()
|
||||
->where(function ($query) use ($attachment): void {
|
||||
$query
|
||||
->where('site_logo', $attachment->id)
|
||||
->orWhere('footer_logo', $attachment->id)
|
||||
->orWhere('favicon_id', $attachment->id);
|
||||
})
|
||||
->exists();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
namespace App\Domains\Ticket\Listeners;
|
||||
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Notification\Events\TicketsAvailable;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
||||
use App\Domains\Ticket\Services\TicketGeneratorService;
|
||||
|
||||
@@ -16,10 +16,13 @@ class GenerateTicketsForPaidPurchase
|
||||
|
||||
public function handle(PurchasePaid $event): void
|
||||
{
|
||||
$purchase = Purchase::query()
|
||||
$purchase = $event->purchase
|
||||
->newQuery()
|
||||
->with(['user', 'items'])
|
||||
->findOrFail($event->purchaseId);
|
||||
->findOrFail($event->purchase->getKey());
|
||||
$user = $purchase->user;
|
||||
$ticketIds = [];
|
||||
|
||||
foreach ($purchase->items as $purchaseItem) {
|
||||
$catalogItem = CatalogItem::query()
|
||||
->where('tenant_code', $purchase->tenant_codigo)
|
||||
@@ -37,13 +40,19 @@ class GenerateTicketsForPaidPurchase
|
||||
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
||||
}
|
||||
|
||||
$this->ticketGenerator->generate(
|
||||
$generatedTickets = $this->ticketGenerator->generate(
|
||||
$catalogItem,
|
||||
$user,
|
||||
$purchaseItem->cantidad,
|
||||
$purchaseItem->source_variant_id,
|
||||
$purchase->getKey(),
|
||||
);
|
||||
|
||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
||||
}
|
||||
|
||||
if ($ticketIds !== []) {
|
||||
TicketsAvailable::dispatch($purchase, $ticketIds);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Domains\Ticket\Services;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticket\Models\Ticket;
|
||||
use Barryvdh\DomPDF\Facade\Pdf;
|
||||
use Barryvdh\DomPDF\PDF as DomPdf;
|
||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||
use Endroid\QrCode\QrCode;
|
||||
use Endroid\QrCode\Writer\PngWriter;
|
||||
@@ -20,36 +19,12 @@ class TicketPdfService
|
||||
* @param Collection<int, Ticket> $tickets
|
||||
*/
|
||||
public function download(Tenant $tenant, Collection $tickets): Response
|
||||
{
|
||||
return $this->pdf($tenant, $tickets)->download($this->filename($tickets));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Ticket> $tickets
|
||||
*/
|
||||
public function contents(Tenant $tenant, Collection $tickets): string
|
||||
{
|
||||
return $this->pdf($tenant, $tickets)->output();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Ticket> $tickets
|
||||
*/
|
||||
public function filename(Collection $tickets): string
|
||||
{
|
||||
return 'tickets_'.$tickets->pluck('id')->implode('_').'.pdf';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, Ticket> $tickets
|
||||
*/
|
||||
private function pdf(Tenant $tenant, Collection $tickets): DomPdf
|
||||
{
|
||||
$tenant->loadMissing('headerLogo');
|
||||
$primaryColor = $this->color($tenant->primary_color, '#009933');
|
||||
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
|
||||
|
||||
return Pdf::loadView('pdf.tickets', [
|
||||
$pdf = Pdf::loadView('pdf.tickets', [
|
||||
'tenant' => $tenant,
|
||||
'tickets' => $tickets,
|
||||
'logoDataUri' => $this->logoDataUri($tenant),
|
||||
@@ -60,6 +35,10 @@ class TicketPdfService
|
||||
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
|
||||
),
|
||||
])->setPaper('a4');
|
||||
|
||||
$ticketIds = $tickets->pluck('id')->implode('_');
|
||||
|
||||
return $pdf->download("tickets_{$ticketIds}.pdf");
|
||||
}
|
||||
|
||||
private function qrCodeDataUri(string $value): string
|
||||
|
||||
@@ -21,7 +21,7 @@ vigente, vencido o usado, y resuelve sus fechas efectivas de inicio y fin sin pe
|
||||
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
|
||||
2. `GenerateTicketsForPaidPurchase` atiende el evento.
|
||||
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
||||
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
|
||||
4. El flujo puede emitir disponibilidad para que `Notification` informe al comprador.
|
||||
|
||||
## Endpoints
|
||||
|
||||
|
||||
@@ -3,9 +3,11 @@
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Notification\Events\TicketsAvailable;
|
||||
use App\Domains\Notification\Events\UserRegistered;
|
||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||
@@ -31,8 +33,9 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Event::listen(PurchasePaid::class, SendPurchasePaidEmail::class);
|
||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
||||
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -6,16 +6,15 @@
|
||||
"keywords": ["laravel", "framework"],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"ext-gd": "*",
|
||||
"php": "^8.3",
|
||||
"barryvdh/laravel-dompdf": "^3.1",
|
||||
"endroid/qr-code": "^6.1",
|
||||
"laravel/framework": "^13.8",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/socialite": "^5.29",
|
||||
"laravel/tinker": "^3.0",
|
||||
"league/flysystem-aws-s3-v3": "^3.0",
|
||||
"phpoffice/phpspreadsheet": "^5.9"
|
||||
"league/flysystem-aws-s3-v3": "^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.23",
|
||||
|
||||
376
composer.lock
generated
376
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "a593ab47d99b233f75851dbb7ea50479",
|
||||
"content-hash": "ce185c60c617846be30ae694f0cf6e9c",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -417,82 +417,6 @@
|
||||
],
|
||||
"time": "2024-02-09T16:56:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
"version": "3.4.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/composer/pcre.git",
|
||||
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||
"reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"conflict": {
|
||||
"phpstan/phpstan": "<2.2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^2",
|
||||
"phpstan/phpstan-deprecation-rules": "^2",
|
||||
"phpstan/phpstan-strict-rules": "^2",
|
||||
"phpunit/phpunit": "^9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"phpstan": {
|
||||
"includes": [
|
||||
"extension.neon"
|
||||
]
|
||||
},
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Composer\\Pcre\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jordi Boggiano",
|
||||
"email": "j.boggiano@seld.be",
|
||||
"homepage": "http://seld.be"
|
||||
}
|
||||
],
|
||||
"description": "PCRE wrapping library that offers type-safe preg_* replacements.",
|
||||
"keywords": [
|
||||
"PCRE",
|
||||
"preg",
|
||||
"regex",
|
||||
"regular expression"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/composer/pcre/issues",
|
||||
"source": "https://github.com/composer/pcre/tree/3.4.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://packagist.com",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/composer",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-07T11:47:49+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dasprid/enum",
|
||||
"version": "1.0.7",
|
||||
@@ -3000,191 +2924,6 @@
|
||||
],
|
||||
"time": "2026-03-08T20:05:35+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/maennchen/ZipStream-PHP.git",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"ext-zlib": "*",
|
||||
"php-64bit": "^8.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"brianium/paratest": "^7.7",
|
||||
"ext-zip": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.86",
|
||||
"guzzlehttp/guzzle": "^7.5",
|
||||
"mikey179/vfsstream": "^1.6",
|
||||
"php-coveralls/php-coveralls": "^2.5",
|
||||
"phpunit/phpunit": "^12.0",
|
||||
"vimeo/psalm": "^6.0"
|
||||
},
|
||||
"suggest": {
|
||||
"guzzlehttp/psr7": "^2.4",
|
||||
"psr/http-message": "^2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"ZipStream\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Paul Duncan",
|
||||
"email": "pabs@pablotron.org"
|
||||
},
|
||||
{
|
||||
"name": "Jonatan Männchen",
|
||||
"email": "jonatan@maennchen.ch"
|
||||
},
|
||||
{
|
||||
"name": "Jesse Donat",
|
||||
"email": "donatj@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "András Kolesár",
|
||||
"email": "kolesar@kolesar.hu"
|
||||
}
|
||||
],
|
||||
"description": "ZipStream is a library for dynamically streaming dynamic zip files from PHP without writing to the disk at all on the server.",
|
||||
"keywords": [
|
||||
"stream",
|
||||
"zip"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/maennchen/ZipStream-PHP/issues",
|
||||
"source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/maennchen",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-04-11T18:38:28+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/complex",
|
||||
"version": "3.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPComplex.git",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"reference": "95c56caa1cf5c766ad6d65b6344b807c1e8405b9",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.2 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Complex\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@lange.demon.co.uk"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with complex numbers",
|
||||
"homepage": "https://github.com/MarkBaker/PHPComplex",
|
||||
"keywords": [
|
||||
"complex",
|
||||
"mathematics"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPComplex/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPComplex/tree/3.0.2"
|
||||
},
|
||||
"time": "2022-12-06T16:21:08+00:00"
|
||||
},
|
||||
{
|
||||
"name": "markbaker/matrix",
|
||||
"version": "3.0.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/MarkBaker/PHPMatrix.git",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/MarkBaker/PHPMatrix/zipball/728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"reference": "728434227fe21be27ff6d86621a1b13107a2562c",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-master",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpdocumentor/phpdocumentor": "2.*",
|
||||
"phploc/phploc": "^4.0",
|
||||
"phpmd/phpmd": "2.*",
|
||||
"phpunit/phpunit": "^7.0 || ^8.0 || ^9.0",
|
||||
"sebastian/phpcpd": "^4.0",
|
||||
"squizlabs/php_codesniffer": "^3.7"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Matrix\\": "classes/src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"email": "mark@demon-angel.eu"
|
||||
}
|
||||
],
|
||||
"description": "PHP Class for working with matrices",
|
||||
"homepage": "https://github.com/MarkBaker/PHPMatrix",
|
||||
"keywords": [
|
||||
"mathematics",
|
||||
"matrix",
|
||||
"vector"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/MarkBaker/PHPMatrix/issues",
|
||||
"source": "https://github.com/MarkBaker/PHPMatrix/tree/3.0.1"
|
||||
},
|
||||
"time": "2022-12-02T22:17:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "masterminds/html5",
|
||||
"version": "2.10.1",
|
||||
@@ -3947,115 +3686,6 @@
|
||||
},
|
||||
"time": "2020-10-15T08:29:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoffice/phpspreadsheet",
|
||||
"version": "5.9.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
|
||||
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||
"reference": "05e99ebf61238a70227b4d9cc02d0030d34f6339",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"composer/pcre": "^1||^2||^3",
|
||||
"ext-ctype": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-gd": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-xml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"ext-zip": "*",
|
||||
"ext-zlib": "*",
|
||||
"maennchen/zipstream-php": "^2.1 || ^3.0",
|
||||
"markbaker/complex": "^3.0",
|
||||
"markbaker/matrix": "^3.0",
|
||||
"php": "^8.2",
|
||||
"psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"dealerdirect/phpcodesniffer-composer-installer": "dev-main",
|
||||
"dompdf/dompdf": "^2.0 || ^3.0",
|
||||
"ext-intl": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.2",
|
||||
"mitoteam/jpgraph": "^10.5",
|
||||
"mpdf/mpdf": "^8.1.1",
|
||||
"phpcompatibility/php-compatibility": "^9.3",
|
||||
"phpstan/phpstan": "^1.1 || ^2.0",
|
||||
"phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0 || ^2.0",
|
||||
"phpunit/phpunit": "^10.5 || ^11.0",
|
||||
"squizlabs/php_codesniffer": "^3.7",
|
||||
"tecnickcom/tcpdf": "^6.5"
|
||||
},
|
||||
"suggest": {
|
||||
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
|
||||
"ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
|
||||
"mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
|
||||
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
|
||||
"tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maarten Balliauw",
|
||||
"homepage": "https://blog.maartenballiauw.be"
|
||||
},
|
||||
{
|
||||
"name": "Mark Baker",
|
||||
"homepage": "https://markbakeruk.net"
|
||||
},
|
||||
{
|
||||
"name": "Franck Lefevre",
|
||||
"homepage": "https://rootslabs.net"
|
||||
},
|
||||
{
|
||||
"name": "Erik Tilt"
|
||||
},
|
||||
{
|
||||
"name": "Adrien Crivelli"
|
||||
},
|
||||
{
|
||||
"name": "Owen Leibman"
|
||||
}
|
||||
],
|
||||
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
|
||||
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
|
||||
"keywords": [
|
||||
"OpenXML",
|
||||
"excel",
|
||||
"gnumeric",
|
||||
"ods",
|
||||
"php",
|
||||
"spreadsheet",
|
||||
"xls",
|
||||
"xlsx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
|
||||
"source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.9.0"
|
||||
},
|
||||
"time": "2026-07-12T19:17:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpoption/phpoption",
|
||||
"version": "1.9.5",
|
||||
@@ -10208,8 +9838,8 @@
|
||||
"prefer-stable": true,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": "^8.3",
|
||||
"ext-gd": "*"
|
||||
"ext-gd": "*",
|
||||
"php": "^8.3"
|
||||
},
|
||||
"platform-dev": {},
|
||||
"plugin-api-version": "2.9.0"
|
||||
|
||||
@@ -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',
|
||||
],
|
||||
],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -89,14 +89,6 @@ return [
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'emails' => [
|
||||
'driver' => 'daily',
|
||||
'path' => storage_path('logs/emails/emails.log'),
|
||||
'level' => env('EMAILS_LOG_LEVEL', 'info'),
|
||||
'days' => env('EMAILS_LOG_DAYS', 30),
|
||||
'replace_placeholders' => true,
|
||||
],
|
||||
|
||||
'slack' => [
|
||||
'driver' => 'slack',
|
||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('catalog_items', function (Blueprint $table): void {
|
||||
$table->unsignedInteger('group_order')->default(0)->after('nombre');
|
||||
});
|
||||
|
||||
$footballOrder = [
|
||||
1 => [
|
||||
'slugs' => ['camiseta', 'camiseta-oficial-fnfi'],
|
||||
'names' => ['Camiseta', 'CAMISETA OFICIAL FNFI'],
|
||||
],
|
||||
2 => [
|
||||
'slugs' => ['alojamiento', 'camping'],
|
||||
'names' => ['Alojamiento', 'CAMPING'],
|
||||
],
|
||||
3 => [
|
||||
'slugs' => ['abono'],
|
||||
'names' => ['Abono', 'ABONO'],
|
||||
],
|
||||
4 => [
|
||||
'slugs' => ['comida'],
|
||||
'names' => ['Comida', 'COMIDA'],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($footballOrder as $order => $identifiers) {
|
||||
DB::table('catalog_items')
|
||||
->where('tenant_code', 'fiesta_futbol_infantil')
|
||||
->where(function ($query) use ($identifiers): void {
|
||||
$query
|
||||
->whereIn('slug', $identifiers['slugs'])
|
||||
->orWhereIn('nombre', $identifiers['names']);
|
||||
})
|
||||
->update(['group_order' => $order]);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('catalog_items', function (Blueprint $table): void {
|
||||
$table->dropColumn('group_order');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,96 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
private const FILENAME = 'onticket_favicon.svg';
|
||||
|
||||
/** @var list<string> */
|
||||
private const WEBSITE_TYPE_CODES = ['shopit', 'onticket'];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
$websiteTypes = DB::table('website_type')
|
||||
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
|
||||
->get(['codigo', 'favicon_id']);
|
||||
|
||||
if ($websiteTypes->isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$faviconIds = $websiteTypes
|
||||
->pluck('favicon_id')
|
||||
->filter()
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if (
|
||||
$faviconIds->count() === 1
|
||||
&& DB::table('attachments')
|
||||
->where('id', $faviconIds->first())
|
||||
->where('filename', self::FILENAME)
|
||||
->exists()
|
||||
&& $websiteTypes->every(
|
||||
fn (object $websiteType): bool => $websiteType->favicon_id === $faviconIds->first()
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
$sourcePath = public_path('images/website_types/'.self::FILENAME);
|
||||
|
||||
if (! is_file($sourcePath)) {
|
||||
throw new RuntimeException("Favicon not found at path: {$sourcePath}");
|
||||
}
|
||||
|
||||
$contents = file_get_contents($sourcePath);
|
||||
|
||||
if ($contents === false) {
|
||||
throw new RuntimeException("Could not read favicon at path: {$sourcePath}");
|
||||
}
|
||||
|
||||
$key = (string) Str::uuid();
|
||||
$storedPath = "website-types/{$key}.svg";
|
||||
|
||||
if (! Storage::disk('s3')->put($storedPath, $contents)) {
|
||||
throw new RuntimeException("Could not store favicon at path: {$storedPath}");
|
||||
}
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($contents, $key, $storedPath): void {
|
||||
$attachmentId = DB::table('attachments')->insertGetId([
|
||||
'key' => $key,
|
||||
'path' => $storedPath,
|
||||
'filename' => self::FILENAME,
|
||||
'type' => 'image',
|
||||
'mime_type' => 'image/svg+xml',
|
||||
'extension' => 'svg',
|
||||
'size' => strlen($contents),
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
|
||||
DB::table('website_type')
|
||||
->whereIn('codigo', self::WEBSITE_TYPE_CODES)
|
||||
->update([
|
||||
'favicon_id' => $attachmentId,
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
});
|
||||
} catch (Throwable $throwable) {
|
||||
Storage::disk('s3')->delete($storedPath);
|
||||
|
||||
throw $throwable;
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
// The shared attachment may be in use outside these website types.
|
||||
// Keep this data migration irreversible to avoid deleting an active asset.
|
||||
}
|
||||
};
|
||||
@@ -191,7 +191,6 @@ class DesfilePuraTendenciaSeeder extends Seeder
|
||||
|
||||
FeaturedGroup::query()->create([
|
||||
'tenant_code' => self::TENANT_CODE,
|
||||
'code' => 'entradas',
|
||||
'source_type' => FeaturedGroupSource::All,
|
||||
'category_id' => null,
|
||||
'product_layout' => ProductLayout::TicketSelector,
|
||||
|
||||
@@ -78,7 +78,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
$this->createProduct($tenant, [
|
||||
'slug' => 'camiseta',
|
||||
'nombre' => 'Camiseta',
|
||||
'group_order' => 1,
|
||||
'category_id' => $categories['merchandising']->id,
|
||||
'precio' => 18000,
|
||||
'attribute_codes' => ['color', 'talle'],
|
||||
@@ -93,7 +92,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
$this->createProduct($tenant, [
|
||||
'slug' => 'alojamiento',
|
||||
'nombre' => 'Alojamiento',
|
||||
'group_order' => 2,
|
||||
'category_id' => $categories['alojamientos']->id,
|
||||
'precio' => 35000,
|
||||
'attribute_codes' => ['tipo_alojamiento'],
|
||||
@@ -106,7 +104,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
$this->createProduct($tenant, [
|
||||
'slug' => 'comida',
|
||||
'nombre' => 'Comida',
|
||||
'group_order' => 4,
|
||||
'category_id' => $categories['comidas']->id,
|
||||
'precio' => 4000,
|
||||
'attribute_codes' => ['event_date', 'horario', 'servicio'],
|
||||
@@ -129,7 +126,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
$this->createProduct($tenant, [
|
||||
'slug' => 'abono',
|
||||
'nombre' => 'Abono',
|
||||
'group_order' => 3,
|
||||
'category_id' => $categories['entradas']->id,
|
||||
'precio' => 40000,
|
||||
'has_tickets' => true,
|
||||
@@ -144,7 +140,6 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
||||
|
||||
FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'code' => 'productos',
|
||||
'source_type' => FeaturedGroupSource::All,
|
||||
'category_id' => null,
|
||||
'product_layout' => ProductLayout::Row,
|
||||
|
||||
@@ -171,7 +171,6 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
|
||||
FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'code' => 'productos',
|
||||
'source_type' => FeaturedGroupSource::All,
|
||||
'product_layout' => ProductLayout::ColumnWithImage,
|
||||
'group_layout' => GroupLayout::Paginated,
|
||||
@@ -181,7 +180,6 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
||||
|
||||
$carouselGroup = FeaturedGroup::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'code' => 'productos-destacados',
|
||||
'source_type' => FeaturedGroupSource::Manual,
|
||||
'product_layout' => ProductLayout::ColumnWithImage,
|
||||
'group_layout' => GroupLayout::Carousel,
|
||||
|
||||
@@ -36,7 +36,6 @@ class WebsiteTypeSeeder extends Seeder
|
||||
...self::PRESENTATION,
|
||||
'site_logo' => $this->onTicketLogo(),
|
||||
'footer_logo' => $this->onTicketFooterLogo(),
|
||||
'favicon' => $this->onTicketFavicon(),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -71,7 +70,6 @@ class WebsiteTypeSeeder extends Seeder
|
||||
...self::PRESENTATION,
|
||||
'site_logo' => $this->onTicketLogo(),
|
||||
'footer_logo' => $this->onTicketFooterLogo(),
|
||||
'favicon' => $shopIt->favicon()->firstOrFail()->key,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -177,21 +175,4 @@ class WebsiteTypeSeeder extends Seeder
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
private function onTicketFavicon(): UploadedFile
|
||||
{
|
||||
$path = public_path('images/website_types/onticket_favicon.svg');
|
||||
|
||||
if (! file_exists($path)) {
|
||||
throw new RuntimeException("OnTicket favicon not found at path: {$path}");
|
||||
}
|
||||
|
||||
return new UploadedFile(
|
||||
$path,
|
||||
'onticket_favicon.svg',
|
||||
'image/svg+xml',
|
||||
null,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
<svg width="63" height="36" viewBox="0 0 63 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M44.64 0H17.94C8.05 0 0 8.05 0 17.94C0 27.83 8.05 35.88 17.94 35.88H44.64C54.53 35.88 62.58 27.83 62.58 17.94C62.58 8.05 54.53 0 44.64 0ZM44.62 31.78C36.98 31.78 30.79 25.59 30.79 17.95C30.79 10.31 36.98 4.12 44.62 4.12C52.26 4.12 58.45 10.31 58.45 17.95C58.45 25.59 52.26 31.78 44.62 31.78Z" fill="#FF7006"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 422 B |
@@ -1,4 +1,4 @@
|
||||
@props(['branding', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
|
||||
@props(['tenant', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
|
||||
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="color-scheme" content="light">
|
||||
<title>{{ $branding['name'] }}</title>
|
||||
<title>{{ $tenant->nombre }}</title>
|
||||
<style>
|
||||
@media only screen and (max-width: 620px) {
|
||||
.mail-container { width: 100% !important; }
|
||||
@@ -14,17 +14,17 @@
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="margin: 0; padding: 0; background-color: {{ $branding['background_color'] }}; color: {{ $branding['body_color'] }}; font-family: Arial, Helvetica, sans-serif;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: {{ $branding['background_color'] }};">
|
||||
<body style="margin: 0; padding: 0; background-color: #f1f5f9; color: #334155; font-family: Arial, Helvetica, sans-serif;">
|
||||
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: #f1f5f9;">
|
||||
<tr>
|
||||
<td align="center" style="padding: 32px 12px;">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" class="mail-container" style="width: 600px; max-width: 600px; background-color: {{ $branding['surface_color'] }}; border-top: 4px solid {{ $branding['primary_color'] }}; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);">
|
||||
<table role="presentation" width="600" cellspacing="0" cellpadding="0" border="0" class="mail-container" style="width: 600px; max-width: 600px; background-color: #ffffff; border-top: 4px solid {{ $tenant->primary_color }}; border-radius: 8px; overflow: hidden; box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08);">
|
||||
<tr>
|
||||
<td align="center" bgcolor="{{ $branding['header_bg_color'] }}" style="padding: 24px 32px; background-color: {{ $branding['header_bg_color'] }};">
|
||||
<td align="center" bgcolor="{{ $tenant->header_bg_color }}" style="padding: 24px 32px; background-color: {{ $tenant->header_bg_color }};">
|
||||
@if ($headerLogoUrl)
|
||||
<img src="{{ $headerLogoUrl }}" alt="{{ $branding['name'] }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
|
||||
<img src="{{ $headerLogoUrl }}" alt="{{ $tenant->nombre }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
|
||||
@else
|
||||
<span style="color: {{ $branding['primary_color'] }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $branding['name'] }}</span>
|
||||
<span style="color: {{ $tenant->primary_color }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $tenant->nombre }}</span>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@@ -34,11 +34,11 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" bgcolor="{{ $branding['footer_bg_color'] }}" style="padding: 24px 32px; background-color: {{ $branding['footer_bg_color'] }}; color: #ffffff; font-size: 12px; line-height: 1.5;">
|
||||
<td align="center" bgcolor="{{ $tenant->footer_bg_color }}" style="padding: 24px 32px; background-color: {{ $tenant->footer_bg_color }}; color: #ffffff; font-size: 12px; line-height: 1.5;">
|
||||
@if ($footerLogoUrl)
|
||||
<img src="{{ $footerLogoUrl }}" alt="{{ $branding['name'] }}" width="140" style="display: block; width: auto; max-width: 140px; max-height: 48px; margin: 0 auto 16px; border: 0;">
|
||||
<img src="{{ $footerLogoUrl }}" alt="{{ $tenant->nombre }}" width="140" style="display: block; width: auto; max-width: 140px; max-height: 48px; margin: 0 auto 16px; border: 0;">
|
||||
@endif
|
||||
{{ $footer ?? 'Este correo fue enviado por '.$branding['name'].'.' }}
|
||||
{{ $footer ?? 'Este correo fue enviado por '.$tenant->nombre.'.' }}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<h1 style="margin: 0 0 20px; color: {{ $brand->primary_color }};">
|
||||
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }};">
|
||||
Recuperá tu contraseña
|
||||
</h1>
|
||||
|
||||
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
|
||||
<p>
|
||||
Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $brand->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla.
|
||||
Hola {{ $attempt->user->nombre_apellido }}, creamos tu cuenta de scanner en {{ $tenant->nombre }}. Utilizá este código para crear tu contraseña y comenzar a usarla.
|
||||
</p>
|
||||
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
|
||||
<p>
|
||||
@@ -17,10 +17,10 @@
|
||||
</p>
|
||||
@endif
|
||||
|
||||
<p>Ingresá este código en {{ $brand->nombre }}:</p>
|
||||
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
||||
|
||||
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $brand->primary_color }}; border-radius: 8px; text-align: center;">
|
||||
<span style="color: {{ $brand->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
|
||||
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $tenant->primary_color }}; border-radius: 8px; text-align: center;">
|
||||
<span style="color: {{ $tenant->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
|
||||
{{ $attempt->codigo }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -28,7 +28,7 @@
|
||||
@if($recoveryUrl)
|
||||
<div style="text-align: center; margin-bottom: 28px;">
|
||||
<a href="{{ $recoveryUrl }}"
|
||||
style="display: inline-block; padding: 12px 24px; background-color: {{ $brand->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
||||
{{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<h1 style="margin: 0 0 20px;">¡Compra realizada con éxito!</h1>
|
||||
<h1 style="margin: 0 0 20px;">¡Recibimos tu pago!</h1>
|
||||
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||
@foreach ($purchase->items as $item)
|
||||
@@ -10,6 +10,3 @@
|
||||
@endforeach
|
||||
</table>
|
||||
<p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p>
|
||||
@if ($tickets->isNotEmpty())
|
||||
<p><strong>Tus tickets ya están disponibles</strong></p>
|
||||
@endif
|
||||
@@ -0,0 +1,7 @@
|
||||
<h1 style="margin: 0 0 20px;">Tus tickets ya están disponibles</h1>
|
||||
<p>Generamos {{ $tickets->count() }} {{ $tickets->count() === 1 ? 'ticket' : 'tickets' }} para la compra <strong>#{{ $purchase->id }}</strong>.</p>
|
||||
<ul style="padding-left: 20px;">
|
||||
@foreach ($tickets as $ticket)
|
||||
<li style="margin-bottom: 8px;">{{ $ticket->name }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
@@ -1,3 +1,3 @@
|
||||
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $brand->nombre }}!</h1>
|
||||
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $tenant->nombre }}!</h1>
|
||||
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
|
||||
<p>Ya podés ingresar y comenzar a comprar.</p>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }}; font-size: 26px; line-height: 1.3;">
|
||||
Prueba de correo de Shopit
|
||||
</h1>
|
||||
|
||||
@@ -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}”.",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,19 +72,16 @@ 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)
|
||||
->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.maximum_addable_quantity', 4)
|
||||
->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.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');
|
||||
|
||||
$this->assertNotContains(
|
||||
$unavailableVariant->id,
|
||||
collect($response->json('0.items.0.variants'))->pluck('id')->all(),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void
|
||||
@@ -97,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,
|
||||
@@ -118,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_excludes_out_of_stock_items(): 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(
|
||||
@@ -142,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,
|
||||
@@ -150,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,
|
||||
@@ -162,8 +159,7 @@ class CatalogControllerTest extends TestCase
|
||||
->assertOk()
|
||||
->assertJsonCount(1, '0.items')
|
||||
->assertJsonPath('0.items.0.nombre', 'Available')
|
||||
->assertJsonPath('0.items.0.unavailable_message', null)
|
||||
->assertJsonMissing(['nombre' => 'Unavailable']);
|
||||
->assertJsonCount(0, '0.items.0.availability.reasons');
|
||||
}
|
||||
|
||||
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void
|
||||
@@ -180,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]);
|
||||
@@ -348,10 +344,8 @@ class CatalogControllerTest extends TestCase
|
||||
]);
|
||||
|
||||
$food = $this->createItem($tenant, 'Hamburger');
|
||||
$food->update(['group_order' => 2]);
|
||||
$food->category()->associate($category)->save();
|
||||
$parking = $this->createItem($tenant, 'Parking');
|
||||
$parking->update(['group_order' => 1]);
|
||||
$this->createItem($tenant, 'Parking');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
||||
->assertOk()
|
||||
@@ -360,9 +354,6 @@ class CatalogControllerTest extends TestCase
|
||||
->assertJsonPath('0.items.0.nombre', 'Hamburger')
|
||||
->assertJsonPath('1.title', 'All products')
|
||||
->assertJsonCount(2, '1.items.data')
|
||||
->assertJsonPath('1.items.data.0.nombre', 'Parking')
|
||||
->assertJsonPath('1.items.data.1.nombre', 'Hamburger')
|
||||
->assertJsonMissingPath('1.items.data.0.group_order')
|
||||
->assertJsonPath('1.items.meta.total', 2);
|
||||
}
|
||||
|
||||
@@ -433,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,
|
||||
|
||||
@@ -31,7 +31,6 @@ class CatalogItemControllerTest extends TestCase
|
||||
$response = $this->postJson("/api/tenants/{$tenant->codigo}/catalog-items", [
|
||||
'slug' => 'shirt',
|
||||
'nombre' => 'Shirt',
|
||||
'group_order' => 7,
|
||||
'precio' => 100,
|
||||
'max_units_per_user' => 4,
|
||||
'attribute_codes' => [$attribute->codigo],
|
||||
@@ -48,7 +47,6 @@ class CatalogItemControllerTest extends TestCase
|
||||
$response
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.nombre', 'Shirt')
|
||||
->assertJsonMissingPath('data.group_order')
|
||||
->assertJsonPath('data.max_units_per_user', 4)
|
||||
->assertJsonCount(2, 'data.images')
|
||||
->assertJsonCount(1, 'data.variants')
|
||||
@@ -59,7 +57,6 @@ class CatalogItemControllerTest extends TestCase
|
||||
|
||||
$this->assertSame([0, 1], $item->attachments()->get()->pluck('pivot.orden')->all());
|
||||
$this->assertSame(4, $item->max_units_per_user);
|
||||
$this->assertSame(7, $item->group_order);
|
||||
$this->assertSame([0], $variant->attachments()->get()->pluck('pivot.orden')->all());
|
||||
$this->assertDatabaseHas('catalog_items_attachments', [
|
||||
'catalog_item_id' => $item->id,
|
||||
|
||||
@@ -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_omits_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');
|
||||
@@ -71,17 +82,14 @@ class CatalogItemDetailControllerTest extends TestCase
|
||||
->assertOk()
|
||||
->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
|
||||
->assertJsonMissingPath('data.stock_tecnico')
|
||||
->assertJsonMissingPath('data.images');
|
||||
$this->assertNotContains(
|
||||
$firstVariant->id,
|
||||
collect($response->json('data.variants'))->pluck('id')->all(),
|
||||
);
|
||||
$this->assertStringContainsString($secondImage->path, $response->json('data.selected_variant.images.0'));
|
||||
$this->assertStringNotContainsString($firstImage->path, $response->json('data.selected_variant.images.0'));
|
||||
$this->assertStringNotContainsString($itemImage->path, $response->json('data.selected_variant.images.0'));
|
||||
@@ -91,19 +99,6 @@ class CatalogItemDetailControllerTest extends TestCase
|
||||
)->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_it_does_not_return_an_out_of_stock_item(): void
|
||||
{
|
||||
$tenant = $this->createTenant('detail-out-of-stock');
|
||||
$inventory = Inventory::query()->create([
|
||||
'real_stock' => 5,
|
||||
'reserved_stock' => 5,
|
||||
]);
|
||||
$item = $this->createItem($tenant, 'Sold out item', $inventory);
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/catalog-items/{$item->id}")
|
||||
->assertNotFound();
|
||||
}
|
||||
|
||||
public function test_it_selects_the_requested_variant_and_lists_variant_values_and_stock(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
@@ -144,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')
|
||||
@@ -156,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');
|
||||
@@ -196,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');
|
||||
}
|
||||
@@ -365,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')
|
||||
@@ -432,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(
|
||||
|
||||
@@ -96,7 +96,6 @@ class CatalogSchemaTest extends TestCase
|
||||
$this->assertEqualsCanonicalizing([
|
||||
'id',
|
||||
'tenant_code',
|
||||
'code',
|
||||
'source_type',
|
||||
'category_id',
|
||||
'product_layout',
|
||||
|
||||
@@ -62,24 +62,8 @@ class CatalogSearchTest extends TestCase
|
||||
$this->createCatalogItem($tenant, "Running {$number}");
|
||||
}
|
||||
$exactMatch = $this->createCatalogItem($tenant, 'Running');
|
||||
$outOfStock = CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'slug' => 'running-sold-out',
|
||||
'nombre' => 'Running sold out',
|
||||
'descripcion' => 'Running sold out description',
|
||||
'precio' => 100,
|
||||
]);
|
||||
$outOfStock->variants()->create([
|
||||
'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id,
|
||||
]);
|
||||
CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'inventory_id' => Inventory::query()->create(['real_stock' => 2, 'reserved_stock' => 2])->id,
|
||||
'slug' => 'running-direct-sold-out',
|
||||
'nombre' => 'Running direct sold out',
|
||||
'descripcion' => 'Running direct sold out description',
|
||||
'precio' => 100,
|
||||
]);
|
||||
$outOfStockMatch = $this->createCatalogItem($tenant, 'Running unavailable');
|
||||
$outOfStockMatch->inventory->update(['real_stock' => 0]);
|
||||
$this->createCatalogItem($tenant, 'Unrelated');
|
||||
$this->createCatalogItem($otherTenant, 'Running foreign');
|
||||
|
||||
@@ -94,8 +78,7 @@ class CatalogSearchTest extends TestCase
|
||||
->assertJsonPath('meta.total', 6)
|
||||
->assertJsonCount(4, 'data')
|
||||
->assertJsonPath('data.0.id', $exactMatch->id)
|
||||
->assertJsonMissing(['nombre' => 'Running sold out'])
|
||||
->assertJsonMissing(['nombre' => 'Running direct sold out'])
|
||||
->assertJsonMissing(['nombre' => 'Running unavailable'])
|
||||
->assertJsonMissing(['nombre' => 'Running foreign'])
|
||||
->assertJsonMissing(['nombre' => 'Unrelated']);
|
||||
}
|
||||
|
||||
@@ -29,17 +29,6 @@ class CategoryDetailTest extends TestCase
|
||||
$this->createCatalogItem($tenant, $category, 'Remera C');
|
||||
$firstItem = $this->createCatalogItem($tenant, $category, 'Remera A');
|
||||
$secondItem = $this->createCatalogItem($tenant, $category, 'Remera B');
|
||||
$outOfStock = CatalogItem::query()->create([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'category_id' => $category->id,
|
||||
'slug' => 'remera-agotada',
|
||||
'nombre' => 'Remera agotada',
|
||||
'descripcion' => 'Sin stock',
|
||||
'precio' => 100,
|
||||
]);
|
||||
$outOfStock->variants()->create([
|
||||
'inventory_id' => Inventory::query()->create(['real_stock' => 0])->id,
|
||||
]);
|
||||
$this->createCatalogItem($tenant, $otherCategory, 'Pantalón');
|
||||
|
||||
$this->getJson("/api/tenants/{$tenant->codigo}/categories/{$category->id}")
|
||||
@@ -55,7 +44,6 @@ class CategoryDetailTest extends TestCase
|
||||
->assertJsonCount(2, 'data')
|
||||
->assertJsonPath('data.0.id', $firstItem->id)
|
||||
->assertJsonPath('data.1.id', $secondItem->id)
|
||||
->assertJsonMissing(['nombre' => 'Remera agotada'])
|
||||
->assertJsonMissing(['nombre' => 'Pantalón']);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Migrations;
|
||||
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SetWebsiteTypeFaviconTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_it_uploads_one_favicon_and_shares_the_attachment_between_website_types(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
|
||||
DB::table('website_type')->insert([
|
||||
[
|
||||
'codigo' => 'shopit',
|
||||
'nombre' => 'ShopIt',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
[
|
||||
'codigo' => 'onticket',
|
||||
'nombre' => 'OnTicket',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now(),
|
||||
],
|
||||
]);
|
||||
|
||||
$migration = require database_path(
|
||||
'migrations/2026_08_26_000000_set_website_type_favicon.php'
|
||||
);
|
||||
|
||||
$migration->up();
|
||||
$migration->up();
|
||||
|
||||
$faviconIds = DB::table('website_type')
|
||||
->whereIn('codigo', ['shopit', 'onticket'])
|
||||
->pluck('favicon_id');
|
||||
|
||||
$this->assertCount(2, $faviconIds);
|
||||
$this->assertNotNull($faviconIds->first());
|
||||
$this->assertSame(1, $faviconIds->unique()->count());
|
||||
|
||||
$attachment = DB::table('attachments')->where('id', $faviconIds->first())->first();
|
||||
|
||||
$this->assertNotNull($attachment);
|
||||
$this->assertSame('onticket_favicon.svg', $attachment->filename);
|
||||
$this->assertSame('image/svg+xml', $attachment->mime_type);
|
||||
$this->assertSame('svg', $attachment->extension);
|
||||
$this->assertSame(1, DB::table('attachments')->where('filename', 'onticket_favicon.svg')->count());
|
||||
Storage::disk('s3')->assertExists($attachment->path);
|
||||
$this->assertSame(
|
||||
file_get_contents(public_path('images/website_types/onticket_favicon.svg')),
|
||||
Storage::disk('s3')->get($attachment->path),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -71,24 +71,19 @@ class NotificationMailServiceTest extends TestCase
|
||||
|
||||
public function test_it_sends_a_branded_welcome_email(): void
|
||||
{
|
||||
$this->useWebsiteTypeBranding();
|
||||
|
||||
app(NotificationMailService::class)->sendWelcome($this->user->id, $this->tenant->codigo);
|
||||
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||
$mail->assertTo('ada@example.com');
|
||||
$mail->assertHasSubject('Bienvenido a OnTicket');
|
||||
$mail->assertHasSubject('Bienvenido a Mail Tenant');
|
||||
|
||||
return str_contains($mail->render(), 'Ada Lovelace')
|
||||
&& str_contains($mail->render(), 'OnTicket')
|
||||
&& ! str_contains($mail->render(), 'Mail Tenant')
|
||||
&& str_contains($mail->render(), 'border-top: 4px solid #ff7006');
|
||||
&& str_contains($mail->render(), 'Mail Tenant');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_sends_a_branded_password_reset_email(): void
|
||||
{
|
||||
$this->useWebsiteTypeBranding();
|
||||
$attempt = $this->user->resetPasswordAttempts()->create([
|
||||
'codigo' => '0123',
|
||||
]);
|
||||
@@ -100,15 +95,13 @@ class NotificationMailServiceTest extends TestCase
|
||||
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||
$mail->assertTo('ada@example.com');
|
||||
$mail->assertHasSubject('Código para recuperar tu contraseña - OnTicket');
|
||||
$mail->assertHasSubject('Código para recuperar tu contraseña - Mail Tenant');
|
||||
$rendered = $mail->render();
|
||||
|
||||
return str_contains($rendered, '0123')
|
||||
&& str_contains($rendered, 'Ada Lovelace')
|
||||
&& str_contains($rendered, 'OnTicket')
|
||||
&& ! str_contains($rendered, 'Mail Tenant')
|
||||
&& str_contains($rendered, '#ff7006')
|
||||
&& ! str_contains($rendered, 'border: 2px solid #112233');
|
||||
&& str_contains($rendered, 'Mail Tenant')
|
||||
&& str_contains($rendered, '#112233');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -142,9 +135,8 @@ class NotificationMailServiceTest extends TestCase
|
||||
});
|
||||
}
|
||||
|
||||
public function test_it_sends_one_purchase_confirmation_with_generated_tickets_attached(): void
|
||||
public function test_it_sends_purchase_and_ticket_emails_to_the_purchase_recipient(): void
|
||||
{
|
||||
$this->useWebsiteTypeBranding();
|
||||
$purchase = Purchase::query()->create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'user_id' => $this->user->id,
|
||||
@@ -175,65 +167,26 @@ class NotificationMailServiceTest extends TestCase
|
||||
$ticket = Ticket::query()->create([
|
||||
'tenant_code' => $this->tenant->codigo,
|
||||
'ticket' => fake()->uuid(),
|
||||
'source_purchase_id' => $purchase->id,
|
||||
'source_catalog_item_id' => $catalogItem->id,
|
||||
'user_id' => $this->user->id,
|
||||
]);
|
||||
$service = app(NotificationMailService::class);
|
||||
|
||||
$service->sendPurchaseConfirmed($purchase->id);
|
||||
|
||||
Mail::assertSent(Mailable::class, 1);
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase, $ticket): bool {
|
||||
$mail->assertTo('checkout@example.com');
|
||||
$attachment = collect($mail->rawAttachments)->firstWhere('name', "tickets_{$ticket->id}.pdf");
|
||||
|
||||
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
|
||||
&& str_contains($mail->render(), '¡Compra realizada con éxito!')
|
||||
&& str_contains($mail->render(), 'Total pagado')
|
||||
&& str_contains($mail->render(), 'Tus tickets ya están disponibles')
|
||||
&& $attachment !== null
|
||||
&& $attachment['options'] === ['mime' => 'application/pdf']
|
||||
&& str_starts_with($attachment['data'], '%PDF-')
|
||||
&& str_contains($mail->render(), 'border-top: 4px solid #112233')
|
||||
&& ! str_contains($mail->render(), 'border-top: 4px solid #ff7006');
|
||||
});
|
||||
}
|
||||
|
||||
public function test_purchase_confirmation_omits_ticket_content_and_attachment_without_tickets(): void
|
||||
{
|
||||
$purchase = Purchase::query()->create([
|
||||
'tenant_codigo' => $this->tenant->codigo,
|
||||
'user_id' => $this->user->id,
|
||||
'status' => Purchase::STATUS_PAID,
|
||||
'payment_method' => 'transfer',
|
||||
'total' => 25,
|
||||
]);
|
||||
|
||||
app(NotificationMailService::class)->sendPurchaseConfirmed($purchase->id);
|
||||
$service->sendPurchasePaid($purchase->id);
|
||||
$service->sendTicketsAvailable($purchase->id, [$ticket->id]);
|
||||
|
||||
Mail::assertSent(Mailable::class, 2);
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail) use ($purchase): bool {
|
||||
return $mail->subject === "Compra confirmada - Compra #{$purchase->id}"
|
||||
&& str_contains($mail->render(), '¡Compra realizada con éxito!')
|
||||
&& ! str_contains($mail->render(), 'Tus tickets ya están disponibles')
|
||||
&& $mail->rawAttachments === [];
|
||||
$mail->assertTo('checkout@example.com');
|
||||
|
||||
return $mail->subject === "Pago confirmado - Compra #{$purchase->id}"
|
||||
&& str_contains($mail->render(), 'Total pagado');
|
||||
});
|
||||
}
|
||||
Mail::assertSent(Mailable::class, function (Mailable $mail): bool {
|
||||
$mail->assertTo('checkout@example.com');
|
||||
|
||||
private function useWebsiteTypeBranding(): void
|
||||
{
|
||||
$websiteType = WebsiteType::query()->create([
|
||||
'codigo' => 'onticket',
|
||||
'nombre' => 'OnTicket',
|
||||
'dominio' => 'onticket.local',
|
||||
'primary_color' => '#ff7006',
|
||||
'body_color' => '#666666',
|
||||
'background_color' => '#f8f8f8',
|
||||
'surface_color' => '#ffffff',
|
||||
'login_header_footer_color' => '#838383',
|
||||
]);
|
||||
|
||||
$this->tenant->update(['website_type_code' => $websiteType->codigo]);
|
||||
$this->tenant->unsetRelation('websiteType');
|
||||
return $mail->subject === 'Tus tickets ya están disponibles'
|
||||
&& str_contains($mail->render(), 'Entrada general');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Notification;
|
||||
|
||||
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use App\Domains\Purchase\Events\PurchasePaid;
|
||||
use Mockery;
|
||||
use Tests\TestCase;
|
||||
|
||||
class QueuedNotificationListenerTest extends TestCase
|
||||
{
|
||||
public function test_purchase_confirmed_email_delegates_with_the_purchase_id(): void
|
||||
{
|
||||
$mailService = Mockery::mock(NotificationMailService::class);
|
||||
$mailService->shouldReceive('sendPurchaseConfirmed')
|
||||
->once()
|
||||
->with(123);
|
||||
$this->app->instance(NotificationMailService::class, $mailService);
|
||||
|
||||
(new SendPurchaseConfirmedEmail)->handle(new PurchasePaid(123));
|
||||
}
|
||||
|
||||
public function test_purchase_paid_event_only_serializes_the_purchase_id(): void
|
||||
{
|
||||
$purchasePaid = unserialize(serialize(new PurchasePaid(123)));
|
||||
|
||||
$this->assertSame(123, $purchasePaid->purchaseId);
|
||||
}
|
||||
}
|
||||
@@ -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', [
|
||||
|
||||
@@ -161,7 +161,6 @@ class DesfilePuraTendenciaSeederTest extends TestCase
|
||||
]);
|
||||
$this->assertDatabaseHas('featured_groups', [
|
||||
'tenant_code' => 'desfile_pura_tendencia',
|
||||
'code' => 'entradas',
|
||||
'source_type' => 'all',
|
||||
'product_layout' => 'ticket_selector',
|
||||
'group_layout' => 'single',
|
||||
|
||||
@@ -70,14 +70,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
||||
['abono', 'alojamiento', 'camiseta', 'comida'],
|
||||
CatalogItem::query()->where('tenant_code', $tenant->codigo)->orderBy('slug')->pluck('slug')->all(),
|
||||
);
|
||||
$this->assertSame(
|
||||
['camiseta', 'alojamiento', 'abono', 'comida'],
|
||||
CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->orderBy('group_order')
|
||||
->pluck('slug')
|
||||
->all(),
|
||||
);
|
||||
$this->assertTrue(
|
||||
CatalogItem::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
@@ -151,7 +143,6 @@ class FiestaFutbolInfantilProductSeederTest extends TestCase
|
||||
));
|
||||
|
||||
$featuredGroup = FeaturedGroup::query()->where('tenant_code', $tenant->codigo)->sole();
|
||||
$this->assertSame('productos', $featuredGroup->code);
|
||||
$this->assertSame(FeaturedGroupSource::All, $featuredGroup->source_type);
|
||||
$this->assertSame(ProductLayout::Row, $featuredGroup->product_layout);
|
||||
$this->assertSame(GroupLayout::SimpleVertical, $featuredGroup->group_layout);
|
||||
|
||||
@@ -69,7 +69,6 @@ class ProductCatalogFromImagesSeederTest extends TestCase
|
||||
->pluck('id');
|
||||
|
||||
$this->assertNotNull($paginatedGroup);
|
||||
$this->assertSame('productos', $paginatedGroup->code);
|
||||
$this->assertSame(ProductLayout::ColumnWithImage, $paginatedGroup->product_layout);
|
||||
$this->assertSame(GroupLayout::Paginated, $paginatedGroup->group_layout);
|
||||
$this->assertSame(FeaturedGroupSource::All, $paginatedGroup->source_type);
|
||||
@@ -77,7 +76,6 @@ class ProductCatalogFromImagesSeederTest extends TestCase
|
||||
$this->assertCount(0, $paginatedGroup->featuredItems);
|
||||
|
||||
$this->assertNotNull($carouselGroup);
|
||||
$this->assertSame('productos-destacados', $carouselGroup->code);
|
||||
$this->assertSame(ProductLayout::ColumnWithImage, $carouselGroup->product_layout);
|
||||
$this->assertSame(GroupLayout::Carousel, $carouselGroup->group_layout);
|
||||
$this->assertSame(FeaturedGroupSource::Manual, $carouselGroup->source_type);
|
||||
|
||||
@@ -21,7 +21,7 @@ class WebsiteTypeSeederTest extends TestCase
|
||||
$this->seed(WebsiteTypeSeeder::class);
|
||||
|
||||
$this->assertSame(2, WebsiteType::query()->count());
|
||||
$this->assertSame(5, Attachment::query()->count());
|
||||
$this->assertSame(4, Attachment::query()->count());
|
||||
|
||||
$expectedPresentation = [
|
||||
'primary_color' => '#FF7006',
|
||||
@@ -49,8 +49,6 @@ class WebsiteTypeSeederTest extends TestCase
|
||||
Storage::disk('s3')->assertExists($shopIt->siteLogo->path);
|
||||
$this->assertSame('onticket_footer_logo.png', $shopIt->footerLogo->filename);
|
||||
Storage::disk('s3')->assertExists($shopIt->footerLogo->path);
|
||||
$this->assertSame('onticket_favicon.svg', $shopIt->favicon->filename);
|
||||
Storage::disk('s3')->assertExists($shopIt->favicon->path);
|
||||
$this->assertSame(['carousel'], $shopIt->extras->pluck('codigo')->all());
|
||||
$this->assertSame('Carrusel principal', $shopIt->extras->sole()->nombre);
|
||||
$this->assertSame([
|
||||
@@ -81,8 +79,6 @@ class WebsiteTypeSeederTest extends TestCase
|
||||
Storage::disk('s3')->assertExists($onTicket->footerLogo->path);
|
||||
$this->assertNotSame($shopIt->site_logo, $onTicket->site_logo);
|
||||
$this->assertNotSame($shopIt->footer_logo, $onTicket->footer_logo);
|
||||
$this->assertSame($shopIt->favicon_id, $onTicket->favicon_id);
|
||||
$this->assertSame('onticket_favicon.svg', $onTicket->favicon->filename);
|
||||
$this->assertEqualsCanonicalizing(
|
||||
['heroConfig', 'eventConfig', 'additionalInfoConfig'],
|
||||
$onTicket->extras->pluck('codigo')->all(),
|
||||
|
||||
@@ -14,7 +14,6 @@ class BootstrapAdminAppControllerTest extends TestCase
|
||||
public function test_it_publicly_bootstraps_the_admin_app_by_domain(): void
|
||||
{
|
||||
$footerLogo = Attachment::factory()->create();
|
||||
$favicon = Attachment::factory()->create();
|
||||
|
||||
WebsiteType::query()->create([
|
||||
'codigo' => 'shopit',
|
||||
@@ -32,7 +31,6 @@ class BootstrapAdminAppControllerTest extends TestCase
|
||||
'border_color' => '#eaeaea',
|
||||
'login_header_footer_color' => '#313131',
|
||||
'footer_logo' => $footerLogo->id,
|
||||
'favicon_id' => $favicon->id,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/adminapp/bootstrap/ADMIN.SHOPIT.TEST')
|
||||
@@ -43,7 +41,6 @@ class BootstrapAdminAppControllerTest extends TestCase
|
||||
->assertJsonPath('data.login_header_footer_color', '#313131')
|
||||
->assertJsonPath('data.site_logo', null)
|
||||
->assertJsonPath('data.footer_logo', $footerLogo->getTemporaryUrl(1440))
|
||||
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
|
||||
->assertJsonMissingPath('data.forms')
|
||||
->assertJsonMissingPath('data.codigo')
|
||||
->assertJsonMissingPath('data.nombre')
|
||||
|
||||
@@ -14,7 +14,6 @@ class BootstrapScannerControllerTest extends TestCase
|
||||
public function test_it_publicly_bootstraps_the_scanner_by_scanner_domain(): void
|
||||
{
|
||||
$siteLogo = Attachment::factory()->create();
|
||||
$favicon = Attachment::factory()->create();
|
||||
|
||||
WebsiteType::query()->create([
|
||||
'codigo' => 'shopit',
|
||||
@@ -33,7 +32,6 @@ class BootstrapScannerControllerTest extends TestCase
|
||||
'border_color' => '#eaeaea',
|
||||
'login_header_footer_color' => '#313131',
|
||||
'site_logo' => $siteLogo->id,
|
||||
'favicon_id' => $favicon->id,
|
||||
]);
|
||||
|
||||
$this->getJson('/api/v1/scanner/bootstrap/SCANNER.SHOPIT.TEST')
|
||||
@@ -42,7 +40,6 @@ class BootstrapScannerControllerTest extends TestCase
|
||||
->assertJsonPath('data.primary_color', '#112233')
|
||||
->assertJsonPath('data.site_logo', $siteLogo->getTemporaryUrl(1440))
|
||||
->assertJsonPath('data.footer_logo', null)
|
||||
->assertJsonPath('data.favicon', $favicon->getTemporaryUrl(1440))
|
||||
->assertJsonMissingPath('data.codigo')
|
||||
->assertJsonMissingPath('data.nombre')
|
||||
->assertJsonMissingPath('data.dominio')
|
||||
|
||||
@@ -69,31 +69,4 @@ class WebsiteTypeServiceTest extends TestCase
|
||||
'favicon_id' => $favicon->id,
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_it_keeps_a_shared_favicon_when_one_website_type_replaces_it(): void
|
||||
{
|
||||
Storage::fake('s3');
|
||||
|
||||
$service = app(WebsiteTypeService::class);
|
||||
$shopIt = $service->create([
|
||||
'codigo' => 'shopit',
|
||||
'nombre' => 'ShopIt',
|
||||
'favicon' => UploadedFile::fake()->image('shared-favicon.png', 32, 32),
|
||||
]);
|
||||
$sharedFavicon = $shopIt->favicon()->firstOrFail();
|
||||
$onTicket = $service->create([
|
||||
'codigo' => 'onticket',
|
||||
'nombre' => 'OnTicket',
|
||||
'favicon' => $sharedFavicon->key,
|
||||
]);
|
||||
|
||||
$service->updateOrCreate(
|
||||
['codigo' => 'shopit'],
|
||||
['favicon' => UploadedFile::fake()->image('shopit-favicon.png', 32, 32)],
|
||||
);
|
||||
|
||||
$this->assertSame($sharedFavicon->id, $onTicket->fresh()->favicon_id);
|
||||
$this->assertDatabaseHas('attachments', ['id' => $sharedFavicon->id]);
|
||||
Storage::disk('s3')->assertExists($sharedFavicon->path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Services\CatalogService;
|
||||
use App\Domains\Event\Models\EventDate;
|
||||
use App\Domains\Notification\Events\TicketsAvailable;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Shared\Enums\FieldType;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
@@ -21,6 +22,7 @@ use App\Domains\Ticket\Services\TicketGeneratorService;
|
||||
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Event;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
use Illuminate\Support\Str;
|
||||
use Tests\TestCase;
|
||||
@@ -218,6 +220,7 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
|
||||
public function test_marking_a_purchase_as_paid_generates_its_tickets_once(): void
|
||||
{
|
||||
Event::fake([TicketsAvailable::class]);
|
||||
$item = $this->createTicketableItem('paid-ticket');
|
||||
$purchase = $this->createPurchase($item, 2);
|
||||
$purchase->setRelation('items', new EloquentCollection);
|
||||
@@ -226,6 +229,7 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
|
||||
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
||||
$this->assertDatabaseCount('tickets', 2);
|
||||
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
|
||||
|
||||
$this->actingAs($this->user, 'sanctum')
|
||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||
@@ -236,6 +240,7 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
$purchase->markAsPaid();
|
||||
|
||||
$this->assertDatabaseCount('tickets', 2);
|
||||
Event::assertDispatchedTimes(TicketsAvailable::class, 1);
|
||||
}
|
||||
|
||||
public function test_a_ticket_generated_from_a_purchase_keeps_its_source_ids(): void
|
||||
@@ -448,6 +453,7 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
|
||||
public function test_marking_a_purchase_as_paid_ignores_items_without_tickets(): void
|
||||
{
|
||||
Event::fake([TicketsAvailable::class]);
|
||||
$item = $this->createTicketableItem('regular-product');
|
||||
$item->update(['has_tickets' => false]);
|
||||
$purchase = $this->createPurchase($item->fresh(), 1);
|
||||
@@ -456,6 +462,7 @@ class TicketGeneratorServiceTest extends TestCase
|
||||
|
||||
$this->assertSame(Purchase::STATUS_PAID, $purchase->status);
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
Event::assertNotDispatched(TicketsAvailable::class);
|
||||
|
||||
$this->actingAs($this->user, 'sanctum')
|
||||
->getJson("/api/tenants/{$this->tenant->codigo}/compras/{$purchase->id}")
|
||||
|
||||
@@ -17,13 +17,11 @@ class AdminAppBootstrapResourceTest extends TestCase
|
||||
]);
|
||||
$websiteType->setRelation('siteLogo', null);
|
||||
$websiteType->setRelation('footerLogo', null);
|
||||
$websiteType->setRelation('favicon', null);
|
||||
$data = AdminAppBootstrapResource::make([
|
||||
'website_type' => $websiteType,
|
||||
])->resolve(request());
|
||||
|
||||
$this->assertSame('shopit', $data['website_type_code']);
|
||||
$this->assertNull($data['favicon']);
|
||||
$this->assertArrayNotHasKey('forms', $data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,13 +83,11 @@ class CatalogModelsTest extends TestCase
|
||||
public function test_catalog_item_is_the_catalog_root(): void
|
||||
{
|
||||
$item = new CatalogItem;
|
||||
$this->assertSame(0, $item->group_order);
|
||||
$item->setRawAttributes([
|
||||
'category_id' => '10',
|
||||
'brand_id' => '20',
|
||||
'inventory_id' => '30',
|
||||
'type' => CatalogItemType::Standard->value,
|
||||
'group_order' => '4',
|
||||
'precio' => '12.50',
|
||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||
'inventory_subject' => InventorySubject::Seat->value,
|
||||
@@ -102,7 +100,6 @@ class CatalogModelsTest extends TestCase
|
||||
$this->assertSame(20, $item->brand_id);
|
||||
$this->assertSame(30, $item->inventory_id);
|
||||
$this->assertSame(CatalogItemType::Standard, $item->type);
|
||||
$this->assertSame(4, $item->group_order);
|
||||
$this->assertSame('12.50', $item->precio);
|
||||
$this->assertSame(InventoryPolicy::Tracked, $item->inventory_policy);
|
||||
$this->assertSame(InventorySubject::Seat, $item->inventory_subject);
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Notification;
|
||||
|
||||
use App\Domains\Integration\Services\MailService;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Ticket\Services\TicketPdfService;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Mockery;
|
||||
use ReflectionMethod;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class NotificationMailServiceLoggingTest extends TestCase
|
||||
{
|
||||
private NotificationMailService $service;
|
||||
|
||||
private MailService $mailService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
$this->mailService = Mockery::mock(MailService::class);
|
||||
$this->service = new NotificationMailService(
|
||||
$this->mailService,
|
||||
Mockery::mock(TicketPdfService::class),
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_logs_and_swallows_a_missing_purchase(): void
|
||||
{
|
||||
$this->createEmptyPurchasesTable();
|
||||
|
||||
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||
Log::shouldReceive('warning')->once()->with(
|
||||
'Notification email skipped.',
|
||||
[
|
||||
'purchase_id' => 123,
|
||||
'reason' => 'purchase_not_found',
|
||||
'missing_model' => Purchase::class,
|
||||
'email_type' => 'purchase_confirmed',
|
||||
],
|
||||
);
|
||||
|
||||
$this->service->sendPurchaseConfirmed(123);
|
||||
}
|
||||
|
||||
public function test_it_logs_successful_delivery_with_the_mailer(): void
|
||||
{
|
||||
$this->mailService->shouldReceive('mailerName')->once()->andReturn('smtp');
|
||||
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||
Log::shouldReceive('info')->once()->with(
|
||||
'Notification email sent.',
|
||||
[
|
||||
'user_id' => 10,
|
||||
'tenant_code' => 'tenant-test',
|
||||
'email_type' => 'welcome',
|
||||
'mailer' => 'smtp',
|
||||
],
|
||||
);
|
||||
|
||||
$this->sendLogged(
|
||||
'welcome',
|
||||
['user_id' => 10],
|
||||
fn (): array => ['tenant_code' => 'tenant-test'],
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_logs_and_rethrows_delivery_failures(): void
|
||||
{
|
||||
$exception = new RuntimeException('SMTP unavailable.');
|
||||
Log::shouldReceive('channel')->once()->with('emails')->andReturnSelf();
|
||||
Log::shouldReceive('error')->once()->with(
|
||||
'Notification email delivery failed.',
|
||||
Mockery::on(fn (array $context): bool => $context['purchase_id'] === 123
|
||||
&& $context['email_type'] === 'purchase_confirmed'
|
||||
&& $context['exception'] === $exception),
|
||||
);
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
|
||||
$this->sendLogged('purchase_confirmed', ['purchase_id' => 123], function () use ($exception): array {
|
||||
throw $exception;
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $context */
|
||||
private function sendLogged(string $emailType, array $context, callable $send): void
|
||||
{
|
||||
(new ReflectionMethod($this->service, 'sendLogged'))->invoke(
|
||||
$this->service,
|
||||
$emailType,
|
||||
$context,
|
||||
$send,
|
||||
);
|
||||
}
|
||||
|
||||
private function createEmptyPurchasesTable(): void
|
||||
{
|
||||
config([
|
||||
'database.default' => 'sqlite',
|
||||
'database.connections.sqlite.database' => ':memory:',
|
||||
]);
|
||||
Schema::connection('sqlite')->create('compras', function (Blueprint $table): void {
|
||||
$table->id();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,13 @@ namespace Tests\Unit\Notification;
|
||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||
use App\Domains\Notification\Services\NotificationMailService;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use RuntimeException;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SendPasswordResetEmailTest extends TestCase
|
||||
{
|
||||
public function test_it_delegates_mail_failures_to_the_notification_service(): void
|
||||
public function test_it_logs_and_rethrows_mail_failures(): void
|
||||
{
|
||||
$exception = new RuntimeException('SMTP unavailable.');
|
||||
$mailService = \Mockery::mock(NotificationMailService::class);
|
||||
@@ -20,6 +21,16 @@ class SendPasswordResetEmailTest extends TestCase
|
||||
->andThrow($exception);
|
||||
$this->app->instance(NotificationMailService::class, $mailService);
|
||||
|
||||
Log::shouldReceive('error')
|
||||
->once()
|
||||
->with(
|
||||
'Failed to send password reset email.',
|
||||
\Mockery::on(fn (array $context): bool => $context['attempt_id'] === 10
|
||||
&& $context['tenant_code'] === 'tenant-test'
|
||||
&& $context['channel'] === PasswordResetRequested::CHANNEL_STOREFRONT
|
||||
&& $context['exception'] === $exception),
|
||||
);
|
||||
|
||||
$this->expectExceptionObject($exception);
|
||||
|
||||
(new SendPasswordResetEmail)->handle(
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit\Sale;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Logging\Models\ValueChange;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Sale\Services\AdminAppSaleExcelService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Carbon;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminAppSaleExcelServiceTest extends TestCase
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
|
||||
Carbon::setTestNow(Carbon::parse('2026-08-24 17:53:00', 'UTC'));
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
Carbon::setTestNow();
|
||||
|
||||
parent::tearDown();
|
||||
}
|
||||
|
||||
public function test_it_downloads_filtered_sales_as_an_excel_file(): void
|
||||
{
|
||||
$response = app(AdminAppSaleExcelService::class)->downloadSales(
|
||||
$this->tenant(),
|
||||
collect([$this->sale()]),
|
||||
'America/La_Paz',
|
||||
);
|
||||
|
||||
$this->assertExcelResponse(
|
||||
$response,
|
||||
'ventas_acme_20260824_135300.xlsx',
|
||||
function (string $path): void {
|
||||
$sheet = IOFactory::load($path)->getActiveSheet();
|
||||
|
||||
$this->assertSame('Ventas', $sheet->getTitle());
|
||||
$this->assertSame('Cliente Test', $sheet->getCell('C2')->getValue());
|
||||
$this->assertSame('Confirmado', $sheet->getCell('E2')->getValue());
|
||||
$this->assertSame(25000.0, $sheet->getCell('F2')->getValue());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function test_it_downloads_the_modification_history_as_an_excel_file(): void
|
||||
{
|
||||
$sale = $this->sale();
|
||||
$admin = (new User)->forceFill([
|
||||
'id' => 10,
|
||||
'nombre_apellido' => 'Admin Test',
|
||||
'email' => 'admin@example.test',
|
||||
]);
|
||||
$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' => 'user',
|
||||
]);
|
||||
$modification->setRelation('trackable', $sale);
|
||||
$modification->setRelation('user', $admin);
|
||||
|
||||
$response = app(AdminAppSaleExcelService::class)->downloadModifications(
|
||||
$this->tenant(),
|
||||
collect([$modification]),
|
||||
'America/La_Paz',
|
||||
);
|
||||
|
||||
$this->assertExcelResponse(
|
||||
$response,
|
||||
'historial_modificaciones_acme_20260824_135300.xlsx',
|
||||
function (string $path): void {
|
||||
$sheet = IOFactory::load($path)->getActiveSheet();
|
||||
|
||||
$this->assertSame('Modificaciones', $sheet->getTitle());
|
||||
$this->assertSame('#15', $sheet->getCell('C2')->getValue());
|
||||
$this->assertSame('pending_payment', $sheet->getCell('F2')->getValue());
|
||||
$this->assertSame('paid', $sheet->getCell('G2')->getValue());
|
||||
$this->assertSame('Admin Test', $sheet->getCell('H2')->getValue());
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** @param callable(string): void $assertSpreadsheet */
|
||||
private function assertExcelResponse(
|
||||
StreamedResponse $response,
|
||||
string $filename,
|
||||
callable $assertSpreadsheet,
|
||||
): void {
|
||||
$this->assertSame(
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
$response->headers->get('content-type'),
|
||||
);
|
||||
$this->assertStringContainsString(
|
||||
"attachment; filename={$filename}",
|
||||
(string) $response->headers->get('content-disposition'),
|
||||
);
|
||||
|
||||
ob_start();
|
||||
($response->getCallback())();
|
||||
$contents = ob_get_clean();
|
||||
$this->assertIsString($contents);
|
||||
$this->assertStringStartsWith('PK', $contents);
|
||||
|
||||
$path = tempnam(sys_get_temp_dir(), 'shopit_excel_');
|
||||
$this->assertNotFalse($path);
|
||||
|
||||
try {
|
||||
file_put_contents($path, $contents);
|
||||
$assertSpreadsheet($path);
|
||||
} finally {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function tenant(): Tenant
|
||||
{
|
||||
return (new Tenant)->forceFill([
|
||||
'codigo' => 'acme',
|
||||
'nombre' => 'Acme Eventos',
|
||||
]);
|
||||
}
|
||||
|
||||
private function sale(): Purchase
|
||||
{
|
||||
return (new Purchase)->forceFill([
|
||||
'id' => 15,
|
||||
'created_at' => now(),
|
||||
'nombre_apellido' => 'Cliente Test',
|
||||
'quantity' => 2,
|
||||
'status' => Purchase::STATUS_PAID,
|
||||
'total' => '25000.00',
|
||||
'tickets_count' => 2,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user