Compare commits
47 Commits
feature/al
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 899bf12457 | |||
| a460743ae0 | |||
| a4d7b1b789 | |||
| 10655b8c07 | |||
| adc7b21ab8 | |||
| ba1355448a | |||
| 1c76e56f2d | |||
| daa74845b7 | |||
| 05944cec9c | |||
| c820741e5f | |||
| adc4cc9595 | |||
| c67dfcc1a1 | |||
| f61b7e4dee | |||
| e53fa949cf | |||
| c51e24311f | |||
| a650de79c1 | |||
| 8f4fc39858 | |||
| 09554b9f80 | |||
| 0d2198a3db | |||
| 52732da960 | |||
| 22c6631652 | |||
| bbe3cf82f5 | |||
| ff61a3d3b8 | |||
| 6c5d49bf45 | |||
| 2596b5df18 | |||
| 733064c0dc | |||
| 99808c1053 | |||
| e2ad78b1fb | |||
| 8ed8a11b7b | |||
| dc33e5dc09 | |||
| 414df97e9d | |||
| d0424c5589 | |||
| 28d09dedab | |||
| 8220fb5aaf | |||
| bf02d37a33 | |||
| 78ce263849 | |||
| 0c8419a5eb | |||
| 843659583e | |||
| c2921166bd | |||
| 0f67e66b6b | |||
| 725abed06a | |||
| 743939fc63 | |||
| eb50e65fef | |||
| 1cf13501d8 | |||
| 2eeef8d392 | |||
| 87a4fa6288 | |||
| 07d2129410 |
@@ -5,9 +5,6 @@ APP_DEBUG=false
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||||
PURCHASE_QR_EXPIRATION_MINUTES=15
|
|
||||||
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
|
||||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
|
||||||
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||||
FRONTEND_URLS=http://localhost:4200
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
@@ -31,6 +28,7 @@ AUTH_LOGIN_ATTEMPT_WINDOW_MINUTES=30
|
|||||||
AUTH_LOGIN_LOCK_MINUTES=15
|
AUTH_LOGIN_LOCK_MINUTES=15
|
||||||
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
AUTH_LOGIN_RATE_LIMIT_PER_MINUTE=10
|
||||||
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
AUTH_LOGIN_IP_RATE_LIMIT_PER_MINUTE=30
|
||||||
|
AUTH_PASSWORD_RESET_EXPIRATION_MINUTES=60
|
||||||
|
|
||||||
LOG_CHANNEL=daily
|
LOG_CHANNEL=daily
|
||||||
LOG_STACK=single
|
LOG_STACK=single
|
||||||
@@ -41,6 +39,8 @@ TELEPAGOS_LOG_LEVEL=info
|
|||||||
TELEPAGOS_LOG_DAYS=30
|
TELEPAGOS_LOG_DAYS=30
|
||||||
COMMANDS_LOG_LEVEL=info
|
COMMANDS_LOG_LEVEL=info
|
||||||
COMMANDS_LOG_DAYS=30
|
COMMANDS_LOG_DAYS=30
|
||||||
|
EMAILS_LOG_LEVEL=info
|
||||||
|
EMAILS_LOG_DAYS=30
|
||||||
|
|
||||||
DB_CONNECTION=mysql
|
DB_CONNECTION=mysql
|
||||||
DB_HOST=127.0.0.1
|
DB_HOST=127.0.0.1
|
||||||
|
|||||||
@@ -22,10 +22,18 @@ class ValidateResetPasswordAttemptController extends Controller
|
|||||||
{
|
{
|
||||||
$data = $request->validated();
|
$data = $request->validated();
|
||||||
|
|
||||||
if (! $this->resetPasswordAttemptService->validateCode(
|
$result = $this->resetPasswordAttemptService->validateCode(
|
||||||
$data['email'],
|
$data['email'],
|
||||||
$data['codigo'],
|
$data['codigo'],
|
||||||
)) {
|
);
|
||||||
|
|
||||||
|
if ($result === ResetPasswordAttemptService::CODE_EXPIRED) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'codigo' => __('api.auth.reset_code_expired'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result !== ResetPasswordAttemptService::CODE_VALID) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'codigo' => __('api.auth.reset_code_invalid'),
|
'codigo' => __('api.auth.reset_code_invalid'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ use Illuminate\Database\Eloquent\Attributes\Hidden;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
#[Fillable(['user_id', 'codigo', 'reason', 'status'])]
|
#[Fillable(['user_id', 'codigo', 'reason', 'status', 'expires_at'])]
|
||||||
#[Hidden(['codigo'])]
|
#[Hidden(['codigo'])]
|
||||||
class ResetPasswordAttempt extends Model
|
class ResetPasswordAttempt extends Model
|
||||||
{
|
{
|
||||||
@@ -31,6 +31,7 @@ class ResetPasswordAttempt extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
|
'expires_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ use Throwable;
|
|||||||
|
|
||||||
class ResetPasswordAttemptService
|
class ResetPasswordAttemptService
|
||||||
{
|
{
|
||||||
|
public const CODE_VALID = 'valid';
|
||||||
|
|
||||||
|
public const CODE_INVALID = 'invalid';
|
||||||
|
|
||||||
|
public const CODE_EXPIRED = 'expired';
|
||||||
|
|
||||||
public function createForEmail(
|
public function createForEmail(
|
||||||
string $email,
|
string $email,
|
||||||
string $tenantCode,
|
string $tenantCode,
|
||||||
@@ -146,12 +152,12 @@ class ResetPasswordAttemptService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function validateCode(string $email, string $code): bool
|
public function validateCode(string $email, string $code): string
|
||||||
{
|
{
|
||||||
$emailFingerprint = $this->emailFingerprint($email);
|
$emailFingerprint = $this->emailFingerprint($email);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return DB::transaction(function () use ($email, $code, $emailFingerprint): bool {
|
return DB::transaction(function () use ($email, $code, $emailFingerprint): string {
|
||||||
$user = User::query()
|
$user = User::query()
|
||||||
->where('email', $email)
|
->where('email', $email)
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
@@ -169,14 +175,27 @@ class ResetPasswordAttemptService
|
|||||||
'email_fingerprint' => $emailFingerprint,
|
'email_fingerprint' => $emailFingerprint,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return false;
|
return self::CODE_INVALID;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($attempt->expires_at?->isPast()) {
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Password reset code validation failed: attempt expired.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'attempt_id' => $attempt->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return self::CODE_EXPIRED;
|
||||||
}
|
}
|
||||||
|
|
||||||
$attempt->update([
|
$attempt->update([
|
||||||
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return true;
|
return self::CODE_VALID;
|
||||||
});
|
});
|
||||||
} catch (Throwable $exception) {
|
} catch (Throwable $exception) {
|
||||||
Log::error('Failed to validate password reset code.', [
|
Log::error('Failed to validate password reset code.', [
|
||||||
@@ -214,6 +233,19 @@ class ResetPasswordAttemptService
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($attempt->expires_at?->isPast()) {
|
||||||
|
$attempt->update([
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
]);
|
||||||
|
|
||||||
|
Log::info('Password reset failed: attempt expired.', [
|
||||||
|
'email_fingerprint' => $emailFingerprint,
|
||||||
|
'attempt_id' => $attempt->getKey(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$user->password = $password;
|
$user->password = $password;
|
||||||
$user->failed_login_attempts = 0;
|
$user->failed_login_attempts = 0;
|
||||||
$user->last_failed_login_at = null;
|
$user->last_failed_login_at = null;
|
||||||
@@ -270,6 +302,7 @@ class ResetPasswordAttemptService
|
|||||||
'codigo' => $this->generateCode(),
|
'codigo' => $this->generateCode(),
|
||||||
'reason' => $reason,
|
'reason' => $reason,
|
||||||
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
'status' => ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
'expires_at' => now()->addMinutes((int) config('auth.passwords.users.expire')),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return $attempt->getKey();
|
return $attempt->getKey();
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class AdminAppBootstrapResource extends JsonResource
|
|||||||
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
'login_header_footer_color' => $websiteType->login_header_footer_color,
|
||||||
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
'site_logo' => $websiteType->siteLogo?->getTemporaryUrl(1440),
|
||||||
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
'footer_logo' => $websiteType->footerLogo?->getTemporaryUrl(1440),
|
||||||
|
'favicon' => $websiteType->favicon?->getTemporaryUrl(1440),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class AdminAppBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('dominio', $domain)
|
->where('dominio', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class ScannerBootstrapService
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'website_type' => WebsiteType::query()
|
'website_type' => WebsiteType::query()
|
||||||
->with(['siteLogo', 'footerLogo'])
|
->with(['siteLogo', 'footerLogo', 'favicon'])
|
||||||
->where('scanner_domain', $domain)
|
->where('scanner_domain', $domain)
|
||||||
->firstOrFail(),
|
->firstOrFail(),
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Cart\Models;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
@@ -28,6 +29,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
'status',
|
'status',
|
||||||
'origin',
|
'origin',
|
||||||
'current_purchase_id',
|
'current_purchase_id',
|
||||||
|
'current_stock_reservation_id',
|
||||||
])]
|
])]
|
||||||
class Cart extends Model
|
class Cart extends Model
|
||||||
{
|
{
|
||||||
@@ -36,6 +38,16 @@ class Cart extends Model
|
|||||||
|
|
||||||
protected $table = 'carritos';
|
protected $table = 'carritos';
|
||||||
|
|
||||||
|
public const STATUS_ACTIVE = 'active';
|
||||||
|
|
||||||
|
public const STATUS_CHECKOUT = 'checkout';
|
||||||
|
|
||||||
|
public const STATUS_CONVERTED = 'converted';
|
||||||
|
|
||||||
|
public const STATUS_EXPIRED = 'expired';
|
||||||
|
|
||||||
|
public const STATUS_ABANDONED = 'abandoned';
|
||||||
|
|
||||||
public const ORIGIN_USER = 'user';
|
public const ORIGIN_USER = 'user';
|
||||||
|
|
||||||
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
public const ORIGIN_DIRECT_CHECKOUT = 'direct_checkout';
|
||||||
@@ -45,6 +57,7 @@ class Cart extends Model
|
|||||||
return [
|
return [
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
'current_purchase_id' => 'integer',
|
'current_purchase_id' => 'integer',
|
||||||
|
'current_stock_reservation_id' => 'integer',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +97,15 @@ class Cart extends Model
|
|||||||
return $this->belongsTo(Purchase::class, 'current_purchase_id');
|
return $this->belongsTo(Purchase::class, 'current_purchase_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
|
public function currentStockReservation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(
|
||||||
|
StockReservation::class,
|
||||||
|
'current_stock_reservation_id',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function getTotalAmount(): float
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
$items = $this->relationLoaded('items')
|
$items = $this->relationLoaded('items')
|
||||||
@@ -107,6 +129,7 @@ class Cart extends Model
|
|||||||
|
|
||||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||||
$cartQuantity = (int) $this->items()
|
$cartQuantity = (int) $this->items()
|
||||||
->where('catalog_item_id', $catalogItemId)
|
->where('catalog_item_id', $catalogItemId)
|
||||||
@@ -140,12 +163,11 @@ class Cart extends Model
|
|||||||
'cantidad' => $quantity,
|
'cantidad' => $quantity,
|
||||||
]);
|
]);
|
||||||
} else {
|
} else {
|
||||||
app(StockReservationService::class)->ensure($item, $selectedItem);
|
|
||||||
$item->cantidad += $quantity;
|
$item->cantidad += $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
app(StockReservationService::class)->reserve($item, $selectedItem, $quantity);
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -172,6 +194,7 @@ class Cart extends Model
|
|||||||
$excludedPurchaseId,
|
$excludedPurchaseId,
|
||||||
): CartItem {
|
): CartItem {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
|
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
@@ -205,7 +228,6 @@ class Cart extends Model
|
|||||||
$nextAvailableQuantity,
|
$nextAvailableQuantity,
|
||||||
);
|
);
|
||||||
|
|
||||||
app(StockReservationService::class)->release($item, $currentSelection, $item->cantidad);
|
|
||||||
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||||
|
|
||||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||||
@@ -222,11 +244,10 @@ class Cart extends Model
|
|||||||
->first();
|
->first();
|
||||||
|
|
||||||
if ($targetItem !== null) {
|
if ($targetItem !== null) {
|
||||||
app(StockReservationService::class)->ensure($targetItem, $nextSelection);
|
|
||||||
$targetItem->cantidad += $quantity;
|
$targetItem->cantidad += $quantity;
|
||||||
$targetItem->save();
|
$targetItem->save();
|
||||||
app(StockReservationService::class)->reserve($targetItem, $nextSelection, $quantity);
|
|
||||||
$item->delete();
|
$item->delete();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $targetItem->fresh();
|
return $targetItem->fresh();
|
||||||
}
|
}
|
||||||
@@ -234,7 +255,7 @@ class Cart extends Model
|
|||||||
$item->variant_id = $variantId;
|
$item->variant_id = $variantId;
|
||||||
$item->cantidad = $quantity;
|
$item->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
app(StockReservationService::class)->reserve($item, $nextSelection, $quantity);
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
}
|
}
|
||||||
@@ -263,16 +284,9 @@ class Cart extends Model
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($delta > 0) {
|
|
||||||
app(StockReservationService::class)->reserve($item, $currentSelection, $delta);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($delta < 0) {
|
|
||||||
app(StockReservationService::class)->release($item, $currentSelection, abs($delta));
|
|
||||||
}
|
|
||||||
|
|
||||||
$item->cantidad = $quantity;
|
$item->cantidad = $quantity;
|
||||||
$item->save();
|
$item->save();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
|
|
||||||
return $item->fresh();
|
return $item->fresh();
|
||||||
});
|
});
|
||||||
@@ -282,6 +296,7 @@ class Cart extends Model
|
|||||||
{
|
{
|
||||||
DB::transaction(function () use ($cartItemId): void {
|
DB::transaction(function () use ($cartItemId): void {
|
||||||
$this->invalidateCurrentCheckout();
|
$this->invalidateCurrentCheckout();
|
||||||
|
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||||
|
|
||||||
/** @var CartItem $item */
|
/** @var CartItem $item */
|
||||||
$item = $this->items()
|
$item = $this->items()
|
||||||
@@ -289,17 +304,8 @@ class Cart extends Model
|
|||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->firstOrFail();
|
->firstOrFail();
|
||||||
|
|
||||||
$selectedItem = $this->resolveScopedItem(
|
|
||||||
$item->catalog_item_id,
|
|
||||||
$item->variant_id,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
app(StockReservationService::class)->release(
|
|
||||||
$item,
|
|
||||||
$selectedItem,
|
|
||||||
$item->cantidad,
|
|
||||||
);
|
|
||||||
$item->delete();
|
$item->delete();
|
||||||
|
app(StockReservationService::class)->syncCart($this);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,13 +347,20 @@ class Cart extends Model
|
|||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
], true)) {
|
], true)) {
|
||||||
|
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
|
||||||
$currentPurchase->update([
|
$currentPurchase->update([
|
||||||
'status' => Purchase::STATUS_SUPERSEDED,
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$this->current_purchase_id = null;
|
||||||
|
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
app(StockReservationService::class)->detachFromPurchase($currentPurchase);
|
app(StockReservationService::class)->releaseForPurchase(
|
||||||
|
$currentPurchase,
|
||||||
|
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
|
||||||
|
);
|
||||||
self::query()
|
self::query()
|
||||||
->whereKey($cart->getKey())
|
->whereKey($cart->getKey())
|
||||||
->where('current_purchase_id', $currentPurchase->getKey())
|
->where('current_purchase_id', $currentPurchase->getKey())
|
||||||
|
|||||||
@@ -3,13 +3,11 @@
|
|||||||
namespace App\Domains\Cart\Models;
|
namespace App\Domains\Cart\Models;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
@@ -57,10 +55,4 @@ class CartItem extends Model
|
|||||||
{
|
{
|
||||||
return $this->variant ?? $this->catalogItem;
|
return $this->variant ?? $this->catalogItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<StockReservation, $this> */
|
|
||||||
public function stockReservations(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(StockReservation::class);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,12 @@ namespace App\Domains\Cart\Services;
|
|||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
|
use App\Domains\Catalog\Services\ExpireStockReservationsService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Symfony\Component\HttpFoundation\Cookie;
|
use Symfony\Component\HttpFoundation\Cookie;
|
||||||
@@ -22,7 +25,7 @@ class CartService
|
|||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
$cart = $this->findCart($tenant, $resolvedIdentity['identity']);
|
$cart = $this->resolveCart($tenant, $resolvedIdentity['identity']);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
return $this->makeEmptyCart($tenant);
|
return $this->makeEmptyCart($tenant);
|
||||||
@@ -119,7 +122,7 @@ class CartService
|
|||||||
{
|
{
|
||||||
$cart = new Cart([
|
$cart = new Cart([
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$cart->setRelation('items', collect());
|
$cart->setRelation('items', collect());
|
||||||
@@ -220,12 +223,14 @@ class CartService
|
|||||||
{
|
{
|
||||||
return Cart::query()
|
return Cart::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('status', 'active')
|
->where('origin', Cart::ORIGIN_USER)
|
||||||
|
->whereIn('status', [Cart::STATUS_ACTIVE, Cart::STATUS_EXPIRED])
|
||||||
->when(
|
->when(
|
||||||
$identity['user_id'] !== null,
|
$identity['user_id'] !== null,
|
||||||
fn ($query) => $query->where('user_id', $identity['user_id']),
|
fn ($query) => $query->where('user_id', $identity['user_id']),
|
||||||
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
fn ($query) => $query->where('guest_token', $identity['guest_token']),
|
||||||
)
|
)
|
||||||
|
->orderByRaw('CASE WHEN status = ? THEN 0 ELSE 1 END', [Cart::STATUS_ACTIVE])
|
||||||
->first();
|
->first();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +239,7 @@ class CartService
|
|||||||
*/
|
*/
|
||||||
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
protected function findCartOrFail(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$cart = $this->findCart($tenant, $identity);
|
$cart = $this->resolveCart($tenant, $identity, replaceExpired: false);
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
throw new NotFoundHttpException('Cart not found.');
|
throw new NotFoundHttpException('Cart not found.');
|
||||||
@@ -247,10 +252,73 @@ class CartService
|
|||||||
* @param array{user_id: ?int, guest_token: ?string} $identity
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
*/
|
*/
|
||||||
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
protected function findOrCreateCart(Tenant $tenant, array $identity): Cart
|
||||||
|
{
|
||||||
|
return $this->resolveCart($tenant, $identity)
|
||||||
|
?? $this->createCart($tenant, $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function resolveCart(
|
||||||
|
Tenant $tenant,
|
||||||
|
array $identity,
|
||||||
|
bool $replaceExpired = true,
|
||||||
|
): ?Cart {
|
||||||
|
$cart = $this->findCart($tenant, $identity);
|
||||||
|
|
||||||
|
if ($cart?->status === Cart::STATUS_ACTIVE
|
||||||
|
&& $cart->current_stock_reservation_id !== null
|
||||||
|
&& app(ExpireStockReservationsService::class)
|
||||||
|
->expireIfOverdue($cart->current_stock_reservation_id)) {
|
||||||
|
$cart = $this->findCart($tenant, $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart?->status === Cart::STATUS_EXPIRED) {
|
||||||
|
if (! $replaceExpired) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->replaceExpiredCart($cart, $tenant, $identity);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart !== null) {
|
||||||
|
return $cart;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function replaceExpiredCart(Cart $expiredCart, Tenant $tenant, array $identity): Cart
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($expiredCart, $tenant, $identity): Cart {
|
||||||
|
/** @var Cart|null $lockedCart */
|
||||||
|
$lockedCart = Cart::query()->lockForUpdate()->find($expiredCart->getKey());
|
||||||
|
|
||||||
|
if ($lockedCart?->status === Cart::STATUS_EXPIRED) {
|
||||||
|
$lockedCart->update([
|
||||||
|
'status' => Cart::STATUS_ABANDONED,
|
||||||
|
'current_purchase_id' => null,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->findCart($tenant, $identity)
|
||||||
|
?? $this->createCart($tenant, $identity);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{user_id: ?int, guest_token: ?string} $identity
|
||||||
|
*/
|
||||||
|
protected function createCart(Tenant $tenant, array $identity): Cart
|
||||||
{
|
{
|
||||||
$attributes = [
|
$attributes = [
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
'status' => 'active',
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
|
'origin' => Cart::ORIGIN_USER,
|
||||||
];
|
];
|
||||||
|
|
||||||
if ($identity['user_id'] !== null) {
|
if ($identity['user_id'] !== null) {
|
||||||
|
|||||||
@@ -1,123 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Cart\Services;
|
|
||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class ExpireCartReservationsService
|
|
||||||
{
|
|
||||||
public function expireOverdue(): int
|
|
||||||
{
|
|
||||||
$expiredItems = 0;
|
|
||||||
$lastCartItemId = 0;
|
|
||||||
|
|
||||||
do {
|
|
||||||
$cartItemIds = StockReservation::query()
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->whereNull('purchase_id')
|
|
||||||
->whereNotNull('cart_item_id')
|
|
||||||
->whereNotNull('expires_at')
|
|
||||||
->where('expires_at', '<=', now())
|
|
||||||
->where('cart_item_id', '>', $lastCartItemId)
|
|
||||||
->whereHas('cartItem.cart', fn ($query) => $query->where('status', 'active'))
|
|
||||||
->select('cart_item_id')
|
|
||||||
->distinct()
|
|
||||||
->orderBy('cart_item_id')
|
|
||||||
->limit(500)
|
|
||||||
->pluck('cart_item_id');
|
|
||||||
|
|
||||||
foreach ($cartItemIds as $cartItemId) {
|
|
||||||
$lastCartItemId = (int) $cartItemId;
|
|
||||||
|
|
||||||
if ($this->expireCartItem($lastCartItemId)) {
|
|
||||||
$expiredItems++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} while ($cartItemIds->count() === 500);
|
|
||||||
|
|
||||||
return $expiredItems;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function expireCartItem(int $cartItemId): bool
|
|
||||||
{
|
|
||||||
/** @var CartItem|null $candidate */
|
|
||||||
$candidate = CartItem::query()->select(['id', 'cart_id'])->find($cartItemId);
|
|
||||||
if ($candidate === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return DB::transaction(function () use ($candidate, $cartItemId): bool {
|
|
||||||
/** @var Cart|null $cart */
|
|
||||||
$cart = Cart::query()
|
|
||||||
->whereKey($candidate->cart_id)
|
|
||||||
->where('status', 'active')
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($cart === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var CartItem|null $cartItem */
|
|
||||||
$cartItem = $cart->items()
|
|
||||||
->whereKey($cartItemId)
|
|
||||||
->lockForUpdate()
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if ($cartItem === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$reservations = StockReservation::query()
|
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->orderBy('inventory_id')
|
|
||||||
->lockForUpdate()
|
|
||||||
->get();
|
|
||||||
|
|
||||||
if (
|
|
||||||
$reservations->isEmpty()
|
|
||||||
|| $reservations->contains(
|
|
||||||
fn (StockReservation $reservation): bool => $reservation->purchase_id !== null
|
|
||||||
|| $reservation->expires_at === null
|
|
||||||
|| $reservation->expires_at->isFuture(),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$inventories = Inventory::query()
|
|
||||||
->whereKey($reservations->pluck('inventory_id'))
|
|
||||||
->orderBy('id')
|
|
||||||
->lockForUpdate()
|
|
||||||
->get()
|
|
||||||
->keyBy('id');
|
|
||||||
|
|
||||||
foreach ($reservations as $reservation) {
|
|
||||||
$inventory = $inventories->get($reservation->inventory_id)
|
|
||||||
?? throw new \InvalidArgumentException('No se encontro el inventario reservado.');
|
|
||||||
|
|
||||||
$inventory->release((int) $reservation->quantity);
|
|
||||||
$reservation->update([
|
|
||||||
'quantity' => 0,
|
|
||||||
'status' => StockReservation::STATUS_EXPIRED,
|
|
||||||
'expires_at' => null,
|
|
||||||
'released_at' => now(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cartItem->delete();
|
|
||||||
|
|
||||||
if (! $cart->items()->exists()) {
|
|
||||||
$cart->update(['status' => 'expired']);
|
|
||||||
$cart->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,13 +6,12 @@ Gestiona el carrito activo de un tenant tanto para visitantes como para usuarios
|
|||||||
|
|
||||||
## Modelo
|
## Modelo
|
||||||
|
|
||||||
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total y permite agregar, actualizar o quitar ítems.
|
- `Cart`: pertenece a un tenant y opcionalmente a un usuario; calcula el total, permite agregar, actualizar o quitar ítems y apunta a su reserva de stock vigente mediante `current_stock_reservation_id`.
|
||||||
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
|
- `CartItem`: referencia un `CatalogItem` y, opcionalmente, una `Variant`; sólo persiste la selección y cantidad, y expone siempre los datos vigentes del catálogo.
|
||||||
|
|
||||||
## Servicios
|
## Servicios
|
||||||
|
|
||||||
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
- `CartService`: obtiene el carrito, modifica ítems y administra la cookie del token invitado.
|
||||||
- `ExpireCartReservationsService`: libera las reservas vencidas de carritos activos y elimina los carritos que quedan vacíos.
|
|
||||||
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
- `GuestCartMergeService`: incorpora el carrito invitado al usuario cuando este se autentica.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
@@ -34,4 +33,6 @@ Depende de `Catalog` para productos y variantes, de `Tenant` para aislar datos y
|
|||||||
|
|
||||||
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
|
Un carrito puede pasar a `checkout`. Las compras directas usan un carrito técnico con `origin=direct_checkout`; los carritos normales conservan `origin=user` y pueden restaurarse al cancelar o vencer la compra.
|
||||||
|
|
||||||
El comando unificado `php artisan reservations:expire` procesa primero las compras vencidas y luego las reservas activas sin compra cuyo `expires_at` haya vencido. Se ejecuta cada minuto mediante el scheduler, conserva la fila de reserva con estado `expired`, elimina el ítem abandonado y elimina lógicamente el carrito cuando queda vacío. Cada intento registra sus resultados o su error en el log diario `storage/logs/commands/commands-AAAA-MM-DD.log`.
|
Cada edición sincroniza una única reserva para el carrito completo. Si varios ítems o bundles consumen el mismo inventario, se persiste una sola línea con la cantidad agregada. Al editar durante checkout, la compra anterior queda `superseded`, se desvincula y el carrito conserva la misma reserva activa con sus líneas actualizadas.
|
||||||
|
|
||||||
|
El comando unificado `php artisan reservations:expire` recorre una sola vez las reservas activas cuyo `expires_at` haya vencido. Cuando pertenecen a un carrito, conserva la reserva y sus líneas como historial, libera el stock como conjunto y cambia el carrito asociado a `expired` sin eliminar sus ítems. Al volver a resolver ese carrito desde la API, el anterior pasa automáticamente a `abandoned` y se crea uno activo y vacío para la misma identidad. El cliente nunca necesita reiniciarlo explícitamente. La API también materializa este vencimiento al acceder al carrito aunque el comando programado todavía no haya corrido.
|
||||||
|
|||||||
@@ -123,10 +123,6 @@ class CatalogController extends Controller
|
|||||||
$variantId === null ? null : (int) $variantId,
|
$variantId === null ? null : (int) $variantId,
|
||||||
);
|
);
|
||||||
$allowances->attach(collect([$item]), $this->userId($request));
|
$allowances->attach(collect([$item]), $this->userId($request));
|
||||||
abort_unless($allowances->availability(
|
|
||||||
$item->availableStock(),
|
|
||||||
$item->getAttribute('remaining_user_quota'),
|
|
||||||
)->isVisible(), 404);
|
|
||||||
|
|
||||||
return CatalogItemDetailResource::make($item);
|
return CatalogItemDetailResource::make($item);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Enums;
|
|
||||||
|
|
||||||
enum AvailabilityEffect: string
|
|
||||||
{
|
|
||||||
case Hide = 'hide';
|
|
||||||
case Restrict = 'restrict';
|
|
||||||
case Notice = 'notice';
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
class StockReservationExpiredException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
parent::__construct(__('api.cart.reservation_expired'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -27,6 +27,7 @@ use Illuminate\Support\Collection;
|
|||||||
'type',
|
'type',
|
||||||
'slug',
|
'slug',
|
||||||
'nombre',
|
'nombre',
|
||||||
|
'group_order',
|
||||||
'descripcion',
|
'descripcion',
|
||||||
'precio',
|
'precio',
|
||||||
'inventory_policy',
|
'inventory_policy',
|
||||||
@@ -47,6 +48,7 @@ class CatalogItem extends Model
|
|||||||
'inventory_policy' => InventoryPolicy::Tracked->value,
|
'inventory_policy' => InventoryPolicy::Tracked->value,
|
||||||
'inventory_subject' => InventorySubject::Product->value,
|
'inventory_subject' => InventorySubject::Product->value,
|
||||||
'has_tickets' => false,
|
'has_tickets' => false,
|
||||||
|
'group_order' => 0,
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
@@ -56,6 +58,7 @@ class CatalogItem extends Model
|
|||||||
'brand_id' => 'integer',
|
'brand_id' => 'integer',
|
||||||
'inventory_id' => 'integer',
|
'inventory_id' => 'integer',
|
||||||
'type' => CatalogItemType::class,
|
'type' => CatalogItemType::class,
|
||||||
|
'group_order' => 'integer',
|
||||||
'precio' => 'decimal:2',
|
'precio' => 'decimal:2',
|
||||||
'inventory_policy' => InventoryPolicy::class,
|
'inventory_policy' => InventoryPolicy::class,
|
||||||
'inventory_subject' => InventorySubject::class,
|
'inventory_subject' => InventorySubject::class,
|
||||||
@@ -172,17 +175,29 @@ class CatalogItem extends Model
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param Builder<CatalogItem> $query */
|
/** @param Builder<CatalogItem> $query */
|
||||||
public function scopeWhereVariantsAvailable(Builder $query): Builder
|
public function scopeWhereAvailable(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where(function (Builder $query): void {
|
return $query->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->whereDoesntHave('variants')
|
->where('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
||||||
->orWhere('catalog_items.inventory_policy', InventoryPolicy::Unlimited->value)
|
|
||||||
->orWhereHas(
|
->orWhereHas(
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||||
->whereColumn('inventories.real_stock', '>', 'inventories.reserved_stock')
|
->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')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,10 +48,10 @@ class Inventory extends Model
|
|||||||
return $this->hasOne(Variant::class);
|
return $this->hasOne(Variant::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<StockReservation, $this> */
|
/** @return HasMany<StockReservationLine, $this> */
|
||||||
public function stockReservations(): HasMany
|
public function stockReservationLines(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasMany(StockReservation::class);
|
return $this->hasMany(StockReservationLine::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function availableStock(): int
|
public function availableStock(): int
|
||||||
|
|||||||
@@ -2,21 +2,20 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Models;
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'inventory_id',
|
|
||||||
'cart_item_id',
|
|
||||||
'purchase_id',
|
|
||||||
'quantity',
|
|
||||||
'status',
|
'status',
|
||||||
'expires_at',
|
'expires_at',
|
||||||
'committed_at',
|
'committed_at',
|
||||||
'released_at',
|
'released_at',
|
||||||
|
'expired_at',
|
||||||
|
'release_reason',
|
||||||
])]
|
])]
|
||||||
class StockReservation extends Model
|
class StockReservation extends Model
|
||||||
{
|
{
|
||||||
@@ -31,31 +30,28 @@ class StockReservation extends Model
|
|||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'inventory_id' => 'integer',
|
|
||||||
'cart_item_id' => 'integer',
|
|
||||||
'purchase_id' => 'integer',
|
|
||||||
'quantity' => 'integer',
|
|
||||||
'expires_at' => 'datetime',
|
'expires_at' => 'datetime',
|
||||||
'committed_at' => 'datetime',
|
'committed_at' => 'datetime',
|
||||||
'released_at' => 'datetime',
|
'released_at' => 'datetime',
|
||||||
|
'expired_at' => 'datetime',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Inventory, $this> */
|
/** @return HasMany<StockReservationLine, $this> */
|
||||||
public function inventory(): BelongsTo
|
public function lines(): HasMany
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Inventory::class);
|
return $this->hasMany(StockReservationLine::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<CartItem, $this> */
|
/** @return HasOne<Cart, $this> */
|
||||||
public function cartItem(): BelongsTo
|
public function currentCart(): HasOne
|
||||||
{
|
{
|
||||||
return $this->belongsTo(CartItem::class);
|
return $this->hasOne(Cart::class, 'current_stock_reservation_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Purchase, $this> */
|
/** @return HasOne<Purchase, $this> */
|
||||||
public function purchase(): BelongsTo
|
public function purchase(): HasOne
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Purchase::class);
|
return $this->hasOne(Purchase::class);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
38
app/Domains/Catalog/Models/StockReservationLine.php
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Domains\Catalog\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
|
|
||||||
|
#[Fillable([
|
||||||
|
'stock_reservation_id',
|
||||||
|
'inventory_id',
|
||||||
|
'quantity',
|
||||||
|
'tracks_inventory',
|
||||||
|
])]
|
||||||
|
class StockReservationLine extends Model
|
||||||
|
{
|
||||||
|
protected function casts(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'stock_reservation_id' => 'integer',
|
||||||
|
'inventory_id' => 'integer',
|
||||||
|
'quantity' => 'integer',
|
||||||
|
'tracks_inventory' => 'boolean',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
|
public function reservation(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(StockReservation::class, 'stock_reservation_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return BelongsTo<Inventory, $this> */
|
||||||
|
public function inventory(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Inventory::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -51,6 +51,7 @@ class StoreCatalogItemRequest extends FormRequest
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
'nombre' => ['required', 'string', 'max:255'],
|
||||||
|
'group_order' => ['sometimes', 'integer', 'min:0'],
|
||||||
'descripcion' => ['sometimes', 'nullable', 'string'],
|
'descripcion' => ['sometimes', 'nullable', 'string'],
|
||||||
'precio' => ['required', 'numeric', 'min:0'],
|
'precio' => ['required', 'numeric', 'min:0'],
|
||||||
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
'inventory_policy' => [Rule::prohibitedIf($isBundle), 'sometimes', Rule::enum(InventoryPolicy::class)],
|
||||||
|
|||||||
@@ -38,13 +38,19 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'nombre' => $catalogItem->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'descripcion' => $catalogItem->descripcion,
|
'descripcion' => $catalogItem->descripcion,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'availability' => $this->availability($availableStock, $remainingUserQuota),
|
'maximum_addable_quantity' => $this->maximumAddable(
|
||||||
'variants' => $catalogItem->variants
|
$availableStock,
|
||||||
->map(function (Variant $variant) use ($catalogItem): array {
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->unavailableMessage(
|
||||||
|
$availableStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'variants' => $catalogItem->visibleVariants()
|
||||||
|
->map(function (Variant $variant) use ($catalogItem, $remainingUserQuota): array {
|
||||||
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
$variantStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
: $variant->inventory->availableStock();
|
: $variant->inventory->availableStock();
|
||||||
$availability = $this->availability($variantStock, null);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
@@ -54,11 +60,17 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'availability' => $availability,
|
'maximum_addable_quantity' => $this->maximumAddable(
|
||||||
|
$variantStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->unavailableMessage(
|
||||||
|
$variantStock,
|
||||||
|
$remainingUserQuota,
|
||||||
|
),
|
||||||
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
|
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -78,7 +90,8 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'descripcion' => $catalogItem->descripcion,
|
'descripcion' => $catalogItem->descripcion,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'image' => $this->firstImageUrl($catalogItem),
|
'image' => $this->firstImageUrl($catalogItem),
|
||||||
'availability' => $this->availability($availableStock, $remainingUserQuota),
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,27 +107,30 @@ class CatalogFeaturedItemResource extends JsonResource
|
|||||||
'nombre' => $catalogItem->nombre,
|
'nombre' => $catalogItem->nombre,
|
||||||
'precio' => $catalogItem->precio,
|
'precio' => $catalogItem->precio,
|
||||||
'image' => $this->firstImageUrl($catalogItem),
|
'image' => $this->firstImageUrl($catalogItem),
|
||||||
'availability' => $this->availability($availableStock, $remainingUserQuota),
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock, $remainingUserQuota),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($availableStock, $remainingUserQuota),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
private function firstImageUrl(CatalogItem $catalogItem): ?string
|
||||||
{
|
{
|
||||||
$attachment = $catalogItem->attachments->first()
|
$attachment = $catalogItem->attachments->first()
|
||||||
?? $catalogItem->variants
|
?? $catalogItem->visibleVariants()
|
||||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
return $attachment?->getTemporaryUrl(1440);
|
return $attachment?->getTemporaryUrl(1440);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
private function maximumAddable(?int $stock, ?int $remainingUserQuota): ?int
|
||||||
private function availability(
|
{
|
||||||
?int $stock,
|
|
||||||
?int $remainingUserQuota,
|
|
||||||
): array {
|
|
||||||
return app(CatalogItemAllowanceService::class)
|
return app(CatalogItemAllowanceService::class)
|
||||||
->availability($stock, $remainingUserQuota)
|
->maximumAddableQuantity($stock, $remainingUserQuota);
|
||||||
->toArray();
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock, ?int $remainingUserQuota): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)
|
||||||
|
->unavailableMessage($stock, $remainingUserQuota);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,14 +38,20 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
'max_units_per_user' => $this->max_units_per_user,
|
'max_units_per_user' => $this->max_units_per_user,
|
||||||
'has_tickets' => $this->has_tickets,
|
'has_tickets' => $this->has_tickets,
|
||||||
'attributes' => $this->attributesData(),
|
'attributes' => $this->attributesData(),
|
||||||
'availability' => $this->availability($this->availableStock()),
|
'maximum_addable_quantity' => $this->when(
|
||||||
|
$selectedVariant === null,
|
||||||
|
fn () => $this->maximumAddable($this->availableStock()),
|
||||||
|
),
|
||||||
|
'unavailable_message' => $this->when(
|
||||||
|
$selectedVariant === null,
|
||||||
|
fn () => $this->unavailableMessage($this->availableStock()),
|
||||||
|
),
|
||||||
'images' => $this->when(
|
'images' => $this->when(
|
||||||
$selectedVariant === null,
|
$selectedVariant === null,
|
||||||
fn () => $this->imageUrls($this->attachments),
|
fn () => $this->imageUrls($this->attachments),
|
||||||
),
|
),
|
||||||
'variants' => $this->variants
|
'variants' => $this->variants
|
||||||
->map(fn (Variant $variant): array => $this->variantData($variant))
|
->map(fn (Variant $variant): array => $this->variantData($variant))
|
||||||
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
|
|
||||||
->values(),
|
->values(),
|
||||||
'selected_variant' => $this->when(
|
'selected_variant' => $this->when(
|
||||||
$selectedVariant !== null,
|
$selectedVariant !== null,
|
||||||
@@ -158,10 +164,6 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
$values = $variant->selectionOptions($this->itemAttributes);
|
$values = $variant->selectionOptions($this->itemAttributes);
|
||||||
$eventDates = $variant->selectedEventDates();
|
$eventDates = $variant->selectedEventDates();
|
||||||
$variantStock = $this->variantStock($variant);
|
$variantStock = $this->variantStock($variant);
|
||||||
$availability = $this->availability(
|
|
||||||
$variantStock,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
@@ -171,7 +173,8 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
'event_dates' => $eventDates->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'availability' => $availability,
|
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($variantStock),
|
||||||
'values' => $values,
|
'values' => $values,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -191,14 +194,19 @@ class CatalogItemDetailResource extends JsonResource
|
|||||||
: $variant->inventory->availableStock();
|
: $variant->inventory->availableStock();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
private function maximumAddable(?int $stock): ?int
|
||||||
private function availability(
|
{
|
||||||
?int $stock,
|
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
|
||||||
bool $includeUserQuota = true,
|
|
||||||
): array {
|
|
||||||
return app(CatalogItemAllowanceService::class)->availability(
|
|
||||||
$stock,
|
$stock,
|
||||||
$includeUserQuota ? $this->getAttribute('remaining_user_quota') : null,
|
$this->getAttribute('remaining_user_quota'),
|
||||||
)->toArray();
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)->unavailableMessage(
|
||||||
|
$stock,
|
||||||
|
$this->getAttribute('remaining_user_quota'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,8 +16,9 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
$availableStock = $this->availableStock();
|
$availableStock = $this->availableStock();
|
||||||
|
$visibleVariants = $this->visibleVariants();
|
||||||
$attachment = $this->attachments->first()
|
$attachment = $this->attachments->first()
|
||||||
?? $this->variants
|
?? $visibleVariants
|
||||||
->flatMap(fn (Variant $variant) => $variant->attachments)
|
->flatMap(fn (Variant $variant) => $variant->attachments)
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
@@ -28,16 +29,13 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
'descripcion' => $this->descripcion,
|
'descripcion' => $this->descripcion,
|
||||||
'precio' => $this->precio,
|
'precio' => $this->precio,
|
||||||
'image' => $attachment?->getTemporaryUrl(1440),
|
'image' => $attachment?->getTemporaryUrl(1440),
|
||||||
'availability' => $this->availability($availableStock),
|
'maximum_addable_quantity' => $this->maximumAddable($availableStock),
|
||||||
'variants' => $this->variants
|
'unavailable_message' => $this->unavailableMessage($availableStock),
|
||||||
|
'variants' => $visibleVariants
|
||||||
->map(function (Variant $variant): array {
|
->map(function (Variant $variant): array {
|
||||||
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
|
$variantStock = $this->inventory_policy === InventoryPolicy::Unlimited
|
||||||
? null
|
? null
|
||||||
: $variant->inventory?->availableStock();
|
: $variant->inventory?->availableStock();
|
||||||
$availability = $this->availability(
|
|
||||||
$variantStock,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
@@ -47,23 +45,28 @@ class CatalogSearchItemResource extends JsonResource
|
|||||||
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
'event_dates' => $variant->selectedEventDates()->map(fn ($eventDate): string => $eventDate->date->format('Y-m-d'))->values(),
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'availability' => $availability,
|
'maximum_addable_quantity' => $this->maximumAddable($variantStock),
|
||||||
|
'unavailable_message' => $this->unavailableMessage($variantStock),
|
||||||
'values' => $variant->selectorOptions($this->itemAttributes),
|
'values' => $variant->selectorOptions($this->itemAttributes),
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
->filter(fn (array $variant): bool => $variant['availability']['state'] === 'visible')
|
|
||||||
->values(),
|
->values(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
private function maximumAddable(?int $stock): ?int
|
||||||
private function availability(
|
{
|
||||||
?int $stock,
|
return app(CatalogItemAllowanceService::class)->maximumAddableQuantity(
|
||||||
bool $includeUserQuota = true,
|
|
||||||
): array {
|
|
||||||
return app(CatalogItemAllowanceService::class)->availability(
|
|
||||||
$stock,
|
$stock,
|
||||||
$includeUserQuota ? $this->getAttribute('remaining_user_quota') : null,
|
$this->getAttribute('remaining_user_quota'),
|
||||||
)->toArray();
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function unavailableMessage(?int $stock): ?string
|
||||||
|
{
|
||||||
|
return app(CatalogItemAllowanceService::class)->unavailableMessage(
|
||||||
|
$stock,
|
||||||
|
$this->getAttribute('remaining_user_quota'),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,63 +0,0 @@
|
|||||||
<?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,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?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'] ?? [],
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -25,6 +25,24 @@ class CatalogInventoryService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||||
|
*/
|
||||||
|
public function detailedRequirementsFor(CatalogItem|Variant $selection, int $quantity = 1): array
|
||||||
|
{
|
||||||
|
if ($quantity <= 0) {
|
||||||
|
throw new \InvalidArgumentException('La cantidad debe ser mayor a cero.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return array_map(
|
||||||
|
fn (array $requirement): array => [
|
||||||
|
...$requirement,
|
||||||
|
'quantity' => $requirement['quantity'] * $quantity,
|
||||||
|
],
|
||||||
|
$this->inventoryRequirements($selection),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
public function availableQuantity(CatalogItem|Variant $selection): ?int
|
||||||
{
|
{
|
||||||
if ($selection instanceof CatalogItem
|
if ($selection instanceof CatalogItem
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\AvailabilityEffect;
|
|
||||||
use App\Domains\Catalog\Enums\CatalogAction;
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
@@ -16,7 +14,6 @@ class CatalogItemAllowanceService
|
|||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly UserPurchaseLimitService $purchaseLimits,
|
private readonly UserPurchaseLimitService $purchaseLimits,
|
||||||
private readonly AvailabilityPolicyResolver $policies,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @param Collection<int, CatalogItem> $catalogItems */
|
/** @param Collection<int, CatalogItem> $catalogItems */
|
||||||
@@ -45,82 +42,16 @@ class CatalogItemAllowanceService
|
|||||||
return min($availableStock, $remainingUserQuota);
|
return min($availableStock, $remainingUserQuota);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function availability(
|
public function unavailableMessage(?int $availableStock, ?int $remainingUserQuota): ?string
|
||||||
?int $availableStock,
|
{
|
||||||
?int $remainingUserQuota,
|
|
||||||
): AvailabilityDecision {
|
|
||||||
$reasons = [];
|
|
||||||
|
|
||||||
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
|
if ($remainingUserQuota !== null && $remainingUserQuota <= 0) {
|
||||||
$reasons[] = [
|
return self::USER_QUOTA_REACHED_MESSAGE;
|
||||||
'code' => 'user_quota_reached',
|
|
||||||
'message' => self::USER_QUOTA_REACHED_MESSAGE,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($availableStock !== null && $availableStock <= 0) {
|
if ($availableStock !== null && $availableStock <= 0) {
|
||||||
$reasons[] = [
|
return self::OUT_OF_STOCK_MESSAGE;
|
||||||
'code' => 'out_of_stock',
|
|
||||||
'message' => self::OUT_OF_STOCK_MESSAGE,
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->decision(
|
return null;
|
||||||
$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,10 +22,7 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|||||||
|
|
||||||
class CatalogService
|
class CatalogService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(protected AttachmentService $attachmentService) {}
|
||||||
protected AttachmentService $attachmentService,
|
|
||||||
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $data
|
* @param array<string, mixed> $data
|
||||||
@@ -208,6 +205,13 @@ class CatalogService
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$visibleVariants = $catalogItem->visibleVariants();
|
$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
|
$selectedVariant = $variantId === null
|
||||||
? $visibleVariants->first()
|
? $visibleVariants->first()
|
||||||
: $visibleVariants->firstWhere('id', $variantId);
|
: $visibleVariants->firstWhere('id', $variantId);
|
||||||
@@ -232,8 +236,9 @@ class CatalogService
|
|||||||
$containsPattern = "%{$normalizedTerm}%";
|
$containsPattern = "%{$normalizedTerm}%";
|
||||||
$startsWithPattern = "{$normalizedTerm}%";
|
$startsWithPattern = "{$normalizedTerm}%";
|
||||||
|
|
||||||
$query = CatalogItem::query()
|
$paginator = CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->whereAvailable()
|
||||||
->where(function (Builder $query) use ($containsPattern): void {
|
->where(function (Builder $query) use ($containsPattern): void {
|
||||||
$query
|
$query
|
||||||
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
->whereRaw('LOWER(nombre) LIKE ?', [$containsPattern])
|
||||||
@@ -260,10 +265,7 @@ class CatalogService
|
|||||||
'variants.definitions.itemAttribute.attribute.options',
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
'bundleComponents.catalogItem',
|
'bundleComponents.catalogItem',
|
||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
]);
|
])
|
||||||
|
|
||||||
$paginator = $this->visibleCatalogItems
|
|
||||||
->apply($query)
|
|
||||||
->orderByRaw(
|
->orderByRaw(
|
||||||
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
|
'CASE WHEN LOWER(nombre) = ? THEN 0 WHEN LOWER(nombre) LIKE ? THEN 1 ELSE 2 END',
|
||||||
[$normalizedTerm, $startsWithPattern],
|
[$normalizedTerm, $startsWithPattern],
|
||||||
@@ -283,9 +285,10 @@ class CatalogService
|
|||||||
int $perPage,
|
int $perPage,
|
||||||
int $page,
|
int $page,
|
||||||
): LengthAwarePaginator {
|
): LengthAwarePaginator {
|
||||||
$query = CatalogItem::query()
|
return CatalogItem::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('category_id', $category->id)
|
->where('category_id', $category->id)
|
||||||
|
->whereAvailable()
|
||||||
->with([
|
->with([
|
||||||
'attachments',
|
'attachments',
|
||||||
'inventory',
|
'inventory',
|
||||||
@@ -297,10 +300,7 @@ class CatalogService
|
|||||||
'variants.definitions.itemAttribute.attribute.options',
|
'variants.definitions.itemAttribute.attribute.options',
|
||||||
'bundleComponents.catalogItem',
|
'bundleComponents.catalogItem',
|
||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
]);
|
])
|
||||||
|
|
||||||
return $this->visibleCatalogItems
|
|
||||||
->apply($query)
|
|
||||||
->orderBy('nombre')
|
->orderBy('nombre')
|
||||||
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
->paginate(perPage: $perPage, pageName: 'page', page: $page);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,50 +2,184 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
use App\Domains\Cart\Services\ExpireCartReservationsService;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
|
use App\Domains\Purchase\Services\Checkout\ReleaseCheckoutService;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
class ExpireStockReservationsService
|
class ExpireStockReservationsService
|
||||||
{
|
{
|
||||||
|
private const BATCH_SIZE = 500;
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CheckoutService $checkout,
|
private readonly ReleaseCheckoutService $purchases,
|
||||||
private readonly ExpireCartReservationsService $carts,
|
private readonly StockReservationService $reservations,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array{purchases: int, cart_items: int}
|
* @return array{purchases: int, cart_reservations: int, orphan_reservations: int, failed: int}
|
||||||
*/
|
*/
|
||||||
public function expireOverdue(): array
|
public function expireOverdue(): array
|
||||||
{
|
{
|
||||||
$expiredPurchases = null;
|
$summary = [
|
||||||
$expiredCartItems = null;
|
'purchases' => 0,
|
||||||
|
'cart_reservations' => 0,
|
||||||
|
'orphan_reservations' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
];
|
||||||
|
$lastReservationId = 0;
|
||||||
|
|
||||||
try {
|
do {
|
||||||
$expiredPurchases = $this->checkout->expireOverduePurchases();
|
$reservationIds = StockReservation::query()
|
||||||
$expiredCartItems = $this->carts->expireOverdue();
|
->where('status', StockReservation::STATUS_ACTIVE)
|
||||||
|
->whereNotNull('expires_at')
|
||||||
|
->where('expires_at', '<=', now())
|
||||||
|
->where('id', '>', $lastReservationId)
|
||||||
|
->orderBy('id')
|
||||||
|
->limit(self::BATCH_SIZE)
|
||||||
|
->pluck('id');
|
||||||
|
|
||||||
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
foreach ($reservationIds as $reservationId) {
|
||||||
'command' => 'reservations:expire',
|
$lastReservationId = (int) $reservationId;
|
||||||
'expired_purchases' => $expiredPurchases,
|
|
||||||
'expired_cart_items' => $expiredCartItems,
|
|
||||||
'total_expired' => $expiredPurchases + $expiredCartItems,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return [
|
try {
|
||||||
'purchases' => $expiredPurchases,
|
$owner = $this->expireReservation($lastReservationId);
|
||||||
'cart_items' => $expiredCartItems,
|
if ($owner !== null) {
|
||||||
];
|
$summary[$owner]++;
|
||||||
} catch (Throwable $exception) {
|
}
|
||||||
Log::channel('commands')->error('Stock reservation cleanup failed.', [
|
} catch (Throwable $exception) {
|
||||||
'command' => 'reservations:expire',
|
$summary['failed']++;
|
||||||
'expired_purchases' => $expiredPurchases,
|
Log::channel('commands')->error('Failed to expire overdue stock reservation.', [
|
||||||
'expired_cart_items' => $expiredCartItems,
|
'command' => 'reservations:expire',
|
||||||
'exception' => $exception,
|
'stock_reservation_id' => $lastReservationId,
|
||||||
]);
|
'exception' => $exception,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} while ($reservationIds->count() === self::BATCH_SIZE);
|
||||||
|
|
||||||
throw $exception;
|
Log::channel('commands')->info('Stock reservation cleanup completed.', [
|
||||||
|
'command' => 'reservations:expire',
|
||||||
|
'expired_purchases' => $summary['purchases'],
|
||||||
|
'expired_cart_reservations' => $summary['cart_reservations'],
|
||||||
|
'expired_orphan_reservations' => $summary['orphan_reservations'],
|
||||||
|
'failed_reservations' => $summary['failed'],
|
||||||
|
'total_expired' => $summary['purchases']
|
||||||
|
+ $summary['cart_reservations']
|
||||||
|
+ $summary['orphan_reservations'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expireIfOverdue(int $reservationId): bool
|
||||||
|
{
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->find($reservationId);
|
||||||
|
if ($reservation?->status === StockReservation::STATUS_EXPIRED) {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expireReservation($reservationId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'purchases'|'cart_reservations'|'orphan_reservations'|null */
|
||||||
|
private function expireReservation(int $reservationId): ?string
|
||||||
|
{
|
||||||
|
$purchaseId = Purchase::query()
|
||||||
|
->where('stock_reservation_id', $reservationId)
|
||||||
|
->value('id');
|
||||||
|
if ($purchaseId !== null) {
|
||||||
|
return $this->expirePurchase((int) $purchaseId);
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartId = Cart::query()
|
||||||
|
->where('current_stock_reservation_id', $reservationId)
|
||||||
|
->where('status', 'active')
|
||||||
|
->value('id');
|
||||||
|
if ($cartId !== null) {
|
||||||
|
return $this->expireCart((int) $cartId, $reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expireOrphan($reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'purchases'|null */
|
||||||
|
private function expirePurchase(int $purchaseId): ?string
|
||||||
|
{
|
||||||
|
/** @var Purchase|null $purchase */
|
||||||
|
$purchase = Purchase::query()->find($purchaseId);
|
||||||
|
if ($purchase === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$purchase = $this->purchases->expire($purchase);
|
||||||
|
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||||
|
return 'purchases';
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation = $purchase->stockReservation;
|
||||||
|
if ($this->isOverdue($reservation)) {
|
||||||
|
throw new RuntimeException('An overdue active reservation belongs to a purchase that cannot expire.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'cart_reservations'|null */
|
||||||
|
private function expireCart(int $cartId, int $reservationId): ?string
|
||||||
|
{
|
||||||
|
return DB::transaction(function () use ($cartId, $reservationId): ?string {
|
||||||
|
/** @var Cart|null $cart */
|
||||||
|
$cart = Cart::query()
|
||||||
|
->whereKey($cartId)
|
||||||
|
->where('current_stock_reservation_id', $reservationId)
|
||||||
|
->where('status', Cart::STATUS_ACTIVE)
|
||||||
|
->lockForUpdate()
|
||||||
|
->first();
|
||||||
|
if ($cart === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->find($reservationId);
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reservations->expire($reservation);
|
||||||
|
$cart->update(['status' => Cart::STATUS_EXPIRED]);
|
||||||
|
|
||||||
|
return 'cart_reservations';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return 'orphan_reservations'|null */
|
||||||
|
private function expireOrphan(int $reservationId): ?string
|
||||||
|
{
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->find($reservationId);
|
||||||
|
if (! $this->isOverdue($reservation)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->reservations->expire($reservation);
|
||||||
|
|
||||||
|
return 'orphan_reservations';
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isOverdue(?StockReservation $reservation): bool
|
||||||
|
{
|
||||||
|
return $reservation !== null
|
||||||
|
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||||
|
&& $reservation->expires_at !== null
|
||||||
|
&& ! $reservation->expires_at->isFuture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ class FeaturedGroupService
|
|||||||
/** @return array<array-key, mixed> */
|
/** @return array<array-key, mixed> */
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogItemAllowanceService $allowances,
|
private readonly CatalogItemAllowanceService $allowances,
|
||||||
private readonly VisibleCatalogItemsQuery $visibleCatalogItems,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
|
public function itemsResponse(FeaturedGroup $featuredGroup, int $page, ?int $userId = null): array
|
||||||
@@ -50,6 +49,7 @@ class FeaturedGroupService
|
|||||||
{
|
{
|
||||||
$query = CatalogItem::query()
|
$query = CatalogItem::query()
|
||||||
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
->where('catalog_items.tenant_code', $featuredGroup->tenant_code)
|
||||||
|
->whereAvailable()
|
||||||
->where(function (Builder $query): void {
|
->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->whereDoesntHave('category')
|
->whereDoesntHave('category')
|
||||||
@@ -72,8 +72,6 @@ class FeaturedGroupService
|
|||||||
'bundleComponents.variant.catalogItem',
|
'bundleComponents.variant.catalogItem',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$query = $this->visibleCatalogItems->apply($query);
|
|
||||||
|
|
||||||
return match ($featuredGroup->source_type) {
|
return match ($featuredGroup->source_type) {
|
||||||
FeaturedGroupSource::Manual => $query
|
FeaturedGroupSource::Manual => $query
|
||||||
->select('catalog_items.*')
|
->select('catalog_items.*')
|
||||||
@@ -84,7 +82,9 @@ class FeaturedGroupService
|
|||||||
FeaturedGroupSource::Category => $query
|
FeaturedGroupSource::Category => $query
|
||||||
->where('catalog_items.category_id', $featuredGroup->category_id)
|
->where('catalog_items.category_id', $featuredGroup->category_id)
|
||||||
->orderBy('catalog_items.id'),
|
->orderBy('catalog_items.id'),
|
||||||
FeaturedGroupSource::All => $query->orderBy('catalog_items.id'),
|
FeaturedGroupSource::All => $query
|
||||||
|
->orderBy('catalog_items.group_order')
|
||||||
|
->orderBy('catalog_items.id'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,262 +2,486 @@
|
|||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
namespace App\Domains\Catalog\Services;
|
||||||
|
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\StockReservationLine;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
class StockReservationService
|
class StockReservationService
|
||||||
{
|
{
|
||||||
|
public const REASON_CART_EMPTY = 'cart_empty';
|
||||||
|
|
||||||
|
public const REASON_CART_CHANGED = 'cart_changed';
|
||||||
|
|
||||||
|
public const REASON_PURCHASE_SUPERSEDED = 'purchase_superseded';
|
||||||
|
|
||||||
|
public const REASON_PURCHASE_CANCELLED = 'purchase_cancelled';
|
||||||
|
|
||||||
|
public const REASON_PAYMENT_REJECTED = 'payment_rejected';
|
||||||
|
|
||||||
|
public const REASON_MANUAL_RELEASE = 'manual_release';
|
||||||
|
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly CatalogInventoryService $inventory,
|
private readonly CatalogInventoryService $inventory,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function reserve(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
public function syncCart(Cart $cart): ?StockReservation
|
||||||
{
|
{
|
||||||
DB::transaction(function () use ($cartItem, $selection, $quantity): void {
|
return DB::transaction(function () use ($cart): ?StockReservation {
|
||||||
$this->inventory->reserve($selection, $quantity);
|
/** @var Cart $lockedCart */
|
||||||
$this->recordIncrease($cartItem, $selection, $quantity);
|
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
});
|
$items = $lockedCart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
}
|
$this->loadSelections($items);
|
||||||
|
$requirements = $this->requirementsForItems($items);
|
||||||
|
|
||||||
public function release(
|
$reservation = $lockedCart->current_stock_reservation_id === null
|
||||||
CartItem $cartItem,
|
? null
|
||||||
CatalogItem|Variant $selection,
|
: StockReservation::query()->lockForUpdate()->find($lockedCart->current_stock_reservation_id);
|
||||||
int $quantity,
|
|
||||||
string $releasedStatus = StockReservation::STATUS_RELEASED,
|
|
||||||
): void {
|
|
||||||
DB::transaction(function () use ($cartItem, $selection, $quantity, $releasedStatus): void {
|
|
||||||
$this->ensure($cartItem, $selection);
|
|
||||||
$this->inventory->release($selection, $quantity);
|
|
||||||
$this->recordDecrease($cartItem, $selection, $quantity, $releasedStatus);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function commit(
|
if ($reservation !== null) {
|
||||||
CartItem $cartItem,
|
$this->assertUsableCartReservation($reservation);
|
||||||
CatalogItem|Variant $selection,
|
}
|
||||||
Purchase $purchase,
|
|
||||||
): void {
|
|
||||||
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
|
|
||||||
$this->ensure($cartItem, $selection);
|
|
||||||
$this->inventory->commit($selection, (int) $cartItem->cantidad);
|
|
||||||
|
|
||||||
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
if ($requirements === []) {
|
||||||
foreach ($requirements as $inventoryId => $quantity) {
|
if ($reservation !== null && $reservation->status === StockReservation::STATUS_ACTIVE) {
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
$this->finalizeLocked(
|
||||||
if (
|
$reservation,
|
||||||
$reservation === null
|
StockReservation::STATUS_RELEASED,
|
||||||
|| $reservation->status !== StockReservation::STATUS_ACTIVE
|
self::REASON_CART_EMPTY,
|
||||||
|| $reservation->purchase_id !== $purchase->getKey()
|
);
|
||||||
|| $reservation->quantity !== $quantity
|
|
||||||
) {
|
|
||||||
throw new \InvalidArgumentException('La reserva de stock no coincide con el item del carrito.');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$reservation->update([
|
$lockedCart->update(['current_stock_reservation_id' => null]);
|
||||||
'status' => StockReservation::STATUS_COMMITTED,
|
$cart->current_stock_reservation_id = null;
|
||||||
'committed_at' => now(),
|
|
||||||
'expires_at' => null,
|
return null;
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function ensure(CartItem $cartItem, CatalogItem|Variant $selection): void
|
|
||||||
{
|
|
||||||
$requirements = $this->inventory->requirementsFor($selection, (int) $cartItem->cantidad);
|
|
||||||
|
|
||||||
foreach ($requirements as $inventoryId => $quantity) {
|
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
|
||||||
|
|
||||||
if ($reservation === null) {
|
if ($reservation === null) {
|
||||||
StockReservation::query()->create([
|
$reservation = StockReservation::query()->create([
|
||||||
'inventory_id' => $inventoryId,
|
|
||||||
'cart_item_id' => $cartItem->getKey(),
|
|
||||||
'quantity' => $quantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
'status' => StockReservation::STATUS_ACTIVE,
|
||||||
'expires_at' => $this->expiration(),
|
'expires_at' => $this->expiration(),
|
||||||
]);
|
]);
|
||||||
|
$lockedCart->update(['current_stock_reservation_id' => $reservation->getKey()]);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity !== $quantity) {
|
if (Purchase::query()->where('stock_reservation_id', $reservation->getKey())->exists()) {
|
||||||
$reservation->update([
|
throw new \InvalidArgumentException('La reserva vinculada a una compra no se puede modificar.');
|
||||||
'quantity' => $quantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
|
||||||
'committed_at' => null,
|
|
||||||
'released_at' => null,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function attachToPurchase(
|
$currentLines = StockReservationLine::query()
|
||||||
CartItem $cartItem,
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
CatalogItem|Variant $selection,
|
->orderBy('inventory_id')
|
||||||
Purchase $purchase,
|
|
||||||
): void {
|
|
||||||
DB::transaction(function () use ($cartItem, $selection, $purchase): void {
|
|
||||||
$this->ensure($cartItem, $selection);
|
|
||||||
StockReservation::query()
|
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->update([
|
|
||||||
'purchase_id' => $purchase->getKey(),
|
|
||||||
'expires_at' => $purchase->expires_at,
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function detachFromPurchase(Purchase $purchase): void
|
|
||||||
{
|
|
||||||
StockReservation::query()
|
|
||||||
->where('purchase_id', $purchase->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->update([
|
|
||||||
'purchase_id' => null,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function restore(CartItem $cartItem, CatalogItem|Variant $selection): void
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($cartItem, $selection): void {
|
|
||||||
$requirements = $this->inventory->requirementsFor(
|
|
||||||
$selection,
|
|
||||||
(int) $cartItem->cantidad,
|
|
||||||
);
|
|
||||||
$activeReservations = StockReservation::query()
|
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->get()
|
->get()
|
||||||
->keyBy('inventory_id');
|
->keyBy('inventory_id');
|
||||||
|
$inventoryIds = collect(array_keys($requirements))
|
||||||
$hasCompleteReservation = collect($requirements)->every(
|
->merge($currentLines->keys())
|
||||||
fn (int $quantity, int $inventoryId): bool => (int) ($activeReservations->get($inventoryId)?->quantity ?? 0) === $quantity,
|
->map(fn ($id): int => (int) $id)
|
||||||
);
|
->unique()
|
||||||
|
->sort()
|
||||||
if ($hasCompleteReservation) {
|
->values();
|
||||||
return;
|
$inventories = Inventory::query()
|
||||||
}
|
->whereKey($inventoryIds)
|
||||||
|
->orderBy('id')
|
||||||
if ($activeReservations->isNotEmpty()) {
|
|
||||||
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->inventory->reserve($selection, (int) $cartItem->cantidad);
|
|
||||||
$this->recordIncrease($cartItem, $selection, (int) $cartItem->cantidad);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function syncPurchaseExpiration(Purchase $purchase): void
|
|
||||||
{
|
|
||||||
StockReservation::query()
|
|
||||||
->where('purchase_id', $purchase->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->update(['expires_at' => $purchase->expires_at]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function transfer(CartItem $source, CartItem $target): void
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($source, $target): void {
|
|
||||||
$sourceReservations = StockReservation::query()
|
|
||||||
->where('cart_item_id', $source->getKey())
|
|
||||||
->where('status', StockReservation::STATUS_ACTIVE)
|
|
||||||
->orderBy('inventory_id')
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->get();
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
|
||||||
foreach ($sourceReservations as $sourceReservation) {
|
foreach ($inventoryIds as $inventoryId) {
|
||||||
$targetReservation = $this->lockReservation($target, (int) $sourceReservation->inventory_id);
|
$inventory = $inventories->get($inventoryId)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario requerido.');
|
||||||
|
$previous = (int) ($currentLines->get($inventoryId)?->quantity ?? 0);
|
||||||
|
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||||
|
$delta = $required - $previous;
|
||||||
|
|
||||||
if ($targetReservation === null) {
|
if ($delta > 0
|
||||||
$sourceItemQuantity = (int) $source->cantidad;
|
&& $requirements[$inventoryId]['tracks_inventory']
|
||||||
$targetItemQuantity = (int) $target->fresh()->cantidad;
|
&& $inventory->availableStock() < $delta) {
|
||||||
$perItemQuantity = intdiv((int) $sourceReservation->quantity, $sourceItemQuantity);
|
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar el carrito.');
|
||||||
$sourceReservation->update([
|
}
|
||||||
'cart_item_id' => $target->getKey(),
|
|
||||||
'purchase_id' => null,
|
if ($delta < 0 && $inventory->reserved_stock < abs($delta)) {
|
||||||
'quantity' => $perItemQuantity * $targetItemQuantity,
|
throw new \InvalidArgumentException('La reserva de stock del carrito es inconsistente.');
|
||||||
'expires_at' => $this->expiration(),
|
}
|
||||||
]);
|
}
|
||||||
|
|
||||||
|
foreach ($inventoryIds as $inventoryId) {
|
||||||
|
/** @var Inventory $inventory */
|
||||||
|
$inventory = $inventories->get($inventoryId);
|
||||||
|
$line = $currentLines->get($inventoryId);
|
||||||
|
$previous = (int) ($line?->quantity ?? 0);
|
||||||
|
$required = (int) ($requirements[$inventoryId]['quantity'] ?? 0);
|
||||||
|
$delta = $required - $previous;
|
||||||
|
|
||||||
|
if ($delta > 0) {
|
||||||
|
$inventory->reserve($delta, $requirements[$inventoryId]['tracks_inventory']);
|
||||||
|
} elseif ($delta < 0) {
|
||||||
|
$inventory->release(abs($delta));
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($required === 0) {
|
||||||
|
$line?->delete();
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$targetReservation->update([
|
StockReservationLine::query()->updateOrCreate(
|
||||||
'quantity' => $targetReservation->quantity + $sourceReservation->quantity,
|
[
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
'stock_reservation_id' => $reservation->getKey(),
|
||||||
'expires_at' => $this->expiration(),
|
'inventory_id' => $inventoryId,
|
||||||
]);
|
],
|
||||||
$sourceReservation->delete();
|
[
|
||||||
|
'quantity' => $required,
|
||||||
|
'tracks_inventory' => $requirements[$inventoryId]['tracks_inventory'],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'expires_at' => $this->expiration(),
|
||||||
|
'release_reason' => null,
|
||||||
|
]);
|
||||||
|
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attachToPurchase(
|
||||||
|
Cart $cart,
|
||||||
|
Purchase $purchase,
|
||||||
|
Carbon $expiresAt,
|
||||||
|
): StockReservation {
|
||||||
|
return DB::transaction(function () use ($cart, $purchase, $expiresAt): StockReservation {
|
||||||
|
/** @var Cart $lockedCart */
|
||||||
|
$lockedCart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
/** @var Purchase $lockedPurchase */
|
||||||
|
$lockedPurchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
|
||||||
|
if ($lockedCart->current_stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('El carrito no tiene una reserva de stock activa.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($lockedCart->current_stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
|
||||||
|
$linkedPurchase = Purchase::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
|
->whereKeyNot($lockedPurchase->getKey())
|
||||||
|
->exists();
|
||||||
|
if ($linkedPurchase) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock ya pertenece a otra compra.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lockedPurchase->update(['stock_reservation_id' => $reservation->getKey()]);
|
||||||
|
$reservation->update(['expires_at' => $expiresAt]);
|
||||||
|
$purchase->stock_reservation_id = $reservation->getKey();
|
||||||
|
$cart->current_stock_reservation_id = $reservation->getKey();
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function commit(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($purchase): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($purchase->stock_reservation_id);
|
||||||
|
if ($reservation->status === StockReservation::STATUS_COMMITTED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||||
|
}
|
||||||
|
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $this->lockLines($reservation);
|
||||||
|
if ($lines->isEmpty()) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no tiene inventarios.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$inventories = $this->lockInventories($lines);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventory = $inventories->get($line->inventory_id)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||||
|
if ($inventory->reserved_stock < $line->quantity
|
||||||
|
|| ($line->tracks_inventory && $inventory->real_stock < $line->quantity)) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventories->get($line->inventory_id)->buy(
|
||||||
|
(int) $line->quantity,
|
||||||
|
(bool) $line->tracks_inventory,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update([
|
||||||
|
'status' => StockReservation::STATUS_COMMITTED,
|
||||||
|
'expires_at' => null,
|
||||||
|
'committed_at' => now(),
|
||||||
|
'released_at' => null,
|
||||||
|
'expired_at' => null,
|
||||||
|
'release_reason' => null,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseForPurchase(
|
||||||
|
Purchase $purchase,
|
||||||
|
string $status = StockReservation::STATUS_RELEASED,
|
||||||
|
?string $reason = null,
|
||||||
|
): void {
|
||||||
|
DB::transaction(function () use ($purchase, $status, $reason): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->find($purchase->stock_reservation_id);
|
||||||
|
if ($reservation !== null) {
|
||||||
|
$this->finalizeLocked($reservation, $status, $reason);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordIncrease(CartItem $cartItem, CatalogItem|Variant $selection, int $quantity): void
|
public function returnToCart(Purchase $purchase, Cart $cart): StockReservation
|
||||||
{
|
{
|
||||||
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
return DB::transaction(function () use ($purchase, $cart): StockReservation {
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
/** @var Cart $cart */
|
||||||
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
|
||||||
if ($reservation === null) {
|
if ($purchase->stock_reservation_id === null
|
||||||
StockReservation::query()->create([
|
|| $cart->current_stock_reservation_id !== $purchase->stock_reservation_id) {
|
||||||
'inventory_id' => $inventoryId,
|
throw new \InvalidArgumentException('La compra y el carrito no comparten la reserva activa.');
|
||||||
'cart_item_id' => $cartItem->getKey(),
|
|
||||||
'quantity' => $requiredQuantity,
|
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$reservation->update([
|
/** @var StockReservation $reservation */
|
||||||
'quantity' => ($reservation->status === StockReservation::STATUS_ACTIVE ? $reservation->quantity : 0) + $requiredQuantity,
|
$reservation = StockReservation::query()
|
||||||
'status' => StockReservation::STATUS_ACTIVE,
|
->lockForUpdate()
|
||||||
'committed_at' => null,
|
->findOrFail($purchase->stock_reservation_id);
|
||||||
'released_at' => null,
|
$this->assertUsableCartReservation($reservation);
|
||||||
'expires_at' => $this->expiration(),
|
|
||||||
]);
|
$purchase->update(['stock_reservation_id' => null]);
|
||||||
}
|
$cart->update(['current_purchase_id' => null]);
|
||||||
|
$reservation->update(['expires_at' => $this->expiration()]);
|
||||||
|
|
||||||
|
return $reservation->fresh('lines');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recordDecrease(
|
public function assertCartReservationUsable(Cart $cart): void
|
||||||
CartItem $cartItem,
|
{
|
||||||
CatalogItem|Variant $selection,
|
DB::transaction(function () use ($cart): void {
|
||||||
int $quantity,
|
/** @var Cart $cart */
|
||||||
string $releasedStatus,
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
|
if ($cart->current_stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($cart->current_stock_reservation_id);
|
||||||
|
$this->assertUsableCartReservation($reservation);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function releaseCurrentCartReservation(
|
||||||
|
Cart $cart,
|
||||||
|
string $reason = self::REASON_CART_CHANGED,
|
||||||
): void {
|
): void {
|
||||||
foreach ($this->inventory->requirementsFor($selection, $quantity) as $inventoryId => $requiredQuantity) {
|
DB::transaction(function () use ($cart, $reason): void {
|
||||||
$reservation = $this->lockReservation($cartItem, $inventoryId);
|
/** @var Cart $cart */
|
||||||
if ($reservation === null || $reservation->status !== StockReservation::STATUS_ACTIVE || $reservation->quantity < $requiredQuantity) {
|
$cart = Cart::query()->lockForUpdate()->findOrFail($cart->getKey());
|
||||||
throw new \InvalidArgumentException('La reserva de stock no alcanza para liberar la cantidad solicitada.');
|
if ($cart->current_stock_reservation_id === null) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$remaining = $reservation->quantity - $requiredQuantity;
|
/** @var StockReservation|null $reservation */
|
||||||
$reservation->update([
|
$reservation = StockReservation::query()->lockForUpdate()->find($cart->current_stock_reservation_id);
|
||||||
'quantity' => $remaining,
|
if ($reservation !== null) {
|
||||||
'status' => $remaining === 0 ? $releasedStatus : StockReservation::STATUS_ACTIVE,
|
$this->finalizeLocked($reservation, StockReservation::STATUS_RELEASED, $reason);
|
||||||
'released_at' => $remaining === 0 ? now() : null,
|
}
|
||||||
'expires_at' => $remaining === 0 ? null : $reservation->expires_at,
|
$cart->update(['current_stock_reservation_id' => null]);
|
||||||
]);
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function expire(StockReservation $reservation): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($reservation): void {
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()->lockForUpdate()->findOrFail($reservation->getKey());
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->expires_at === null
|
||||||
|
|| $reservation->expires_at->isFuture()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->finalizeLocked($reservation, StockReservation::STATUS_EXPIRED, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function clearExpirationForReview(Purchase $purchase): void
|
||||||
|
{
|
||||||
|
DB::transaction(function () use ($purchase): void {
|
||||||
|
/** @var Purchase $purchase */
|
||||||
|
$purchase = Purchase::query()->lockForUpdate()->findOrFail($purchase->getKey());
|
||||||
|
if ($purchase->stock_reservation_id === null) {
|
||||||
|
throw new \InvalidArgumentException('La compra no tiene una reserva de stock.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var StockReservation $reservation */
|
||||||
|
$reservation = StockReservation::query()
|
||||||
|
->lockForUpdate()
|
||||||
|
->findOrFail($purchase->stock_reservation_id);
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está activa.');
|
||||||
|
}
|
||||||
|
if ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture()) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
$reservation->update(['expires_at' => null]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, CartItem> $items
|
||||||
|
* @return array<int, array{quantity: int, tracks_inventory: bool}>
|
||||||
|
*/
|
||||||
|
private function requirementsForItems(Collection $items): array
|
||||||
|
{
|
||||||
|
$requirements = [];
|
||||||
|
foreach ($items as $item) {
|
||||||
|
$selection = $item->selectedItem();
|
||||||
|
if ($selection === null) {
|
||||||
|
throw new \InvalidArgumentException('El carrito contiene un item de catálogo inexistente.');
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($this->inventory->detailedRequirementsFor($selection, (int) $item->cantidad) as $inventoryId => $requirement) {
|
||||||
|
if (isset($requirements[$inventoryId])) {
|
||||||
|
$requirements[$inventoryId]['quantity'] += $requirement['quantity'];
|
||||||
|
$requirements[$inventoryId]['tracks_inventory'] =
|
||||||
|
$requirements[$inventoryId]['tracks_inventory'] || $requirement['tracks_inventory'];
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requirements[$inventoryId] = $requirement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ksort($requirements);
|
||||||
|
|
||||||
|
return $requirements;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param Collection<int, CartItem> $items */
|
||||||
|
private function loadSelections(Collection $items): void
|
||||||
|
{
|
||||||
|
$items->load([
|
||||||
|
'catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.catalogItem.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.inventory',
|
||||||
|
'catalogItem.bundleComponents.variant.catalogItem',
|
||||||
|
'variant.inventory',
|
||||||
|
'variant.catalogItem',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Collection<int, StockReservationLine> */
|
||||||
|
private function lockLines(StockReservation $reservation): Collection
|
||||||
|
{
|
||||||
|
return StockReservationLine::query()
|
||||||
|
->where('stock_reservation_id', $reservation->getKey())
|
||||||
|
->orderBy('inventory_id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, StockReservationLine> $lines
|
||||||
|
* @return Collection<int, Inventory>
|
||||||
|
*/
|
||||||
|
private function lockInventories(Collection $lines): Collection
|
||||||
|
{
|
||||||
|
return Inventory::query()
|
||||||
|
->whereKey($lines->pluck('inventory_id'))
|
||||||
|
->orderBy('id')
|
||||||
|
->lockForUpdate()
|
||||||
|
->get()
|
||||||
|
->keyBy('id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function finalizeLocked(
|
||||||
|
StockReservation $reservation,
|
||||||
|
string $status,
|
||||||
|
?string $reason,
|
||||||
|
): void {
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (! in_array($status, [StockReservation::STATUS_RELEASED, StockReservation::STATUS_EXPIRED], true)) {
|
||||||
|
throw new \InvalidArgumentException('El estado final de la reserva no es válido.');
|
||||||
|
}
|
||||||
|
|
||||||
|
$lines = $this->lockLines($reservation);
|
||||||
|
$inventories = $this->lockInventories($lines);
|
||||||
|
foreach ($lines as $line) {
|
||||||
|
$inventory = $inventories->get($line->inventory_id)
|
||||||
|
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||||
|
$inventory->release((int) $line->quantity);
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = now();
|
||||||
|
$reservation->update([
|
||||||
|
'status' => $status,
|
||||||
|
'expires_at' => null,
|
||||||
|
'released_at' => $status === StockReservation::STATUS_RELEASED ? $now : null,
|
||||||
|
'expired_at' => $status === StockReservation::STATUS_EXPIRED ? $now : null,
|
||||||
|
'release_reason' => $status === StockReservation::STATUS_RELEASED ? $reason : null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($status === StockReservation::STATUS_RELEASED) {
|
||||||
|
Cart::query()
|
||||||
|
->where('current_stock_reservation_id', $reservation->getKey())
|
||||||
|
->update(['current_stock_reservation_id' => null]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function lockReservation(CartItem $cartItem, int $inventoryId): ?StockReservation
|
private function assertUsableCartReservation(StockReservation $reservation): void
|
||||||
{
|
{
|
||||||
return StockReservation::query()
|
if ($reservation->status === StockReservation::STATUS_EXPIRED
|
||||||
->where('cart_item_id', $cartItem->getKey())
|
|| ($reservation->expires_at !== null && ! $reservation->expires_at->isFuture())) {
|
||||||
->where('inventory_id', $inventoryId)
|
throw new StockReservationExpiredException;
|
||||||
->lockForUpdate()
|
}
|
||||||
->first();
|
|
||||||
|
if ($reservation->status !== StockReservation::STATUS_ACTIVE
|
||||||
|
|| $reservation->expires_at === null) {
|
||||||
|
throw new \InvalidArgumentException('La reserva de stock no está disponible para operar el carrito.');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function expiration(): Carbon
|
private function expiration(): Carbon
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ use Illuminate\Support\Collection;
|
|||||||
|
|
||||||
class VariantSelectionService
|
class VariantSelectionService
|
||||||
{
|
{
|
||||||
public function __construct(
|
|
||||||
private readonly CatalogItemAllowanceService $allowances,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $selectedValues
|
* @param array<string, mixed> $selectedValues
|
||||||
* @return array<string, mixed>
|
* @return array<string, mixed>
|
||||||
@@ -192,17 +188,13 @@ class VariantSelectionService
|
|||||||
/** @return array<string, mixed> */
|
/** @return array<string, mixed> */
|
||||||
private function variantData(CatalogItem $catalogItem, Variant $variant): array
|
private function variantData(CatalogItem $catalogItem, Variant $variant): array
|
||||||
{
|
{
|
||||||
$availableStock = $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
|
||||||
? null
|
|
||||||
: $variant->inventory?->availableStock();
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'id' => $variant->id,
|
'id' => $variant->id,
|
||||||
'descripcion' => $variant->getDescription(),
|
'descripcion' => $variant->getDescription(),
|
||||||
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
'precio' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
'availability' => $this->allowances
|
'stock_tecnico' => $catalogItem->inventory_policy === InventoryPolicy::Unlimited
|
||||||
->availability($availableStock, null)
|
? null
|
||||||
->toArray(),
|
: $variant->inventory?->availableStock(),
|
||||||
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
'values' => $variant->selectorOptions($catalogItem->itemAttributes),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,81 +0,0 @@
|
|||||||
<?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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
- `CatalogItem` es la raíz del producto y se relaciona con tenant, categoría, marca, inventario, variantes, atributos, adjuntos y grupos destacados.
|
||||||
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
- `Variant`, `ItemAttribute`, `Attribute`, `AttributeOption` y `VariantDefinition` describen opciones comercializables.
|
||||||
- `Inventory` administra stock disponible, reservado y comprado.
|
- `Inventory` administra stock disponible, reservado y comprado.
|
||||||
- `StockReservation` atribuye cada unidad reservada a un ítem de carrito y, durante checkout, a una compra, con estados `active`, `committed`, `released` y `expired`.
|
- `StockReservation` representa la reserva completa de un carrito o checkout, con estados `active`, `committed`, `released` y `expired`. Su `expires_at` es el único reloj del bloqueo. Al expirar, también pasan a `expired` la compra pagable y el carrito asociados dentro de la misma transacción. Sus estados terminales nunca se reactivan ni se reemplazan implícitamente. Sus `StockReservationLine` agregan la cantidad requerida por inventario, incluso cuando varios ítems o bundles consumen el mismo stock.
|
||||||
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
- `Category` soporta jerarquía y categorías globales o propias del tenant.
|
||||||
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
- `FeaturedGroup` y `FeaturedItem` organizan secciones destacadas.
|
||||||
- `BundleComponent` representa los componentes de un paquete.
|
- `BundleComponent` representa los componentes de un paquete.
|
||||||
@@ -18,7 +18,8 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
|
|
||||||
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
- `CatalogService`: alta, búsqueda, detalle, listado por categoría y eliminación.
|
||||||
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
- `CatalogInventoryService`: consulta, reserva, libera y confirma inventario.
|
||||||
- `StockReservationService`: mantiene el ledger de reservas sincronizado con `Inventory.reserved_stock`.
|
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
||||||
|
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
|
||||||
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
||||||
|
|
||||||
|
|||||||
@@ -144,7 +144,6 @@ class InvitationPurchaseProvisioner
|
|||||||
if ($purchaseId !== null) {
|
if ($purchaseId !== null) {
|
||||||
DB::table('compras')->where('id', $purchaseId)->update([
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
'status' => 'paid',
|
'status' => 'paid',
|
||||||
'expires_at' => null,
|
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
@@ -158,7 +157,6 @@ class InvitationPurchaseProvisioner
|
|||||||
'cart_id' => null,
|
'cart_id' => null,
|
||||||
'status' => 'paid',
|
'status' => 'paid',
|
||||||
'payment_method' => self::PAYMENT_METHOD,
|
'payment_method' => self::PAYMENT_METHOD,
|
||||||
'expires_at' => null,
|
|
||||||
'total' => 0,
|
'total' => 0,
|
||||||
'dni' => null,
|
'dni' => null,
|
||||||
'transfer_payer_dni' => null,
|
'transfer_payer_dni' => null,
|
||||||
@@ -392,15 +390,27 @@ class InvitationPurchaseProvisioner
|
|||||||
'sold_units' => $inventory->sold_units + 1,
|
'sold_units' => $inventory->sold_units + 1,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
DB::table('stock_reservations')->insert([
|
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
|
||||||
|
if ($reservationId === null) {
|
||||||
|
$reservationId = DB::table('stock_reservations')->insertGetId([
|
||||||
|
'status' => 'committed',
|
||||||
|
'committed_at' => $now,
|
||||||
|
'released_at' => null,
|
||||||
|
'expired_at' => null,
|
||||||
|
'release_reason' => null,
|
||||||
|
'created_at' => $now,
|
||||||
|
'updated_at' => $now,
|
||||||
|
]);
|
||||||
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::table('stock_reservation_lines')->insert([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
'inventory_id' => $inventory->id,
|
'inventory_id' => $inventory->id,
|
||||||
'cart_item_id' => null,
|
|
||||||
'purchase_id' => $purchaseId,
|
|
||||||
'quantity' => 1,
|
'quantity' => 1,
|
||||||
'status' => 'committed',
|
'tracks_inventory' => true,
|
||||||
'expires_at' => null,
|
|
||||||
'committed_at' => $now,
|
|
||||||
'released_at' => null,
|
|
||||||
'created_at' => $now,
|
'created_at' => $now,
|
||||||
'updated_at' => $now,
|
'updated_at' => $now,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
namespace App\Domains\Integration\Services;
|
namespace App\Domains\Integration\Services;
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
use App\Domains\Client\Models\Client;
|
||||||
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use App\Domains\Tenant\Models\WebsiteType;
|
||||||
use Exception;
|
use Exception;
|
||||||
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
use Illuminate\Contracts\Mail\Factory as MailFactory;
|
||||||
use Illuminate\Contracts\Mail\Mailer;
|
use Illuminate\Contracts\Mail\Mailer;
|
||||||
@@ -70,24 +72,35 @@ class MailService extends BaseIntegrationService
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public function send(string|array $recipient, string $subject, string $content): void
|
/**
|
||||||
{
|
* @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 {
|
||||||
if (! $this->mailer || ! $this->tenant) {
|
if (! $this->mailer || ! $this->tenant) {
|
||||||
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
$brand ??= $this->tenant;
|
||||||
|
$branding = $this->brandingFor($brand);
|
||||||
|
|
||||||
$html = Blade::render(
|
$html = Blade::render(
|
||||||
<<<'BLADE'
|
<<<'BLADE'
|
||||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
<x-mail.branded-layout :branding="$branding" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
||||||
{!! $content !!}
|
{!! $content !!}
|
||||||
</x-mail.branded-layout>
|
</x-mail.branded-layout>
|
||||||
BLADE,
|
BLADE,
|
||||||
[
|
[
|
||||||
'tenant' => $this->tenant,
|
'branding' => $branding,
|
||||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
'headerLogoUrl' => $brand instanceof WebsiteType
|
||||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
? $brand->siteLogo?->getTemporaryUrl(1440)
|
||||||
|
: $brand->headerLogo?->getTemporaryUrl(1440),
|
||||||
|
'footerLogoUrl' => $brand->footerLogo?->getTemporaryUrl(1440),
|
||||||
'content' => $content,
|
'content' => $content,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -96,6 +109,14 @@ class MailService extends BaseIntegrationService
|
|||||||
->subject($subject)
|
->subject($subject)
|
||||||
->html($html);
|
->html($html);
|
||||||
|
|
||||||
|
foreach ($attachments as $attachment) {
|
||||||
|
$mail->attachData(
|
||||||
|
$attachment['data'],
|
||||||
|
$attachment['name'],
|
||||||
|
['mime' => $attachment['mime']],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$this->mailer->to($recipient)->send($mail);
|
$this->mailer->to($recipient)->send($mail);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,6 +127,36 @@ class MailService extends BaseIntegrationService
|
|||||||
: (string) config('mail.default');
|
: (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
|
public function onSetup(): void
|
||||||
{
|
{
|
||||||
if (! $this->mailer || ! $this->clientContext) {
|
if (! $this->mailer || ! $this->clientContext) {
|
||||||
|
|||||||
@@ -28,10 +28,21 @@ class TestMail extends Mailable
|
|||||||
{
|
{
|
||||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
$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(
|
return new Content(
|
||||||
view: 'mail.test',
|
view: 'mail.test',
|
||||||
with: [
|
with: [
|
||||||
'tenant' => $this->tenant,
|
'tenant' => $this->tenant,
|
||||||
|
'branding' => $branding,
|
||||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
||||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?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,8 +6,6 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
|||||||
use App\Domains\Notification\Services\NotificationMailService;
|
use App\Domains\Notification\Services\NotificationMailService;
|
||||||
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
use Illuminate\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
@@ -22,21 +20,10 @@ class SendPasswordResetEmail implements ShouldQueueAfterCommit
|
|||||||
|
|
||||||
public function handle(PasswordResetRequested $event): void
|
public function handle(PasswordResetRequested $event): void
|
||||||
{
|
{
|
||||||
try {
|
app(NotificationMailService::class)->sendPasswordResetCode(
|
||||||
app(NotificationMailService::class)->sendPasswordResetCode(
|
$event->attemptId,
|
||||||
$event->attemptId,
|
$event->tenantCode,
|
||||||
$event->tenantCode,
|
$event->channel,
|
||||||
$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\Contracts\Queue\ShouldQueueAfterCommit;
|
||||||
use Illuminate\Queue\InteractsWithQueue;
|
use Illuminate\Queue\InteractsWithQueue;
|
||||||
|
|
||||||
class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
class SendPurchaseConfirmedEmail implements ShouldQueueAfterCommit
|
||||||
{
|
{
|
||||||
use InteractsWithQueue;
|
use InteractsWithQueue;
|
||||||
|
|
||||||
@@ -20,6 +20,6 @@ class SendPurchasePaidEmail implements ShouldQueueAfterCommit
|
|||||||
|
|
||||||
public function handle(PurchasePaid $event): void
|
public function handle(PurchasePaid $event): void
|
||||||
{
|
{
|
||||||
app(NotificationMailService::class)->sendPurchasePaid($event->purchase->getKey());
|
app(NotificationMailService::class)->sendPurchaseConfirmed($event->purchaseId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?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,28 +9,43 @@ use App\Domains\Notification\Events\PasswordResetRequested;
|
|||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
|
use App\Domains\Ticket\Services\TicketPdfService;
|
||||||
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
use App\Domains\Ticket\Services\TicketPresentationResolver;
|
||||||
|
use Closure;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
class NotificationMailService
|
class NotificationMailService
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
private readonly MailService $mailService,
|
private readonly MailService $mailService,
|
||||||
|
private readonly TicketPdfService $ticketPdfService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function sendWelcome(int $userId, string $tenantCode): void
|
public function sendWelcome(int $userId, string $tenantCode): void
|
||||||
{
|
{
|
||||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
$this->sendLogged('welcome', [
|
||||||
$user = User::query()->findOrFail($userId);
|
'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;
|
||||||
|
|
||||||
$this->mailService
|
$this->mailService
|
||||||
->forTenant($tenantCode)
|
->forTenant($tenantCode)
|
||||||
->send(
|
->send(
|
||||||
$user->email,
|
$user->email,
|
||||||
"Bienvenido a {$tenant->nombre}",
|
"Bienvenido a {$brand->nombre}",
|
||||||
view('mail.notifications.welcome', compact('tenant', 'user'))->render(),
|
view('mail.notifications.welcome', compact('brand', 'user'))->render(),
|
||||||
);
|
$brand,
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'brand_type' => $tenant->websiteType === null ? 'tenant' : 'website_type',
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendPasswordResetCode(
|
public function sendPasswordResetCode(
|
||||||
@@ -38,95 +53,161 @@ class NotificationMailService
|
|||||||
string $tenantCode,
|
string $tenantCode,
|
||||||
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
string $channel = PasswordResetRequested::CHANNEL_STOREFRONT,
|
||||||
): void {
|
): void {
|
||||||
$tenant = Tenant::query()
|
$context = [
|
||||||
->with('websiteType')
|
'attempt_id' => $attemptId,
|
||||||
->where('codigo', $tenantCode)
|
'tenant_code' => $tenantCode,
|
||||||
->firstOrFail();
|
'channel' => $channel,
|
||||||
$attempt = ResetPasswordAttempt::query()
|
];
|
||||||
->with('user')
|
|
||||||
->findOrFail($attemptId);
|
|
||||||
|
|
||||||
if ($attempt->status !== ResetPasswordAttempt::STATUS_PENDING) {
|
$this->sendLogged('password_reset', $context, function () use ($attemptId, $tenantCode, $channel, $context): ?array {
|
||||||
Log::warning('Password reset email was skipped because the attempt is no longer pending.', [
|
$tenant = Tenant::query()
|
||||||
'attempt_id' => $attemptId,
|
->with('websiteType')
|
||||||
'tenant_code' => $tenantCode,
|
->where('codigo', $tenantCode)
|
||||||
'attempt_status' => $attempt->status,
|
->firstOrFail();
|
||||||
]);
|
$attempt = ResetPasswordAttempt::query()
|
||||||
|
->with('user')
|
||||||
|
->findOrFail($attemptId);
|
||||||
|
|
||||||
return;
|
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,
|
||||||
|
]));
|
||||||
|
|
||||||
$recoveryDomain = match ($channel) {
|
return null;
|
||||||
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);
|
|
||||||
|
|
||||||
$this->mailService
|
$recoveryDomain = match ($channel) {
|
||||||
->forTenant($tenantCode)
|
PasswordResetRequested::CHANNEL_ADMINAPP => $tenant->websiteType?->dominio,
|
||||||
->send(
|
PasswordResetRequested::CHANNEL_SCANNER => $tenant->websiteType?->scanner_domain,
|
||||||
$attempt->user->email,
|
default => $tenant->dominio,
|
||||||
"Código para recuperar tu contraseña - {$tenant->nombre}",
|
};
|
||||||
view('mail.notifications.password-reset', compact('tenant', 'attempt', 'recoveryUrl'))->render(),
|
$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,
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function sendPurchasePaid(int $purchaseId): void
|
public function sendPurchaseConfirmed(int $purchaseId): void
|
||||||
{
|
{
|
||||||
$purchase = Purchase::query()
|
$context = ['purchase_id' => $purchaseId];
|
||||||
->with(['tenant', 'user', 'items'])
|
|
||||||
->findOrFail($purchaseId);
|
|
||||||
|
|
||||||
$this->mailService
|
$this->sendLogged('purchase_confirmed', $context, function () use ($purchaseId, $context): ?array {
|
||||||
->forTenant($purchase->tenant_codigo)
|
$purchase = Purchase::query()
|
||||||
->send(
|
->with(['tenant', 'user', 'items'])
|
||||||
$this->recipientFor($purchase),
|
->find($purchaseId);
|
||||||
"Pago confirmado - Compra #{$purchase->getKey()}",
|
|
||||||
view('mail.notifications.purchase-paid', compact('purchase'))->render(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<int, int> $ticketIds */
|
if ($purchase === null) {
|
||||||
public function sendTicketsAvailable(int $purchaseId, array $ticketIds): void
|
$this->logSkipped('purchase_confirmed', array_merge($context, [
|
||||||
{
|
'reason' => 'purchase_not_found',
|
||||||
$purchase = Purchase::query()->with(['tenant', 'user'])->findOrFail($purchaseId);
|
'missing_model' => Purchase::class,
|
||||||
/** @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();
|
|
||||||
|
|
||||||
if ($tickets->isEmpty()) {
|
return null;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$this->mailService
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
->forTenant($purchase->tenant_codigo)
|
$tickets = Ticket::query()
|
||||||
->send(
|
->where('source_purchase_id', $purchase->getKey())
|
||||||
$this->recipientFor($purchase),
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
'Tus tickets ya están disponibles',
|
->with(TicketPresentationResolver::RELATIONS)
|
||||||
view('mail.notifications.tickets-available', compact('purchase', 'tickets'))->render(),
|
->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(),
|
||||||
|
];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function recipientFor(Purchase $purchase): string
|
private function recipientFor(Purchase $purchase): string
|
||||||
{
|
{
|
||||||
return (string) ($purchase->email ?: $purchase->user?->email);
|
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,12 +8,11 @@ Orquesta notificaciones de negocio por correo a partir de eventos de otros domin
|
|||||||
|
|
||||||
- `UserRegistered`: dispara el correo de bienvenida.
|
- `UserRegistered`: dispara el correo de bienvenida.
|
||||||
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
- `PasswordResetRequested`: envía el código de recuperación si el intento sigue pendiente.
|
||||||
- `PurchasePaid`: envía la confirmación de pago.
|
- `PurchasePaid`: envía la confirmación de compra y adjunta los tickets generados, cuando corresponde.
|
||||||
- `TicketsAvailable`: informa y entrega la disponibilidad de tickets.
|
|
||||||
|
|
||||||
## Componentes
|
## Componentes
|
||||||
|
|
||||||
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`.
|
Los listeners `SendWelcomeEmail`, `SendPasswordResetEmail` y `SendPurchaseConfirmedEmail` delegan en `NotificationMailService`. Este servicio carga el contexto necesario, renderiza las vistas y envía mediante `Integration/MailService`.
|
||||||
|
|
||||||
## API y dependencias
|
## API y dependencias
|
||||||
|
|
||||||
@@ -23,4 +22,6 @@ 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.
|
- 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.
|
- 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.
|
- Los handlers deben permanecer idempotentes o tolerantes a reintentos de cola.
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class PurchaseController extends Controller
|
|||||||
|
|
||||||
return PurchaseResource::collection(
|
return PurchaseResource::collection(
|
||||||
Purchase::query()
|
Purchase::query()
|
||||||
|
->with('stockReservation')
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('user_id', $request->user()->id)
|
->where('user_id', $request->user()->id)
|
||||||
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
|
->when($statuses !== [], fn ($query) => $query->whereIn('status', $statuses))
|
||||||
@@ -92,7 +93,6 @@ class PurchaseController extends Controller
|
|||||||
PaymentIntentRequest $request,
|
PaymentIntentRequest $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
Purchase $compra,
|
Purchase $compra,
|
||||||
CheckoutService $checkoutService,
|
|
||||||
PurchaseStateGuard $purchaseState,
|
PurchaseStateGuard $purchaseState,
|
||||||
): JsonResponse {
|
): JsonResponse {
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
||||||
@@ -101,7 +101,12 @@ class PurchaseController extends Controller
|
|||||||
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
|
? preg_replace('/\D+/', '', (string) $request->validated('transfer_payer_dni'))
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
$updated = DB::transaction(function () use ($compra, $method, $purchaseState, $transferPayerDni): bool {
|
$updated = DB::transaction(function () use (
|
||||||
|
$compra,
|
||||||
|
$method,
|
||||||
|
$purchaseState,
|
||||||
|
$transferPayerDni,
|
||||||
|
): bool {
|
||||||
/** @var Purchase|null $purchase */
|
/** @var Purchase|null $purchase */
|
||||||
$purchase = Purchase::query()
|
$purchase = Purchase::query()
|
||||||
->whereKey($compra->getKey())
|
->whereKey($compra->getKey())
|
||||||
@@ -128,9 +133,6 @@ class PurchaseController extends Controller
|
|||||||
$purchaseUpdate = [
|
$purchaseUpdate = [
|
||||||
'payment_method' => $method,
|
'payment_method' => $method,
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
||||||
'expires_at' => now()->addMinutes(
|
|
||||||
max(1, (int) config("purchase.payment_expiration_minutes.{$method}", 30))
|
|
||||||
),
|
|
||||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
'total' => $purchase->calculateCurrentTotalAmount(),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -150,7 +152,6 @@ class PurchaseController extends Controller
|
|||||||
|
|
||||||
$compra->refresh();
|
$compra->refresh();
|
||||||
$totalAmount = (float) $compra->total;
|
$totalAmount = (float) $compra->total;
|
||||||
$checkoutService->syncReservationExpiration($compra);
|
|
||||||
|
|
||||||
if ($method === 'transfer') {
|
if ($method === 'transfer') {
|
||||||
$telepagosService = new TelepagosIntegrationService;
|
$telepagosService = new TelepagosIntegrationService;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Events;
|
namespace App\Domains\Purchase\Events;
|
||||||
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
|
||||||
use Illuminate\Foundation\Events\Dispatchable;
|
use Illuminate\Foundation\Events\Dispatchable;
|
||||||
use Illuminate\Queue\SerializesModels;
|
use Illuminate\Queue\SerializesModels;
|
||||||
|
|
||||||
@@ -10,5 +9,5 @@ class PurchasePaid
|
|||||||
{
|
{
|
||||||
use Dispatchable, SerializesModels;
|
use Dispatchable, SerializesModels;
|
||||||
|
|
||||||
public function __construct(public readonly Purchase $purchase) {}
|
public function __construct(public readonly int $purchaseId) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,11 +19,11 @@ use Illuminate\Support\Facades\DB;
|
|||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'cart_id',
|
'cart_id',
|
||||||
|
'stock_reservation_id',
|
||||||
'tenant_codigo',
|
'tenant_codigo',
|
||||||
'user_id',
|
'user_id',
|
||||||
'status',
|
'status',
|
||||||
'payment_method',
|
'payment_method',
|
||||||
'expires_at',
|
|
||||||
'total',
|
'total',
|
||||||
'dni',
|
'dni',
|
||||||
'transfer_payer_dni',
|
'transfer_payer_dni',
|
||||||
@@ -77,8 +77,8 @@ class Purchase extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'cart_id' => 'integer',
|
'cart_id' => 'integer',
|
||||||
|
'stock_reservation_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
'expires_at' => 'datetime',
|
|
||||||
'total' => 'decimal:2',
|
'total' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -123,10 +123,10 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<StockReservation, $this> */
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
public function stockReservations(): HasMany
|
public function stockReservation(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->hasMany(StockReservation::class);
|
return $this->belongsTo(StockReservation::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -193,7 +193,7 @@ class Purchase extends Model
|
|||||||
'status' => self::STATUS_PAID,
|
'status' => self::STATUS_PAID,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
PurchasePaid::dispatch($this);
|
PurchasePaid::dispatch($this->getKey());
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class PurchaseResource extends JsonResource
|
|||||||
*/
|
*/
|
||||||
public function toArray(Request $request): array
|
public function toArray(Request $request): array
|
||||||
{
|
{
|
||||||
|
$serverTime = now();
|
||||||
|
$expiresAt = $this->stockReservation?->expires_at;
|
||||||
$items = $this->resource->relationLoaded('items')
|
$items = $this->resource->relationLoaded('items')
|
||||||
? $this->resource->getRelation('items')
|
? $this->resource->getRelation('items')
|
||||||
: collect();
|
: collect();
|
||||||
@@ -48,7 +50,11 @@ class PurchaseResource extends JsonResource
|
|||||||
'created_at' => $this->created_at,
|
'created_at' => $this->created_at,
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'payment_method' => $this->payment_method,
|
'payment_method' => $this->payment_method,
|
||||||
'expires_at' => $this->expires_at,
|
'expires_at' => $expiresAt,
|
||||||
|
'expires_in_seconds' => $expiresAt === null
|
||||||
|
? null
|
||||||
|
: max(0, $expiresAt->getTimestamp() - $serverTime->getTimestamp()),
|
||||||
|
'server_time' => $serverTime,
|
||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'transfer_payer_dni' => $this->transfer_payer_dni,
|
'transfer_payer_dni' => $this->transfer_payer_dni,
|
||||||
'telefono' => $this->telefono,
|
'telefono' => $this->telefono,
|
||||||
|
|||||||
@@ -61,10 +61,7 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
$this->purchaseState->lockCurrentCart($purchase);
|
$this->purchaseState->lockCurrentCart($purchase);
|
||||||
|
|
||||||
if (
|
if ($purchase->status !== Purchase::STATUS_PENDING_PAYMENT) {
|
||||||
$purchase->status !== Purchase::STATUS_PENDING_PAYMENT
|
|
||||||
|| ($purchase->expires_at !== null && $purchase->expires_at->isPast())
|
|
||||||
) {
|
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'purchase' => __('api.purchase.not_available_for_review'),
|
'purchase' => __('api.purchase.not_available_for_review'),
|
||||||
]);
|
]);
|
||||||
@@ -72,9 +69,8 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
$purchase->update([
|
$purchase->update([
|
||||||
'status' => Purchase::STATUS_IN_REVIEW,
|
'status' => Purchase::STATUS_IN_REVIEW,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
$this->reservations->syncPurchaseExpiration($purchase);
|
$this->reservations->clearExpirationForReview($purchase);
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
});
|
});
|
||||||
@@ -161,13 +157,14 @@ class CompleteCheckoutService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
}
|
||||||
$this->reservations->commit($cartItem, $selection, $purchase);
|
|
||||||
} catch (\InvalidArgumentException) {
|
try {
|
||||||
throw ValidationException::withMessages([
|
$this->reservations->commit($purchase);
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
} catch (\InvalidArgumentException) {
|
||||||
]);
|
throw ValidationException::withMessages([
|
||||||
}
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->sourceCart->finalize($purchase);
|
$this->sourceCart->finalize($purchase);
|
||||||
@@ -194,7 +191,7 @@ class CompleteCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
private function loadPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['items.imageAttachment']);
|
return $purchase->load(['items.imageAttachment', 'stockReservation']);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function itemKey(int $catalogItemId, ?int $variantId): string
|
private function itemKey(int $catalogItemId, ?int $variantId): string
|
||||||
|
|||||||
@@ -8,6 +8,6 @@ class PurchaseResponseLoader
|
|||||||
{
|
{
|
||||||
public function load(Purchase $purchase): Purchase
|
public function load(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['tenant', 'items.imageAttachment']);
|
return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ namespace App\Domains\Purchase\Services\Checkout;
|
|||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Models\StockReservation;
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
use App\Domains\Catalog\Services\StockReservationService;
|
||||||
|
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class ReleaseCheckoutService
|
class ReleaseCheckoutService
|
||||||
{
|
{
|
||||||
@@ -32,40 +31,6 @@ class ReleaseCheckoutService
|
|||||||
return $this->release($purchase, Purchase::STATUS_EXPIRED);
|
return $this->release($purchase, Purchase::STATUS_EXPIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function expireOverdue(): int
|
|
||||||
{
|
|
||||||
$expiredCount = 0;
|
|
||||||
|
|
||||||
Purchase::query()
|
|
||||||
->whereIn('status', [Purchase::STATUS_CREATED, Purchase::STATUS_PENDING_PAYMENT])
|
|
||||||
->whereNotNull('expires_at')
|
|
||||||
->where('expires_at', '<=', now())
|
|
||||||
->orderBy('id')
|
|
||||||
->eachById(function (Purchase $purchase) use (&$expiredCount): void {
|
|
||||||
try {
|
|
||||||
$purchase = $this->expire($purchase);
|
|
||||||
} catch (Throwable $exception) {
|
|
||||||
Log::channel('commands')->error('Failed to expire overdue purchase.', [
|
|
||||||
'command' => 'reservations:expire',
|
|
||||||
'purchase_id' => $purchase->getKey(),
|
|
||||||
'tenant_codigo' => $purchase->tenant_codigo,
|
|
||||||
'cart_id' => $purchase->cart_id,
|
|
||||||
'status' => $purchase->status,
|
|
||||||
'expires_at' => $purchase->expires_at,
|
|
||||||
'exception' => $exception,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
|
||||||
$expiredCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return $expiredCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function release(
|
private function release(
|
||||||
Purchase $purchase,
|
Purchase $purchase,
|
||||||
string $targetStatus,
|
string $targetStatus,
|
||||||
@@ -78,6 +43,11 @@ class ReleaseCheckoutService
|
|||||||
): Purchase {
|
): Purchase {
|
||||||
$purchase = $this->lockPurchase($purchase);
|
$purchase = $this->lockPurchase($purchase);
|
||||||
|
|
||||||
|
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||||
|
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||||
|
throw new PurchaseExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
if ($purchase->status === Purchase::STATUS_PAID) {
|
if ($purchase->status === Purchase::STATUS_PAID) {
|
||||||
if ($targetStatus === Purchase::STATUS_EXPIRED) {
|
if ($targetStatus === Purchase::STATUS_EXPIRED) {
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
@@ -100,14 +70,31 @@ class ReleaseCheckoutService
|
|||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||||
|
|
||||||
|
if ($targetStatus === Purchase::STATUS_CANCELLED
|
||||||
|
&& $cart?->status === 'active'
|
||||||
|
&& in_array($purchase->status, [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
], true)) {
|
||||||
|
$this->reservations->returnToCart($purchase, $cart);
|
||||||
|
$purchase->update(['status' => Purchase::STATUS_CANCELLED]);
|
||||||
|
|
||||||
|
return $this->loadPurchase($purchase);
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
$targetStatus === Purchase::STATUS_EXPIRED
|
$targetStatus === Purchase::STATUS_EXPIRED
|
||||||
&& ($purchase->expires_at === null || $purchase->expires_at->isFuture())
|
&& (! in_array($purchase->status, [
|
||||||
|
Purchase::STATUS_CREATED,
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
|
], true) || ! $this->hasOverdueActiveReservation($purchase))
|
||||||
) {
|
) {
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->releasePurchaseReservations($purchase, $targetStatus);
|
$this->releasePurchaseReservations($purchase, $targetStatus, $cart);
|
||||||
|
|
||||||
$purchase->update(['status' => $targetStatus]);
|
$purchase->update(['status' => $targetStatus]);
|
||||||
|
|
||||||
@@ -115,60 +102,67 @@ class ReleaseCheckoutService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function releasePurchaseReservations(Purchase $purchase, string $targetStatus): void
|
private function releasePurchaseReservations(
|
||||||
{
|
Purchase $purchase,
|
||||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
string $targetStatus,
|
||||||
|
?Cart $cart,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
$this->reservations->releaseForPurchase(
|
||||||
|
$purchase,
|
||||||
|
$targetStatus === Purchase::STATUS_EXPIRED
|
||||||
|
? StockReservation::STATUS_EXPIRED
|
||||||
|
: StockReservation::STATUS_RELEASED,
|
||||||
|
$targetStatus === Purchase::STATUS_CANCELLED
|
||||||
|
? StockReservationService::REASON_PURCHASE_CANCELLED
|
||||||
|
: ($targetStatus === Purchase::STATUS_REJECTED
|
||||||
|
? StockReservationService::REASON_PAYMENT_REJECTED
|
||||||
|
: null),
|
||||||
|
);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
throw ValidationException::withMessages([
|
||||||
|
'items' => __('api.purchase.inconsistent_reservation'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
if ($cart === null) {
|
if ($cart === null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cart->status === 'active') {
|
if ($targetStatus === Purchase::STATUS_EXPIRED
|
||||||
$this->reservations->detachFromPurchase($purchase);
|
&& in_array($cart->status, [Cart::STATUS_ACTIVE, Cart::STATUS_CHECKOUT], true)) {
|
||||||
Cart::query()
|
Cart::query()
|
||||||
->whereKey($cart->getKey())
|
->whereKey($cart->getKey())
|
||||||
->where('current_purchase_id', $purchase->getKey())
|
->where('current_purchase_id', $purchase->getKey())
|
||||||
->update(['current_purchase_id' => null]);
|
->where('current_stock_reservation_id', $purchase->stock_reservation_id)
|
||||||
|
->update([
|
||||||
return;
|
'status' => Cart::STATUS_EXPIRED,
|
||||||
}
|
'current_purchase_id' => null,
|
||||||
|
|
||||||
if ($cart->status !== 'checkout') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
|
||||||
$cartItems->load([
|
|
||||||
'catalogItem.inventory',
|
|
||||||
'catalogItem.bundleComponents.catalogItem.inventory',
|
|
||||||
'catalogItem.bundleComponents.variant.inventory',
|
|
||||||
'variant.inventory',
|
|
||||||
'variant.catalogItem',
|
|
||||||
]);
|
|
||||||
|
|
||||||
foreach ($cartItems as $cartItem) {
|
|
||||||
$selection = $cartItem->selectedItem();
|
|
||||||
if ($selection === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
$this->reservations->release(
|
|
||||||
$cartItem,
|
|
||||||
$selection,
|
|
||||||
(int) $cartItem->cantidad,
|
|
||||||
$targetStatus === Purchase::STATUS_EXPIRED
|
|
||||||
? StockReservation::STATUS_EXPIRED
|
|
||||||
: StockReservation::STATUS_RELEASED,
|
|
||||||
);
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'items' => __('api.purchase.inconsistent_reservation'),
|
|
||||||
]);
|
]);
|
||||||
}
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart->status === Cart::STATUS_ACTIVE) {
|
||||||
|
$cartUpdate = [
|
||||||
|
'current_purchase_id' => null,
|
||||||
|
'current_stock_reservation_id' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
Cart::query()
|
||||||
|
->whereKey($cart->getKey())
|
||||||
|
->where('current_purchase_id', $purchase->getKey())
|
||||||
|
->update($cartUpdate);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart->status !== Cart::STATUS_CHECKOUT) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! $cart->trashed()) {
|
if (! $cart->trashed()) {
|
||||||
$cart->update(['status' => 'converted']);
|
$cart->update(['status' => Cart::STATUS_CONVERTED]);
|
||||||
$cart->delete();
|
$cart->delete();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -218,6 +212,17 @@ class ReleaseCheckoutService
|
|||||||
|
|
||||||
private function loadPurchase(Purchase $purchase): Purchase
|
private function loadPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $purchase->load(['items.imageAttachment']);
|
return $purchase->load(['items.imageAttachment', 'stockReservation']);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hasOverdueActiveReservation(Purchase $purchase): bool
|
||||||
|
{
|
||||||
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = $purchase->stockReservation()->lockForUpdate()->first();
|
||||||
|
|
||||||
|
return $reservation !== null
|
||||||
|
&& $reservation->status === StockReservation::STATUS_ACTIVE
|
||||||
|
&& $reservation->expires_at !== null
|
||||||
|
&& ! $reservation->expires_at->isFuture();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Services\Checkout;
|
|||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Cart\Models\CartItem;
|
use App\Domains\Cart\Models\CartItem;
|
||||||
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||||
@@ -12,6 +13,7 @@ use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
|||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -169,21 +171,31 @@ class StartCheckoutService
|
|||||||
'cantidad' => $line['quantity'],
|
'cantidad' => $line['quantity'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
try {
|
|
||||||
$this->reservations->reserve($cartItem, $line['selection'], $line['quantity']);
|
|
||||||
} catch (\InvalidArgumentException) {
|
|
||||||
$availableQuantity = $this->inventory->availableQuantity($line['selection']) ?? 0;
|
|
||||||
|
|
||||||
throw new InsufficientStockException([
|
|
||||||
$this->unavailableItem($line, $availableQuantity),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
$cartItem->setRelation('catalogItem', $line['catalog_item']);
|
$cartItem->setRelation('catalogItem', $line['catalog_item']);
|
||||||
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
|
$cartItem->setRelation('variant', $line['selection'] instanceof Variant ? $line['selection'] : null);
|
||||||
$cartItems->push($cartItem);
|
$cartItems->push($cartItem);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->reservations->syncCart($cart);
|
||||||
|
} catch (\InvalidArgumentException) {
|
||||||
|
$unavailable = $resolvedLines
|
||||||
|
->map(function (array $line): ?array {
|
||||||
|
$availableQuantity = $this->inventory->availableQuantity($line['selection']);
|
||||||
|
|
||||||
|
return $availableQuantity !== null && $availableQuantity < $line['quantity']
|
||||||
|
? $this->unavailableItem($line, $availableQuantity)
|
||||||
|
: null;
|
||||||
|
})
|
||||||
|
->filter()
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
|
||||||
|
throw new InsufficientStockException($unavailable !== [] ? $unavailable : [
|
||||||
|
$this->unavailableItem($resolvedLines->first(), 0),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
$purchase = $this->createPurchase(
|
$purchase = $this->createPurchase(
|
||||||
$tenant,
|
$tenant,
|
||||||
$userId,
|
$userId,
|
||||||
@@ -194,19 +206,12 @@ class StartCheckoutService
|
|||||||
$cart->getKey(),
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||||
|
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
|
||||||
|
|
||||||
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
$cartItems = $cart->items()->orderBy('id')->lockForUpdate()->get();
|
||||||
$this->loadCartItems($cartItems);
|
$this->loadCartItems($cartItems);
|
||||||
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
||||||
|
|
||||||
foreach ($cartItems as $index => $cartItem) {
|
|
||||||
$this->reservations->attachToPurchase(
|
|
||||||
$cartItem,
|
|
||||||
$resolvedLines->get($index)['selection'],
|
|
||||||
$purchase,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,6 +262,7 @@ class StartCheckoutService
|
|||||||
$this->verifyTenantItems($tenant, $cartItems);
|
$this->verifyTenantItems($tenant, $cartItems);
|
||||||
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
|
$this->assertCartPurchaseLimits($tenant, $userId, $cartItems, $cart->getKey());
|
||||||
$cart->setRelation('items', $cartItems);
|
$cart->setRelation('items', $cartItems);
|
||||||
|
$this->reservations->syncCart($cart);
|
||||||
|
|
||||||
$purchase = $this->createPurchase(
|
$purchase = $this->createPurchase(
|
||||||
$tenant,
|
$tenant,
|
||||||
@@ -266,16 +272,9 @@ class StartCheckoutService
|
|||||||
$cart->getKey(),
|
$cart->getKey(),
|
||||||
);
|
);
|
||||||
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
$cart->update(['current_purchase_id' => $purchase->getKey()]);
|
||||||
|
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
|
||||||
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
$purchase->items()->createMany($this->snapshots->fromCartItems($cartItems));
|
||||||
|
|
||||||
foreach ($cartItems as $cartItem) {
|
|
||||||
$this->reservations->attachToPurchase(
|
|
||||||
$cartItem,
|
|
||||||
$cartItem->selectedItem(),
|
|
||||||
$purchase,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
return $this->loadPurchase($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,9 +315,9 @@ class StartCheckoutService
|
|||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
], true)) {
|
], true)) {
|
||||||
|
$this->reservations->returnToCart($currentPurchase, $cart);
|
||||||
$currentPurchase->update([
|
$currentPurchase->update([
|
||||||
'status' => Purchase::STATUS_SUPERSEDED,
|
'status' => Purchase::STATUS_SUPERSEDED,
|
||||||
'expires_at' => null,
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -331,7 +330,11 @@ class StartCheckoutService
|
|||||||
throw new NotFoundHttpException('Cart not found for tenant.');
|
throw new NotFoundHttpException('Cart not found for tenant.');
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($cart->status !== 'active') {
|
if ($cart->status === Cart::STATUS_EXPIRED) {
|
||||||
|
throw new StockReservationExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($cart->status !== Cart::STATUS_ACTIVE) {
|
||||||
throw ValidationException::withMessages([
|
throw ValidationException::withMessages([
|
||||||
'cart_id' => __('api.purchase.inactive_cart'),
|
'cart_id' => __('api.purchase.inactive_cart'),
|
||||||
]);
|
]);
|
||||||
@@ -405,13 +408,17 @@ class StartCheckoutService
|
|||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'status' => Purchase::STATUS_CREATED,
|
'status' => Purchase::STATUS_CREATED,
|
||||||
'payment_method' => null,
|
'payment_method' => null,
|
||||||
'expires_at' => now()->addMinutes(
|
|
||||||
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
|
||||||
),
|
|
||||||
'total' => $total,
|
'total' => $total,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function checkoutExpiration(): Carbon
|
||||||
|
{
|
||||||
|
return now()->addMinutes(
|
||||||
|
max(1, (int) config('purchase.checkout_expiration_minutes', 30)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** @param Collection<int, CartItem> $cartItems */
|
/** @param Collection<int, CartItem> $cartItems */
|
||||||
private function loadCartItems(Collection $cartItems): void
|
private function loadCartItems(Collection $cartItems): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
use App\Domains\Catalog\Services\StockReservationService;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\CompleteCheckoutService;
|
||||||
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
use App\Domains\Purchase\Services\Checkout\EditCheckoutService;
|
||||||
@@ -23,7 +22,6 @@ class CheckoutService
|
|||||||
private readonly EditCheckoutService $editor,
|
private readonly EditCheckoutService $editor,
|
||||||
private readonly CompleteCheckoutService $completer,
|
private readonly CompleteCheckoutService $completer,
|
||||||
private readonly ReleaseCheckoutService $releaser,
|
private readonly ReleaseCheckoutService $releaser,
|
||||||
private readonly StockReservationService $reservations,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @param array<string, mixed> $purchaseData */
|
/** @param array<string, mixed> $purchaseData */
|
||||||
@@ -77,14 +75,4 @@ class CheckoutService
|
|||||||
{
|
{
|
||||||
return $this->releaser->expire($purchase);
|
return $this->releaser->expire($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function expireOverduePurchases(): int
|
|
||||||
{
|
|
||||||
return $this->releaser->expireOverdue();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function syncReservationExpiration(Purchase $purchase): void
|
|
||||||
{
|
|
||||||
$this->reservations->syncPurchaseExpiration($purchase);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace App\Domains\Purchase\Services;
|
namespace App\Domains\Purchase\Services;
|
||||||
|
|
||||||
use App\Domains\Cart\Models\Cart;
|
use App\Domains\Cart\Models\Cart;
|
||||||
|
use App\Domains\Catalog\Models\StockReservation;
|
||||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use Illuminate\Validation\ValidationException;
|
use Illuminate\Validation\ValidationException;
|
||||||
@@ -11,15 +12,32 @@ class PurchaseStateGuard
|
|||||||
{
|
{
|
||||||
public function assertNotExpired(Purchase $purchase): void
|
public function assertNotExpired(Purchase $purchase): void
|
||||||
{
|
{
|
||||||
$hasExpiredStatus = $purchase->status === Purchase::STATUS_EXPIRED;
|
if ($purchase->status === Purchase::STATUS_EXPIRED) {
|
||||||
$hasExpiredByTime = in_array($purchase->status, [
|
throw new PurchaseExpiredException;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array($purchase->status, [
|
||||||
Purchase::STATUS_CREATED,
|
Purchase::STATUS_CREATED,
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
], true)
|
], true)) {
|
||||||
&& $purchase->expires_at !== null
|
return;
|
||||||
&& $purchase->expires_at->isPast();
|
}
|
||||||
|
|
||||||
if ($hasExpiredStatus || $hasExpiredByTime) {
|
/** @var StockReservation|null $reservation */
|
||||||
|
$reservation = $purchase->relationLoaded('stockReservation')
|
||||||
|
? $purchase->getRelation('stockReservation')
|
||||||
|
: ($purchase->exists
|
||||||
|
? $purchase->stockReservation()->first()
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if ($reservation !== null && (
|
||||||
|
$reservation->status === StockReservation::STATUS_EXPIRED
|
||||||
|
|| (
|
||||||
|
$reservation->status === StockReservation::STATUS_ACTIVE
|
||||||
|
&& $reservation->expires_at !== null
|
||||||
|
&& ! $reservation->expires_at->isFuture()
|
||||||
|
)
|
||||||
|
)) {
|
||||||
throw new PurchaseExpiredException;
|
throw new PurchaseExpiredException;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,11 +135,25 @@ class TenantTransactionResetService
|
|||||||
*/
|
*/
|
||||||
private function reservationQuery(array $scope): Builder
|
private function reservationQuery(array $scope): Builder
|
||||||
{
|
{
|
||||||
|
$reservationIds = DB::table('carritos')
|
||||||
|
->whereIn('id', $scope['cart_ids'])
|
||||||
|
->whereNotNull('current_stock_reservation_id')
|
||||||
|
->pluck('current_stock_reservation_id')
|
||||||
|
->merge(
|
||||||
|
DB::table('compras')
|
||||||
|
->whereIn('id', $scope['purchase_ids'])
|
||||||
|
->whereNotNull('stock_reservation_id')
|
||||||
|
->pluck('stock_reservation_id'),
|
||||||
|
)
|
||||||
|
->merge(
|
||||||
|
DB::table('stock_reservation_lines')
|
||||||
|
->whereIn('inventory_id', $scope['inventory_ids'])
|
||||||
|
->pluck('stock_reservation_id'),
|
||||||
|
)
|
||||||
|
->unique()
|
||||||
|
->values();
|
||||||
|
|
||||||
return DB::table('stock_reservations')
|
return DB::table('stock_reservations')
|
||||||
->where(function (Builder $query) use ($scope): void {
|
->whereIn('id', $reservationIds);
|
||||||
$query->whereIn('inventory_id', $scope['inventory_ids'])
|
|
||||||
->orWhereIn('purchase_id', $scope['purchase_ids'])
|
|
||||||
->orWhereIn('cart_item_id', $scope['cart_item_ids']);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,9 +88,9 @@ class UserPurchaseLimitService
|
|||||||
$excludedCartId !== null,
|
$excludedCartId !== null,
|
||||||
fn ($query) => $query->whereKeyNot($excludedCartId),
|
fn ($query) => $query->whereKeyNot($excludedCartId),
|
||||||
))
|
))
|
||||||
->whereHas('stockReservations', fn ($query) => $query
|
->whereHas('cart.currentStockReservation', fn ($query) => $query
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->whereNull('purchase_id'))
|
->whereDoesntHave('purchase'))
|
||||||
->sum('cantidad');
|
->sum('cantidad');
|
||||||
|
|
||||||
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
|
if ($purchasedQuantity + $checkoutQuantity + $reservedCartQuantity + $requestedQuantity > $limit) {
|
||||||
@@ -161,9 +161,9 @@ class UserPurchaseLimitService
|
|||||||
->whereHas('cart', fn ($query) => $query
|
->whereHas('cart', fn ($query) => $query
|
||||||
->where('user_id', $userId)
|
->where('user_id', $userId)
|
||||||
->where('status', 'active'))
|
->where('status', 'active'))
|
||||||
->whereHas('stockReservations', fn ($query) => $query
|
->whereHas('cart.currentStockReservation', fn ($query) => $query
|
||||||
->where('status', 'active')
|
->where('status', 'active')
|
||||||
->whereNull('purchase_id'))
|
->whereDoesntHave('purchase'))
|
||||||
->groupBy('catalog_item_id')
|
->groupBy('catalog_item_id')
|
||||||
->pluck('quantity', 'catalog_item_id');
|
->pluck('quantity', 'catalog_item_id');
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
|
|||||||
|
|
||||||
## Modelo
|
## Modelo
|
||||||
|
|
||||||
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`.
|
- `Purchase`: raíz de la compra; estados `created`, `pending_payment`, `in_review`, `paid`, `cancelled`, `rejected` y `expired`, y referencia la reserva que respaldó ese intento de checkout. No guarda un vencimiento propio: expira como consecuencia del vencimiento de su reserva.
|
||||||
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
|
- `PurchaseItem`: snapshot definitivo del producto o variante, creado recién al confirmar la compra.
|
||||||
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
- `TelepagosQr` y `TelepagosPayment`: datos del QR e intentos/resultados del proveedor.
|
||||||
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
- `PurchasePaid`: evento emitido una sola vez al pasar a pagada bajo bloqueo transaccional.
|
||||||
@@ -15,16 +15,16 @@ Implementa el ciclo de compra y checkout: crea la cabecera de compra desde un ca
|
|||||||
|
|
||||||
`CheckoutService` es la fachada estable. Delega en:
|
`CheckoutService` es la fachada estable. Delega en:
|
||||||
|
|
||||||
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, sin crear todavía `PurchaseItem`.
|
- `StartCheckoutService`: inicia la compra desde el carrito o crea un carrito técnico para compra directa, refresca el vencimiento de la reserva agregada y crea los snapshots `PurchaseItem`.
|
||||||
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
|
- `EditCheckoutService`: modifica los datos del comprador antes del cierre.
|
||||||
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
|
- `CompleteCheckoutService`: completa, envía a revisión o materializa los `PurchaseItem` al confirmar el pago.
|
||||||
- `ReleaseCheckoutService`: cancela, vence y procesa vencimientos pendientes.
|
- `ReleaseCheckoutService`: cancela o vence una compra y aplica sus efectos comerciales; el scanner unificado del dominio Catalog detecta las reservas pendientes de vencimiento.
|
||||||
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
|
- `SourceCartService`: sincroniza o finaliza el carrito de checkout asociado a la compra.
|
||||||
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
- `CatalogSelectionResolver` y `PurchaseItemSnapshotFactory`: resuelven selecciones y generan snapshots.
|
||||||
|
|
||||||
Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y deja de vencer. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
|
Al iniciar checkout o elegir un medio de pago se refresca directamente `StockReservation.expires_at`, que es la única fuente de verdad y se expone como `expires_at` en la respuesta pública de la compra. El refresco sólo se permite mientras la reserva siga vigente; una fecha vencida bloquea todas las mutaciones aun antes de que corra el scheduler. Al materializar la expiración, la compra pagable, su carrito y la reserva pasan a `expired` dentro de la misma transacción. Al cancelar o reemplazar una compra recuperable, ésta se desvincula y el carrito conserva la misma reserva activa. Al informar una transferencia, la compra pasa de `pending_payment` a `in_review` y ese vencimiento se limpia. Si el comprador abandona el checkout durante la revisión, la compra y sus reservas permanecen intactas y se crea un carrito activo nuevo para que pueda seguir comprando. Adminapp puede confirmar o anular explícitamente la compra en revisión.
|
||||||
|
|
||||||
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, recalcula el total y renueva la reserva; Purchase no expone operaciones sobre líneas antes de la confirmación.
|
Las cantidades y variantes se editan mediante el dominio Cart. El endpoint autenticado `PATCH /checkout-carts/{cart}/items/{cartItem}` valida que el carrito pertenezca al usuario y a una compra editable. Cuando existe un cambio real, invalida atómicamente el intento de pago anterior, devuelve la misma reserva activa al carrito y sincroniza sus líneas con el contenido actualizado; Purchase no expone operaciones sobre líneas antes de la confirmación.
|
||||||
|
|
||||||
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
`UserPurchaseLimitService` controla límites de compra y `CheckoutService` conserva el punto de entrada para controladores e integraciones.
|
||||||
|
|
||||||
|
|||||||
@@ -9,18 +9,21 @@ use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
|||||||
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
|
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
|
||||||
use App\Domains\Sale\Resources\AdminApp\SaleResource;
|
use App\Domains\Sale\Resources\AdminApp\SaleResource;
|
||||||
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
|
use App\Domains\Sale\Resources\AdminApp\SaleTicketResource;
|
||||||
|
use App\Domains\Sale\Services\AdminAppSaleExcelService;
|
||||||
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
use App\Domains\Sale\Services\AdminAppSalePdfService;
|
||||||
use App\Domains\Sale\Services\AdminAppSaleService;
|
use App\Domains\Sale\Services\AdminAppSaleService;
|
||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
|
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||||
|
|
||||||
class SaleController extends Controller
|
class SaleController extends Controller
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
protected AdminAppSaleService $saleService,
|
protected AdminAppSaleService $saleService,
|
||||||
protected AdminAppSalePdfService $salePdfService,
|
protected AdminAppSalePdfService $salePdfService,
|
||||||
|
protected AdminAppSaleExcelService $saleExcelService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
|
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
|
||||||
@@ -94,4 +97,27 @@ class SaleController extends Controller
|
|||||||
$request->validated('timezone'),
|
$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'),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
211
app/Domains/Sale/Services/AdminAppSaleExcelService.php
Normal file
211
app/Domains/Sale/Services/AdminAppSaleExcelService.php
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
<?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,6 +8,7 @@ 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.
|
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
||||||
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
- `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.
|
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
|
||||||
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
||||||
- `SaleController`: entrada HTTP del panel.
|
- `SaleController`: entrada HTTP del panel.
|
||||||
@@ -16,8 +17,8 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
|||||||
|
|
||||||
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
|
Bajo `/v1/adminapp/tenant`, protegidos por `auth:sanctum` y `adminapp.tenant`:
|
||||||
|
|
||||||
- `GET /sales` y `GET /sales/pdf`.
|
- `GET /sales`, `GET /sales/pdf` y `GET /sales/excel`.
|
||||||
- `GET /sales/modifications` y `GET /sales/modifications/pdf`.
|
- `GET /sales/modifications`, `GET /sales/modifications/pdf` y `GET /sales/modifications/excel`.
|
||||||
|
|
||||||
## Dependencias
|
## Dependencias
|
||||||
|
|
||||||
@@ -25,4 +26,4 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
|||||||
|
|
||||||
## Consideraciones
|
## Consideraciones
|
||||||
|
|
||||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla y PDF.
|
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('sales', [SaleController::class, 'index']);
|
Route::get('sales', [SaleController::class, 'index']);
|
||||||
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
|
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', [SaleController::class, 'modifications']);
|
||||||
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
|
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}/confirm', [SaleController::class, 'confirm'])->whereNumber('sale');
|
||||||
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
|
Route::post('sales/{sale}/cancel', [SaleController::class, 'cancel'])->whereNumber('sale');
|
||||||
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');
|
Route::get('sales/{sale}/tickets', [SaleController::class, 'tickets'])->whereNumber('sale');
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ class WebsiteTypeService
|
|||||||
&& $previousLogo->id !== $websiteType->site_logo
|
&& $previousLogo->id !== $websiteType->site_logo
|
||||||
&& $previousLogo->id !== $websiteType->footer_logo
|
&& $previousLogo->id !== $websiteType->footer_logo
|
||||||
&& $previousLogo->id !== $websiteType->favicon_id
|
&& $previousLogo->id !== $websiteType->favicon_id
|
||||||
|
&& ! $this->isReferencedByWebsiteType($previousLogo)
|
||||||
) {
|
) {
|
||||||
$this->attachmentService->delete($previousLogo);
|
$this->attachmentService->delete($previousLogo);
|
||||||
}
|
}
|
||||||
@@ -90,4 +91,16 @@ class WebsiteTypeService
|
|||||||
return $websiteType;
|
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;
|
namespace App\Domains\Ticket\Listeners;
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
use App\Domains\Ticket\Exceptions\TicketGenerationException;
|
||||||
use App\Domains\Ticket\Services\TicketGeneratorService;
|
use App\Domains\Ticket\Services\TicketGeneratorService;
|
||||||
|
|
||||||
@@ -16,13 +16,10 @@ class GenerateTicketsForPaidPurchase
|
|||||||
|
|
||||||
public function handle(PurchasePaid $event): void
|
public function handle(PurchasePaid $event): void
|
||||||
{
|
{
|
||||||
$purchase = $event->purchase
|
$purchase = Purchase::query()
|
||||||
->newQuery()
|
|
||||||
->with(['user', 'items'])
|
->with(['user', 'items'])
|
||||||
->findOrFail($event->purchase->getKey());
|
->findOrFail($event->purchaseId);
|
||||||
$user = $purchase->user;
|
$user = $purchase->user;
|
||||||
$ticketIds = [];
|
|
||||||
|
|
||||||
foreach ($purchase->items as $purchaseItem) {
|
foreach ($purchase->items as $purchaseItem) {
|
||||||
$catalogItem = CatalogItem::query()
|
$catalogItem = CatalogItem::query()
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
@@ -40,19 +37,13 @@ class GenerateTicketsForPaidPurchase
|
|||||||
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
throw TicketGenerationException::purchaseWithoutUser($purchase);
|
||||||
}
|
}
|
||||||
|
|
||||||
$generatedTickets = $this->ticketGenerator->generate(
|
$this->ticketGenerator->generate(
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$user,
|
$user,
|
||||||
$purchaseItem->cantidad,
|
$purchaseItem->cantidad,
|
||||||
$purchaseItem->source_variant_id,
|
$purchaseItem->source_variant_id,
|
||||||
$purchase->getKey(),
|
$purchase->getKey(),
|
||||||
);
|
);
|
||||||
|
|
||||||
array_push($ticketIds, ...$generatedTickets->pluck('id')->all());
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($ticketIds !== []) {
|
|
||||||
TicketsAvailable::dispatch($purchase, $ticketIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ namespace App\Domains\Ticket\Services;
|
|||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
use App\Domains\Ticket\Models\Ticket;
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
use Barryvdh\DomPDF\Facade\Pdf;
|
||||||
|
use Barryvdh\DomPDF\PDF as DomPdf;
|
||||||
use Endroid\QrCode\ErrorCorrectionLevel;
|
use Endroid\QrCode\ErrorCorrectionLevel;
|
||||||
use Endroid\QrCode\QrCode;
|
use Endroid\QrCode\QrCode;
|
||||||
use Endroid\QrCode\Writer\PngWriter;
|
use Endroid\QrCode\Writer\PngWriter;
|
||||||
@@ -19,12 +20,36 @@ class TicketPdfService
|
|||||||
* @param Collection<int, Ticket> $tickets
|
* @param Collection<int, Ticket> $tickets
|
||||||
*/
|
*/
|
||||||
public function download(Tenant $tenant, Collection $tickets): Response
|
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');
|
$tenant->loadMissing('headerLogo');
|
||||||
$primaryColor = $this->color($tenant->primary_color, '#009933');
|
$primaryColor = $this->color($tenant->primary_color, '#009933');
|
||||||
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
|
$headerBackgroundColor = $this->color($tenant->header_bg_color, $primaryColor);
|
||||||
|
|
||||||
$pdf = Pdf::loadView('pdf.tickets', [
|
return Pdf::loadView('pdf.tickets', [
|
||||||
'tenant' => $tenant,
|
'tenant' => $tenant,
|
||||||
'tickets' => $tickets,
|
'tickets' => $tickets,
|
||||||
'logoDataUri' => $this->logoDataUri($tenant),
|
'logoDataUri' => $this->logoDataUri($tenant),
|
||||||
@@ -35,10 +60,6 @@ class TicketPdfService
|
|||||||
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
|
fn (Ticket $ticket): array => [$ticket->id => $this->qrCodeDataUri($ticket->ticket)]
|
||||||
),
|
),
|
||||||
])->setPaper('a4');
|
])->setPaper('a4');
|
||||||
|
|
||||||
$ticketIds = $tickets->pluck('id')->implode('_');
|
|
||||||
|
|
||||||
return $pdf->download("tickets_{$ticketIds}.pdf");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private function qrCodeDataUri(string $value): string
|
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.
|
1. `Purchase` emite `PurchasePaid` al confirmarse el pago.
|
||||||
2. `GenerateTicketsForPaidPurchase` atiende el evento.
|
2. `GenerateTicketsForPaidPurchase` atiende el evento.
|
||||||
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
3. `TicketGeneratorService` crea los tickets requeridos según ítems, cantidades y vigencia.
|
||||||
4. El flujo puede emitir disponibilidad para que `Notification` informe al comprador.
|
4. `Notification` envía la confirmación de compra después de la generación y adjunta los tickets cuando existen.
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
|
|||||||
@@ -3,11 +3,9 @@
|
|||||||
namespace App\Providers;
|
namespace App\Providers;
|
||||||
|
|
||||||
use App\Domains\Notification\Events\PasswordResetRequested;
|
use App\Domains\Notification\Events\PasswordResetRequested;
|
||||||
use App\Domains\Notification\Events\TicketsAvailable;
|
|
||||||
use App\Domains\Notification\Events\UserRegistered;
|
use App\Domains\Notification\Events\UserRegistered;
|
||||||
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
use App\Domains\Notification\Listeners\SendPasswordResetEmail;
|
||||||
use App\Domains\Notification\Listeners\SendPurchasePaidEmail;
|
use App\Domains\Notification\Listeners\SendPurchaseConfirmedEmail;
|
||||||
use App\Domains\Notification\Listeners\SendTicketsAvailableEmail;
|
|
||||||
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
use App\Domains\Notification\Listeners\SendWelcomeEmail;
|
||||||
use App\Domains\Purchase\Events\PurchasePaid;
|
use App\Domains\Purchase\Events\PurchasePaid;
|
||||||
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
use App\Domains\Ticket\Listeners\GenerateTicketsForPaidPurchase;
|
||||||
@@ -33,9 +31,8 @@ class AppServiceProvider extends ServiceProvider
|
|||||||
*/
|
*/
|
||||||
public function boot(): void
|
public function boot(): void
|
||||||
{
|
{
|
||||||
Event::listen(PurchasePaid::class, SendPurchasePaidEmail::class);
|
|
||||||
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
Event::listen(PurchasePaid::class, GenerateTicketsForPaidPurchase::class);
|
||||||
Event::listen(TicketsAvailable::class, SendTicketsAvailableEmail::class);
|
Event::listen(PurchasePaid::class, SendPurchaseConfirmedEmail::class);
|
||||||
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
Event::listen(UserRegistered::class, SendWelcomeEmail::class);
|
||||||
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
Event::listen(PasswordResetRequested::class, SendPasswordResetEmail::class);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Auth\Exceptions\AccountLockedException;
|
use App\Domains\Auth\Exceptions\AccountLockedException;
|
||||||
use App\Domains\Catalog\Services\CatalogItemAllowanceService;
|
use App\Domains\Catalog\Exceptions\StockReservationExpiredException;
|
||||||
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
use App\Domains\Purchase\Exceptions\InsufficientStockException;
|
||||||
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
use App\Domains\Purchase\Exceptions\PurchaseExpiredException;
|
||||||
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
|
use App\Domains\Purchase\Exceptions\PurchaseLimitExceededException;
|
||||||
@@ -101,11 +101,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'errors' => $exception->errors(),
|
'errors' => $exception->errors(),
|
||||||
'catalog_item_id' => $exception->catalogItemId,
|
'catalog_item_id' => $exception->catalogItemId,
|
||||||
'catalog_item_name' => $exception->catalogItemName,
|
'catalog_item_name' => $exception->catalogItemName,
|
||||||
'availability' => app(CatalogItemAllowanceService::class)
|
'maximum_addable_quantity' => $exception->maximumAddableQuantity,
|
||||||
->purchaseLimitExceededAvailability(
|
|
||||||
$exception->maximumAddableQuantity,
|
|
||||||
$exception->getMessage(),
|
|
||||||
)->toArray(),
|
|
||||||
], 422);
|
], 422);
|
||||||
});
|
});
|
||||||
$exceptions->render(function (PurchaseExpiredException $exception, Request $request) {
|
$exceptions->render(function (PurchaseExpiredException $exception, Request $request) {
|
||||||
@@ -118,6 +114,16 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||||||
'message' => $exception->getMessage(),
|
'message' => $exception->getMessage(),
|
||||||
], 422);
|
], 422);
|
||||||
});
|
});
|
||||||
|
$exceptions->render(function (StockReservationExpiredException $exception, Request $request) {
|
||||||
|
if (! $request->is('api/*')) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'code' => 'stock_reservation.expired',
|
||||||
|
'message' => $exception->getMessage(),
|
||||||
|
], 422);
|
||||||
|
});
|
||||||
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
$exceptions->render(function (ModelNotFoundException $exception, Request $request) {
|
||||||
if (! $request->is('api/*')) {
|
if (! $request->is('api/*')) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -6,15 +6,16 @@
|
|||||||
"keywords": ["laravel", "framework"],
|
"keywords": ["laravel", "framework"],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"require": {
|
"require": {
|
||||||
"ext-gd": "*",
|
|
||||||
"php": "^8.3",
|
"php": "^8.3",
|
||||||
|
"ext-gd": "*",
|
||||||
"barryvdh/laravel-dompdf": "^3.1",
|
"barryvdh/laravel-dompdf": "^3.1",
|
||||||
"endroid/qr-code": "^6.1",
|
"endroid/qr-code": "^6.1",
|
||||||
"laravel/framework": "^13.8",
|
"laravel/framework": "^13.8",
|
||||||
"laravel/sanctum": "^4.3",
|
"laravel/sanctum": "^4.3",
|
||||||
"laravel/socialite": "^5.29",
|
"laravel/socialite": "^5.29",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"league/flysystem-aws-s3-v3": "^3.0"
|
"league/flysystem-aws-s3-v3": "^3.0",
|
||||||
|
"phpoffice/phpspreadsheet": "^5.9"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"fakerphp/faker": "^1.23",
|
"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",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "ce185c60c617846be30ae694f0cf6e9c",
|
"content-hash": "a593ab47d99b233f75851dbb7ea50479",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
@@ -417,6 +417,82 @@
|
|||||||
],
|
],
|
||||||
"time": "2024-02-09T16:56:22+00:00"
|
"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",
|
"name": "dasprid/enum",
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
@@ -2924,6 +3000,191 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-03-08T20:05:35+00:00"
|
"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",
|
"name": "masterminds/html5",
|
||||||
"version": "2.10.1",
|
"version": "2.10.1",
|
||||||
@@ -3686,6 +3947,115 @@
|
|||||||
},
|
},
|
||||||
"time": "2020-10-15T08:29:30+00:00"
|
"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",
|
"name": "phpoption/phpoption",
|
||||||
"version": "1.9.5",
|
"version": "1.9.5",
|
||||||
@@ -9838,8 +10208,8 @@
|
|||||||
"prefer-stable": true,
|
"prefer-stable": true,
|
||||||
"prefer-lowest": false,
|
"prefer-lowest": false,
|
||||||
"platform": {
|
"platform": {
|
||||||
"ext-gd": "*",
|
"php": "^8.3",
|
||||||
"php": "^8.3"
|
"ext-gd": "*"
|
||||||
},
|
},
|
||||||
"platform-dev": {},
|
"platform-dev": {},
|
||||||
"plugin-api-version": "2.9.0"
|
"plugin-api-version": "2.9.0"
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ return [
|
|||||||
'users' => [
|
'users' => [
|
||||||
'provider' => 'users',
|
'provider' => 'users',
|
||||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||||
'expire' => 60,
|
'expire' => (int) env('AUTH_PASSWORD_RESET_EXPIRATION_MINUTES', 60),
|
||||||
'throttle' => 60,
|
'throttle' => 60,
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -2,33 +2,4 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30),
|
'stock_reservation_expiration_minutes' => (int) env('STOCK_RESERVATION_EXPIRATION_MINUTES', 30),
|
||||||
'availability' => [
|
|
||||||
'default_actions' => [
|
|
||||||
'select_variant',
|
|
||||||
'change_quantity',
|
|
||||||
'add_to_cart',
|
|
||||||
'buy_now',
|
|
||||||
],
|
|
||||||
'rules' => [
|
|
||||||
'user_quota_reached' => [
|
|
||||||
'effect' => 'restrict',
|
|
||||||
'denied_actions' => [
|
|
||||||
'select_variant',
|
|
||||||
'change_quantity',
|
|
||||||
'add_to_cart',
|
|
||||||
'buy_now',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
'out_of_stock' => [
|
|
||||||
'effect' => 'hide',
|
|
||||||
],
|
|
||||||
'requested_quantity_exceeds_user_quota' => [
|
|
||||||
'effect' => 'restrict',
|
|
||||||
'denied_actions' => [
|
|
||||||
'add_to_cart',
|
|
||||||
'buy_now',
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -89,6 +89,14 @@ return [
|
|||||||
'replace_placeholders' => true,
|
'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' => [
|
'slack' => [
|
||||||
'driver' => 'slack',
|
'driver' => 'slack',
|
||||||
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
'url' => env('LOG_SLACK_WEBHOOK_URL'),
|
||||||
|
|||||||
@@ -2,10 +2,4 @@
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
|
'checkout_expiration_minutes' => (int) env('PURCHASE_CHECKOUT_EXPIRATION_MINUTES', 30),
|
||||||
|
|
||||||
'payment_expiration_minutes' => [
|
|
||||||
'qr' => (int) env('PURCHASE_QR_EXPIRATION_MINUTES', 15),
|
|
||||||
'telepagos' => (int) env('PURCHASE_TELEPAGOS_EXPIRATION_MINUTES', 30),
|
|
||||||
'transfer' => (int) env('PURCHASE_TRANSFER_EXPIRATION_MINUTES', 1440),
|
|
||||||
],
|
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
<?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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Domains\Auth\Models\ResetPasswordAttempt;
|
||||||
|
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('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('expires_at')->nullable()->after('status');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attempts created before this migration did not have an expiration instant.
|
||||||
|
DB::table('reset_password_attempts')
|
||||||
|
->whereIn('status', [
|
||||||
|
ResetPasswordAttempt::STATUS_PENDING,
|
||||||
|
ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
])
|
||||||
|
->update(['status' => ResetPasswordAttempt::STATUS_EXPIRED]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('reset_password_attempts', function (Blueprint $table): void {
|
||||||
|
$table->dropColumn('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
<?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::rename('stock_reservations', 'stock_reservation_lines');
|
||||||
|
|
||||||
|
Schema::create('stock_reservations', function (Blueprint $table): void {
|
||||||
|
$table->id();
|
||||||
|
$table->string('status')->default('active');
|
||||||
|
$table->dateTime('expires_at')->nullable();
|
||||||
|
$table->dateTime('committed_at')->nullable();
|
||||||
|
$table->dateTime('released_at')->nullable();
|
||||||
|
$table->dateTime('expired_at')->nullable();
|
||||||
|
$table->string('release_reason')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
$table->index(['status', 'expires_at']);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('stock_reservation_id')->nullable()->after('id');
|
||||||
|
$table->boolean('tracks_inventory')->default(true)->after('quantity');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('current_stock_reservation_id')->nullable()->after('current_purchase_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->foreignId('stock_reservation_id')->nullable()->after('cart_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
$cartIdsByItem = DB::table('carrito_items')->pluck('cart_id', 'id');
|
||||||
|
$unlimitedInventoryIds = DB::table('catalog_items')
|
||||||
|
->where('inventory_policy', 'unlimited')
|
||||||
|
->whereNotNull('inventory_id')
|
||||||
|
->pluck('inventory_id')
|
||||||
|
->merge(
|
||||||
|
DB::table('variantes')
|
||||||
|
->join('catalog_items', 'catalog_items.id', '=', 'variantes.catalog_item_id')
|
||||||
|
->where('catalog_items.inventory_policy', 'unlimited')
|
||||||
|
->pluck('variantes.inventory_id'),
|
||||||
|
)
|
||||||
|
->map(fn ($id): int => (int) $id)
|
||||||
|
->unique();
|
||||||
|
$legacyRows = DB::table('stock_reservation_lines')->orderBy('id')->get();
|
||||||
|
$groups = $legacyRows->groupBy(function (object $row) use ($cartIdsByItem): string {
|
||||||
|
if ($row->purchase_id !== null) {
|
||||||
|
return 'purchase:'.$row->purchase_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
$cartId = $row->cart_item_id === null ? null : $cartIdsByItem->get($row->cart_item_id);
|
||||||
|
|
||||||
|
return $cartId === null ? 'legacy:'.$row->id : 'cart:'.$cartId;
|
||||||
|
});
|
||||||
|
|
||||||
|
foreach ($groups as $key => $rows) {
|
||||||
|
$statuses = $rows->pluck('status');
|
||||||
|
$status = $statuses->contains('active')
|
||||||
|
? 'active'
|
||||||
|
: ($statuses->contains('committed')
|
||||||
|
? 'committed'
|
||||||
|
: ($statuses->contains('expired') ? 'expired' : 'released'));
|
||||||
|
$first = $rows->first();
|
||||||
|
$reservationId = DB::table('stock_reservations')->insertGetId([
|
||||||
|
'status' => $status,
|
||||||
|
'expires_at' => $status === 'active' ? $rows->pluck('expires_at')->filter()->max() : null,
|
||||||
|
'committed_at' => $status === 'committed' ? $rows->pluck('committed_at')->filter()->max() : null,
|
||||||
|
'released_at' => $status === 'released' ? $rows->pluck('released_at')->filter()->max() : null,
|
||||||
|
'expired_at' => $status === 'expired' ? $rows->pluck('released_at')->filter()->max() : null,
|
||||||
|
'release_reason' => null,
|
||||||
|
'created_at' => $first->created_at,
|
||||||
|
'updated_at' => $rows->pluck('updated_at')->filter()->max() ?? $first->updated_at,
|
||||||
|
]);
|
||||||
|
|
||||||
|
foreach ($rows->groupBy('inventory_id') as $inventoryRows) {
|
||||||
|
$line = $inventoryRows->first();
|
||||||
|
DB::table('stock_reservation_lines')->where('id', $line->id)->update([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
'quantity' => $inventoryRows->sum('quantity'),
|
||||||
|
'tracks_inventory' => ! $unlimitedInventoryIds->contains((int) $line->inventory_id),
|
||||||
|
]);
|
||||||
|
DB::table('stock_reservation_lines')
|
||||||
|
->whereIn('id', $inventoryRows->pluck('id')->skip(1))
|
||||||
|
->delete();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (str_starts_with($key, 'purchase:')) {
|
||||||
|
$purchaseId = (int) substr($key, strlen('purchase:'));
|
||||||
|
DB::table('compras')->where('id', $purchaseId)->update([
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
$cartId = DB::table('compras')->where('id', $purchaseId)->value('cart_id');
|
||||||
|
$isCurrent = $cartId !== null
|
||||||
|
&& (int) DB::table('carritos')->where('id', $cartId)->value('current_purchase_id') === $purchaseId;
|
||||||
|
|
||||||
|
if ($status === 'active' && $isCurrent) {
|
||||||
|
DB::table('carritos')->where('id', $cartId)->update([
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} elseif (str_starts_with($key, 'cart:') && $status === 'active') {
|
||||||
|
DB::table('carritos')->where('id', (int) substr($key, strlen('cart:')))->update([
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DB::getDriverName() !== 'sqlite') {
|
||||||
|
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign('stock_reservations_cart_item_id_foreign');
|
||||||
|
$table->dropForeign('stock_reservations_purchase_id_foreign');
|
||||||
|
$table->dropUnique('stock_reservations_cart_item_id_inventory_id_unique');
|
||||||
|
$table->dropIndex('stock_reservations_purchase_id_status_index');
|
||||||
|
$table->dropIndex('stock_reservations_status_expires_at_index');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Schema::table('stock_reservation_lines', function (Blueprint $table): void {
|
||||||
|
$table->unsignedBigInteger('stock_reservation_id')->nullable(false)->change();
|
||||||
|
$table->dropColumn([
|
||||||
|
'cart_item_id',
|
||||||
|
'purchase_id',
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'committed_at',
|
||||||
|
'released_at',
|
||||||
|
]);
|
||||||
|
$table->foreign('stock_reservation_id', 'reservation_lines_reservation_fk')
|
||||||
|
->references('id')
|
||||||
|
->on('stock_reservations')
|
||||||
|
->cascadeOnDelete();
|
||||||
|
$table->unique(
|
||||||
|
['stock_reservation_id', 'inventory_id'],
|
||||||
|
'reservation_lines_reservation_inventory_unique',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->foreign('current_stock_reservation_id', 'carts_current_stock_reservation_fk')
|
||||||
|
->references('id')
|
||||||
|
->on('stock_reservations')
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->unique('current_stock_reservation_id', 'carts_current_stock_reservation_unique');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->foreign('stock_reservation_id', 'purchases_stock_reservation_fk')
|
||||||
|
->references('id')
|
||||||
|
->on('stock_reservations')
|
||||||
|
->nullOnDelete();
|
||||||
|
$table->unique('stock_reservation_id', 'purchases_stock_reservation_unique');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique('purchases_stock_reservation_unique');
|
||||||
|
$table->dropForeign('purchases_stock_reservation_fk');
|
||||||
|
$table->dropColumn('stock_reservation_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('carritos', function (Blueprint $table): void {
|
||||||
|
$table->dropUnique('carts_current_stock_reservation_unique');
|
||||||
|
$table->dropForeign('carts_current_stock_reservation_fk');
|
||||||
|
$table->dropColumn('current_stock_reservation_id');
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::dropIfExists('stock_reservation_lines');
|
||||||
|
Schema::dropIfExists('stock_reservations');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<?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
|
||||||
|
{
|
||||||
|
DB::table('compras')
|
||||||
|
->whereNotNull('stock_reservation_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function ($purchases): void {
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
DB::table('stock_reservations')
|
||||||
|
->where('id', $purchase->stock_reservation_id)
|
||||||
|
->where('status', 'active')
|
||||||
|
->update(['expires_at' => $purchase->expires_at]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->dropIndex(['expires_at']);
|
||||||
|
$table->dropColumn('expires_at');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('compras', function (Blueprint $table): void {
|
||||||
|
$table->timestamp('expires_at')->nullable()->index()->after('payment_method');
|
||||||
|
});
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->whereNotNull('stock_reservation_id')
|
||||||
|
->orderBy('id')
|
||||||
|
->chunkById(500, function ($purchases): void {
|
||||||
|
foreach ($purchases as $purchase) {
|
||||||
|
DB::table('compras')
|
||||||
|
->where('id', $purchase->id)
|
||||||
|
->update([
|
||||||
|
'expires_at' => DB::table('stock_reservations')
|
||||||
|
->where('id', $purchase->stock_reservation_id)
|
||||||
|
->value('expires_at'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
$expiredReservationIds = fn ($query) => $query
|
||||||
|
->select('id')
|
||||||
|
->from('stock_reservations')
|
||||||
|
->where('status', 'expired');
|
||||||
|
|
||||||
|
DB::table('compras')
|
||||||
|
->whereIn('status', ['created', 'pending_payment'])
|
||||||
|
->whereIn('stock_reservation_id', $expiredReservationIds)
|
||||||
|
->update(['status' => 'expired']);
|
||||||
|
|
||||||
|
DB::table('carritos')
|
||||||
|
->whereIn('status', ['active', 'checkout'])
|
||||||
|
->whereIn('current_stock_reservation_id', $expiredReservationIds)
|
||||||
|
->update(['status' => 'expired']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
// Terminal business states cannot be reversed without inventing their prior state.
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
<?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,6 +191,7 @@ class DesfilePuraTendenciaSeeder extends Seeder
|
|||||||
|
|
||||||
FeaturedGroup::query()->create([
|
FeaturedGroup::query()->create([
|
||||||
'tenant_code' => self::TENANT_CODE,
|
'tenant_code' => self::TENANT_CODE,
|
||||||
|
'code' => 'entradas',
|
||||||
'source_type' => FeaturedGroupSource::All,
|
'source_type' => FeaturedGroupSource::All,
|
||||||
'category_id' => null,
|
'category_id' => null,
|
||||||
'product_layout' => ProductLayout::TicketSelector,
|
'product_layout' => ProductLayout::TicketSelector,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
$this->createProduct($tenant, [
|
$this->createProduct($tenant, [
|
||||||
'slug' => 'camiseta',
|
'slug' => 'camiseta',
|
||||||
'nombre' => 'Camiseta',
|
'nombre' => 'Camiseta',
|
||||||
|
'group_order' => 1,
|
||||||
'category_id' => $categories['merchandising']->id,
|
'category_id' => $categories['merchandising']->id,
|
||||||
'precio' => 18000,
|
'precio' => 18000,
|
||||||
'attribute_codes' => ['color', 'talle'],
|
'attribute_codes' => ['color', 'talle'],
|
||||||
@@ -92,6 +93,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
$this->createProduct($tenant, [
|
$this->createProduct($tenant, [
|
||||||
'slug' => 'alojamiento',
|
'slug' => 'alojamiento',
|
||||||
'nombre' => 'Alojamiento',
|
'nombre' => 'Alojamiento',
|
||||||
|
'group_order' => 2,
|
||||||
'category_id' => $categories['alojamientos']->id,
|
'category_id' => $categories['alojamientos']->id,
|
||||||
'precio' => 35000,
|
'precio' => 35000,
|
||||||
'attribute_codes' => ['tipo_alojamiento'],
|
'attribute_codes' => ['tipo_alojamiento'],
|
||||||
@@ -104,6 +106,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
$this->createProduct($tenant, [
|
$this->createProduct($tenant, [
|
||||||
'slug' => 'comida',
|
'slug' => 'comida',
|
||||||
'nombre' => 'Comida',
|
'nombre' => 'Comida',
|
||||||
|
'group_order' => 4,
|
||||||
'category_id' => $categories['comidas']->id,
|
'category_id' => $categories['comidas']->id,
|
||||||
'precio' => 4000,
|
'precio' => 4000,
|
||||||
'attribute_codes' => ['event_date', 'horario', 'servicio'],
|
'attribute_codes' => ['event_date', 'horario', 'servicio'],
|
||||||
@@ -126,6 +129,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
$this->createProduct($tenant, [
|
$this->createProduct($tenant, [
|
||||||
'slug' => 'abono',
|
'slug' => 'abono',
|
||||||
'nombre' => 'Abono',
|
'nombre' => 'Abono',
|
||||||
|
'group_order' => 3,
|
||||||
'category_id' => $categories['entradas']->id,
|
'category_id' => $categories['entradas']->id,
|
||||||
'precio' => 40000,
|
'precio' => 40000,
|
||||||
'has_tickets' => true,
|
'has_tickets' => true,
|
||||||
@@ -140,6 +144,7 @@ class FiestaFutbolInfantilProductSeeder extends Seeder
|
|||||||
|
|
||||||
FeaturedGroup::query()->create([
|
FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'code' => 'productos',
|
||||||
'source_type' => FeaturedGroupSource::All,
|
'source_type' => FeaturedGroupSource::All,
|
||||||
'category_id' => null,
|
'category_id' => null,
|
||||||
'product_layout' => ProductLayout::Row,
|
'product_layout' => ProductLayout::Row,
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
|
|
||||||
FeaturedGroup::query()->create([
|
FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'code' => 'productos',
|
||||||
'source_type' => FeaturedGroupSource::All,
|
'source_type' => FeaturedGroupSource::All,
|
||||||
'product_layout' => ProductLayout::ColumnWithImage,
|
'product_layout' => ProductLayout::ColumnWithImage,
|
||||||
'group_layout' => GroupLayout::Paginated,
|
'group_layout' => GroupLayout::Paginated,
|
||||||
@@ -180,6 +181,7 @@ class ProductCatalogFromImagesSeeder extends Seeder
|
|||||||
|
|
||||||
$carouselGroup = FeaturedGroup::query()->create([
|
$carouselGroup = FeaturedGroup::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'code' => 'productos-destacados',
|
||||||
'source_type' => FeaturedGroupSource::Manual,
|
'source_type' => FeaturedGroupSource::Manual,
|
||||||
'product_layout' => ProductLayout::ColumnWithImage,
|
'product_layout' => ProductLayout::ColumnWithImage,
|
||||||
'group_layout' => GroupLayout::Carousel,
|
'group_layout' => GroupLayout::Carousel,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'site_logo' => $this->onTicketLogo(),
|
'site_logo' => $this->onTicketLogo(),
|
||||||
'footer_logo' => $this->onTicketFooterLogo(),
|
'footer_logo' => $this->onTicketFooterLogo(),
|
||||||
|
'favicon' => $this->onTicketFavicon(),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'site_logo' => $this->onTicketLogo(),
|
'site_logo' => $this->onTicketLogo(),
|
||||||
'footer_logo' => $this->onTicketFooterLogo(),
|
'footer_logo' => $this->onTicketFooterLogo(),
|
||||||
|
'favicon' => $shopIt->favicon()->firstOrFail()->key,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -175,4 +177,21 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
true,
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ return [
|
|||||||
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
|
'password_reset_invalid' => 'The password recovery request is invalid or has already been used.',
|
||||||
'password_updated' => 'Password updated successfully.',
|
'password_updated' => 'Password updated successfully.',
|
||||||
'reset_code_invalid' => 'The code you entered is invalid.',
|
'reset_code_invalid' => 'The code you entered is invalid.',
|
||||||
|
'reset_code_expired' => 'The password recovery code expired. Request a new one.',
|
||||||
'reset_code_valid' => 'Code validated successfully.',
|
'reset_code_valid' => 'Code validated successfully.',
|
||||||
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
|
'invalid_tenant_or_return_url' => 'The tenant or return URL is invalid.',
|
||||||
'request_expired' => 'The authentication request expired. Please try again.',
|
'request_expired' => 'The authentication request expired. Please try again.',
|
||||||
@@ -34,6 +35,7 @@ return [
|
|||||||
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
'bundle_variant_forbidden' => 'A bundle cannot have a variant.',
|
||||||
'empty_bundle' => 'The bundle has no components.',
|
'empty_bundle' => 'The bundle has no components.',
|
||||||
'variant_required' => 'You must select a variant for this item.',
|
'variant_required' => 'You must select a variant for this item.',
|
||||||
|
'reservation_expired' => 'The stock reservation has expired. Use the active cart to continue.',
|
||||||
],
|
],
|
||||||
'purchase' => [
|
'purchase' => [
|
||||||
'expired' => 'The purchase has expired. Please start a new purchase.',
|
'expired' => 'The purchase has expired. Please start a new purchase.',
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ return [
|
|||||||
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
'password_reset_invalid' => 'La solicitud de recuperación es inválida o ya fue utilizada.',
|
||||||
'password_updated' => 'Contraseña modificada correctamente.',
|
'password_updated' => 'Contraseña modificada correctamente.',
|
||||||
'reset_code_invalid' => 'El código ingresado es inválido.',
|
'reset_code_invalid' => 'El código ingresado es inválido.',
|
||||||
|
'reset_code_expired' => 'El código de recuperación expiró. Solicitá uno nuevo.',
|
||||||
'reset_code_valid' => 'Código validado correctamente.',
|
'reset_code_valid' => 'Código validado correctamente.',
|
||||||
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
|
'invalid_tenant_or_return_url' => 'El tenant o la URL de retorno no son válidos.',
|
||||||
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
|
'request_expired' => 'La solicitud de autenticación expiró. Intenta nuevamente.',
|
||||||
@@ -34,6 +35,7 @@ return [
|
|||||||
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
'bundle_variant_forbidden' => 'Un bundle no admite una variante.',
|
||||||
'empty_bundle' => 'El bundle no tiene componentes.',
|
'empty_bundle' => 'El bundle no tiene componentes.',
|
||||||
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
'variant_required' => 'Debe seleccionar una variante para este ítem.',
|
||||||
|
'reservation_expired' => 'La reserva de stock venció. Usá el carrito activo para continuar.',
|
||||||
],
|
],
|
||||||
'purchase' => [
|
'purchase' => [
|
||||||
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",
|
'expired' => "La compra venci\u{00F3}. Inici\u{00E1} una nueva compra.",
|
||||||
|
|||||||
3
public/images/website_types/onticket_favicon.svg
Normal file
3
public/images/website_types/onticket_favicon.svg
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<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>
|
||||||
|
After Width: | Height: | Size: 422 B |
@@ -1,4 +1,4 @@
|
|||||||
@props(['tenant', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
|
@props(['branding', 'headerLogoUrl' => null, 'footerLogoUrl' => null])
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="es">
|
<html lang="es">
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="color-scheme" content="light">
|
<meta name="color-scheme" content="light">
|
||||||
<title>{{ $tenant->nombre }}</title>
|
<title>{{ $branding['name'] }}</title>
|
||||||
<style>
|
<style>
|
||||||
@media only screen and (max-width: 620px) {
|
@media only screen and (max-width: 620px) {
|
||||||
.mail-container { width: 100% !important; }
|
.mail-container { width: 100% !important; }
|
||||||
@@ -14,17 +14,17 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body style="margin: 0; padding: 0; background-color: #f1f5f9; color: #334155; font-family: Arial, Helvetica, sans-serif;">
|
<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: #f1f5f9;">
|
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background-color: {{ $branding['background_color'] }};">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" style="padding: 32px 12px;">
|
<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: #ffffff; border-top: 4px solid {{ $tenant->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: {{ $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);">
|
||||||
<tr>
|
<tr>
|
||||||
<td align="center" bgcolor="{{ $tenant->header_bg_color }}" style="padding: 24px 32px; background-color: {{ $tenant->header_bg_color }};">
|
<td align="center" bgcolor="{{ $branding['header_bg_color'] }}" style="padding: 24px 32px; background-color: {{ $branding['header_bg_color'] }};">
|
||||||
@if ($headerLogoUrl)
|
@if ($headerLogoUrl)
|
||||||
<img src="{{ $headerLogoUrl }}" alt="{{ $tenant->nombre }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
|
<img src="{{ $headerLogoUrl }}" alt="{{ $branding['name'] }}" width="180" style="display: block; width: auto; max-width: 180px; max-height: 64px; border: 0;">
|
||||||
@else
|
@else
|
||||||
<span style="color: {{ $tenant->primary_color }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $tenant->nombre }}</span>
|
<span style="color: {{ $branding['primary_color'] }}; font-size: 24px; font-weight: 700; line-height: 1.2;">{{ $branding['name'] }}</span>
|
||||||
@endif
|
@endif
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -34,11 +34,11 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<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;">
|
<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;">
|
||||||
@if ($footerLogoUrl)
|
@if ($footerLogoUrl)
|
||||||
<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;">
|
<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;">
|
||||||
@endif
|
@endif
|
||||||
{{ $footer ?? 'Este correo fue enviado por '.$tenant->nombre.'.' }}
|
{{ $footer ?? 'Este correo fue enviado por '.$branding['name'].'.' }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }};">
|
<h1 style="margin: 0 0 20px; color: {{ $brand->primary_color }};">
|
||||||
Recuperá tu contraseña
|
Recuperá tu contraseña
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
|
@if($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED)
|
||||||
<p>
|
<p>
|
||||||
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.
|
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.
|
||||||
</p>
|
</p>
|
||||||
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
|
@elseif($attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_ACCOUNT_LOCKED)
|
||||||
<p>
|
<p>
|
||||||
@@ -17,10 +17,10 @@
|
|||||||
</p>
|
</p>
|
||||||
@endif
|
@endif
|
||||||
|
|
||||||
<p>Ingresá este código en {{ $tenant->nombre }}:</p>
|
<p>Ingresá este código en {{ $brand->nombre }}:</p>
|
||||||
|
|
||||||
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $tenant->primary_color }}; border-radius: 8px; text-align: center;">
|
<div style="margin: 28px 0; padding: 20px; border: 2px solid {{ $brand->primary_color }}; border-radius: 8px; text-align: center;">
|
||||||
<span style="color: {{ $tenant->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
|
<span style="color: {{ $brand->primary_color }}; font-size: 36px; font-weight: 700; letter-spacing: 12px;">
|
||||||
{{ $attempt->codigo }}
|
{{ $attempt->codigo }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
@if($recoveryUrl)
|
@if($recoveryUrl)
|
||||||
<div style="text-align: center; margin-bottom: 28px;">
|
<div style="text-align: center; margin-bottom: 28px;">
|
||||||
<a href="{{ $recoveryUrl }}"
|
<a href="{{ $recoveryUrl }}"
|
||||||
style="display: inline-block; padding: 12px 24px; background-color: {{ $tenant->primary_color }}; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: bold;">
|
style="display: inline-block; padding: 12px 24px; background-color: {{ $brand->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' }}
|
{{ $attempt->reason === \App\Domains\Auth\Models\ResetPasswordAttempt::REASON_STAFF_CREATED ? 'Crear mi contraseña' : 'Ingresar código ahora' }}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<h1 style="margin: 0 0 20px;">¡Recibimos tu pago!</h1>
|
<h1 style="margin: 0 0 20px;">¡Compra realizada con éxito!</h1>
|
||||||
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
<p>La compra <strong>#{{ $purchase->id }}</strong> fue confirmada correctamente.</p>
|
||||||
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
<table role="presentation" style="width: 100%; border-collapse: collapse; margin: 20px 0;">
|
||||||
@foreach ($purchase->items as $item)
|
@foreach ($purchase->items as $item)
|
||||||
@@ -10,3 +10,6 @@
|
|||||||
@endforeach
|
@endforeach
|
||||||
</table>
|
</table>
|
||||||
<p style="font-size: 18px;"><strong>Total pagado: ${{ number_format((float) $purchase->total, 2, ',', '.') }}</strong></p>
|
<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
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
<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 {{ $tenant->nombre }}!</h1>
|
<h1 style="margin: 0 0 20px;">¡Bienvenido a {{ $brand->nombre }}!</h1>
|
||||||
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
|
<p>Hola {{ $user->nombre_apellido }}, tu cuenta fue creada correctamente.</p>
|
||||||
<p>Ya podés ingresar y comenzar a comprar.</p>
|
<p>Ya podés ingresar y comenzar a comprar.</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<x-mail.branded-layout :tenant="$tenant" :header-logo-url="$headerLogoUrl" :footer-logo-url="$footerLogoUrl">
|
<x-mail.branded-layout :branding="$branding" :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;">
|
<h1 style="margin: 0 0 20px; color: {{ $tenant->primary_color }}; font-size: 26px; line-height: 1.3;">
|
||||||
Prueba de correo de Shopit
|
Prueba de correo de Shopit
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ Artisan::command('reservations:expire', function (): void {
|
|||||||
$expired = app(ExpireStockReservationsService::class)->expireOverdue();
|
$expired = app(ExpireStockReservationsService::class)->expireOverdue();
|
||||||
|
|
||||||
$this->info("Expired purchases: {$expired['purchases']}");
|
$this->info("Expired purchases: {$expired['purchases']}");
|
||||||
$this->info("Expired cart items: {$expired['cart_items']}");
|
$this->info("Expired cart reservations: {$expired['cart_reservations']}");
|
||||||
|
$this->info("Expired orphan reservations: {$expired['orphan_reservations']}");
|
||||||
|
$this->info("Failed reservations: {$expired['failed']}");
|
||||||
})->purpose('Release expired stock reservations from purchases and abandoned carts');
|
})->purpose('Release expired stock reservations from purchases and abandoned carts');
|
||||||
|
|
||||||
Schedule::command('reservations:expire')
|
Schedule::command('reservations:expire')
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ class CreateResetPasswordAttemptControllerTest extends TestCase
|
|||||||
$this->assertTrue($attempt->user->is($user));
|
$this->assertTrue($attempt->user->is($user));
|
||||||
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
|
$this->assertMatchesRegularExpression('/^\d{4}$/', $attempt->codigo);
|
||||||
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||||
|
$this->assertTrue($attempt->expires_at->between(
|
||||||
|
now()->addMinutes(59),
|
||||||
|
now()->addMinutes(60),
|
||||||
|
));
|
||||||
Event::assertDispatched(
|
Event::assertDispatched(
|
||||||
PasswordResetRequested::class,
|
PasswordResetRequested::class,
|
||||||
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
fn (PasswordResetRequested $event): bool => $event->attemptId === $attempt->id
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ class ResetPasswordAttemptTest extends TestCase
|
|||||||
'id',
|
'id',
|
||||||
'user_id',
|
'user_id',
|
||||||
'codigo',
|
'codigo',
|
||||||
|
'reason',
|
||||||
'status',
|
'status',
|
||||||
|
'expires_at',
|
||||||
], Schema::getColumnListing('reset_password_attempts'));
|
], Schema::getColumnListing('reset_password_attempts'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,12 +30,14 @@ class ResetPasswordAttemptTest extends TestCase
|
|||||||
|
|
||||||
$attempt = $user->resetPasswordAttempts()->create([
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
'codigo' => '123456',
|
'codigo' => '123456',
|
||||||
|
'expires_at' => now()->addHour(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_PENDING, $attempt->status);
|
||||||
$this->assertTrue($attempt->user->is($user));
|
$this->assertTrue($attempt->user->is($user));
|
||||||
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
|
$this->assertTrue($user->resetPasswordAttempts->contains($attempt));
|
||||||
$this->assertFalse($attempt->usesTimestamps());
|
$this->assertFalse($attempt->usesTimestamps());
|
||||||
|
$this->assertTrue($attempt->expires_at->isFuture());
|
||||||
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
|
$this->assertArrayNotHasKey('codigo', $attempt->toArray());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -99,6 +99,30 @@ class ResetPasswordControllerTest extends TestCase
|
|||||||
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
$this->assertSame(ResetPasswordAttempt::STATUS_USED, $attempt->fresh()->status);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_an_expired_validated_attempt_cannot_reset_the_password(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create([
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'password' => 'OldSecret!123',
|
||||||
|
]);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'status' => ResetPasswordAttempt::STATUS_VALIDATED,
|
||||||
|
'expires_at' => now()->subSecond(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
'password' => 'NewSecret!456',
|
||||||
|
'password_confirmation' => 'NewSecret!456',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo');
|
||||||
|
|
||||||
|
$this->assertTrue(Hash::check('OldSecret!123', $user->fresh()->password));
|
||||||
|
$this->assertSame(ResetPasswordAttempt::STATUS_EXPIRED, $attempt->fresh()->status);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_validates_password_confirmation_and_strength(): void
|
public function test_it_validates_password_confirmation_and_strength(): void
|
||||||
{
|
{
|
||||||
$this->postJson('/api/password/reset', [
|
$this->postJson('/api/password/reset', [
|
||||||
|
|||||||
@@ -49,6 +49,27 @@ class ValidateResetPasswordAttemptControllerTest extends TestCase
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_expires_an_attempt_and_returns_the_expired_code_message(): void
|
||||||
|
{
|
||||||
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
$attempt = $user->resetPasswordAttempts()->create([
|
||||||
|
'codigo' => '1234',
|
||||||
|
'expires_at' => now()->subSecond(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/password/reset-attempts/validate', [
|
||||||
|
'email' => 'ada@example.com',
|
||||||
|
'codigo' => '1234',
|
||||||
|
])->assertUnprocessable()
|
||||||
|
->assertJsonValidationErrors('codigo')
|
||||||
|
->assertJsonPath('errors.codigo.0', __('api.auth.reset_code_expired'));
|
||||||
|
|
||||||
|
$this->assertSame(
|
||||||
|
ResetPasswordAttempt::STATUS_EXPIRED,
|
||||||
|
$attempt->fresh()->status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
public function test_it_rejects_an_expired_or_already_validated_attempt(): void
|
||||||
{
|
{
|
||||||
$user = User::factory()->create(['email' => 'ada@example.com']);
|
$user = User::factory()->create(['email' => 'ada@example.com']);
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ namespace Tests\Feature\Cart;
|
|||||||
use App\Domains\Attachable\Enums\AttachmentType;
|
use App\Domains\Attachable\Enums\AttachmentType;
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
|
use App\Domains\Cart\Models\Cart;
|
||||||
use App\Domains\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Inventory;
|
use App\Domains\Catalog\Models\Inventory;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
|
use App\Domains\Catalog\Services\CatalogService;
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
@@ -28,6 +30,81 @@ class CartControllerTest extends TestCase
|
|||||||
]));
|
]));
|
||||||
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type'));
|
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_type'));
|
||||||
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id'));
|
$this->assertFalse(Schema::hasColumn('carrito_items', 'buyable_id'));
|
||||||
|
$this->assertTrue(Schema::hasColumns('stock_reservations', [
|
||||||
|
'status',
|
||||||
|
'expires_at',
|
||||||
|
'committed_at',
|
||||||
|
'released_at',
|
||||||
|
'expired_at',
|
||||||
|
'release_reason',
|
||||||
|
]));
|
||||||
|
$this->assertFalse(Schema::hasColumn('stock_reservations', 'quantity'));
|
||||||
|
$this->assertTrue(Schema::hasColumns('stock_reservation_lines', [
|
||||||
|
'stock_reservation_id',
|
||||||
|
'inventory_id',
|
||||||
|
'quantity',
|
||||||
|
'tracks_inventory',
|
||||||
|
]));
|
||||||
|
$this->assertTrue(Schema::hasColumn('carritos', 'current_stock_reservation_id'));
|
||||||
|
$this->assertTrue(Schema::hasColumn('compras', 'stock_reservation_id'));
|
||||||
|
$this->assertFalse(Schema::hasColumn('compras', 'expires_at'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_it_aggregates_shared_inventory_into_one_cart_reservation_line(): void
|
||||||
|
{
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$firstItem = $this->createDirectItem($tenant, 10, '10.00');
|
||||||
|
$secondItem = app(CatalogService::class)->create([
|
||||||
|
'tenant_code' => $tenant->codigo,
|
||||||
|
'type' => 'bundle',
|
||||||
|
'slug' => 'second-shared-item',
|
||||||
|
'nombre' => 'Second shared item',
|
||||||
|
'precio' => '20.00',
|
||||||
|
'components' => [[
|
||||||
|
'catalog_item_id' => $firstItem->id,
|
||||||
|
'quantity' => 1,
|
||||||
|
]],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$firstResponse = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $firstItem->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])->assertOk();
|
||||||
|
$guestToken = $firstResponse->getCookie('guest_token', false)?->getValue();
|
||||||
|
|
||||||
|
$this->call(
|
||||||
|
'POST',
|
||||||
|
'/api/tenants/acme/cart/items',
|
||||||
|
[],
|
||||||
|
['guest_token' => $guestToken],
|
||||||
|
[],
|
||||||
|
['HTTP_Accept' => 'application/json', 'CONTENT_TYPE' => 'application/json'],
|
||||||
|
json_encode([
|
||||||
|
'catalog_item_id' => $secondItem->id,
|
||||||
|
'cantidad' => 3,
|
||||||
|
]),
|
||||||
|
)->assertOk();
|
||||||
|
|
||||||
|
$reservationId = (int) $firstResponse->json('data.id');
|
||||||
|
$reservationId = (int) Cart::query()
|
||||||
|
->findOrFail($reservationId)
|
||||||
|
->current_stock_reservation_id;
|
||||||
|
|
||||||
|
$this->assertDatabaseCount('stock_reservations', 1);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
|
'id' => $reservationId,
|
||||||
|
'status' => 'active',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'stock_reservation_id' => $reservationId,
|
||||||
|
'inventory_id' => $firstItem->inventory_id,
|
||||||
|
'quantity' => 5,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseCount('stock_reservation_lines', 1);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $firstItem->inventory_id,
|
||||||
|
'reserved_stock' => 5,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_adds_a_catalog_item_without_a_variant(): void
|
public function test_it_adds_a_catalog_item_without_a_variant(): void
|
||||||
@@ -74,9 +151,11 @@ class CartControllerTest extends TestCase
|
|||||||
'id' => $item->inventory_id,
|
'id' => $item->inventory_id,
|
||||||
'reserved_stock' => 2,
|
'reserved_stock' => 2,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
'inventory_id' => $item->inventory_id,
|
'inventory_id' => $item->inventory_id,
|
||||||
'quantity' => 2,
|
'quantity' => 2,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -96,38 +175,44 @@ class CartControllerTest extends TestCase
|
|||||||
])->assertOk();
|
])->assertOk();
|
||||||
|
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'inventory_id' => $item->inventory_id,
|
|
||||||
'quantity' => 2,
|
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(),
|
'expires_at' => $now->copy()->addMinutes(45)->toDateTimeString(),
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'inventory_id' => $item->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
$this->travelBack();
|
$this->travelBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_expires_abandoned_cart_reservations_and_removes_empty_carts(): void
|
public function test_it_expires_a_cart_reservation_and_automatically_replaces_the_cart(): void
|
||||||
{
|
{
|
||||||
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
|
$user = User::factory()->create();
|
||||||
$item = $this->createDirectItem($tenant, 10, '49.90');
|
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||||
|
|
||||||
$response = $this->postJson('/api/tenants/acme/cart/items', [
|
$response = $this->actingAs($user, 'sanctum')
|
||||||
'catalog_item_id' => $item->id,
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
'cantidad' => 2,
|
'catalog_item_id' => $item->id,
|
||||||
])->assertOk();
|
'cantidad' => 2,
|
||||||
|
])->assertOk();
|
||||||
$cartId = $response->json('data.id');
|
$cartId = $response->json('data.id');
|
||||||
$cartItemId = $response->json('data.items.0.id');
|
$cartItemId = $response->json('data.items.0.id');
|
||||||
|
$cart = Cart::query()->findOrFail($cartId);
|
||||||
|
$reservationId = $cart->current_stock_reservation_id;
|
||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
->expectsOutput('Expired cart items: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->travel(31)->minutes();
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
->expectsOutput('Expired cart items: 1')
|
->expectsOutput('Expired cart reservations: 1')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->assertDatabaseHas('inventories', [
|
$this->assertDatabaseHas('inventories', [
|
||||||
@@ -135,28 +220,141 @@ class CartControllerTest extends TestCase
|
|||||||
'real_stock' => 10,
|
'real_stock' => 10,
|
||||||
'reserved_stock' => 0,
|
'reserved_stock' => 0,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseMissing('carrito_items', ['id' => $cartItemId]);
|
$this->assertDatabaseHas('carrito_items', ['id' => $cartItemId]);
|
||||||
$this->assertSoftDeleted('carritos', [
|
$this->assertDatabaseHas('carritos', [
|
||||||
'id' => $cartId,
|
'id' => $cartId,
|
||||||
'status' => 'expired',
|
'status' => Cart::STATUS_EXPIRED,
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
'deleted_at' => null,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'inventory_id' => $item->inventory_id,
|
'id' => $reservationId,
|
||||||
'cart_item_id' => null,
|
|
||||||
'purchase_id' => null,
|
|
||||||
'quantity' => 0,
|
|
||||||
'status' => 'expired',
|
'status' => 'expired',
|
||||||
'expires_at' => null,
|
'expires_at' => null,
|
||||||
]);
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'inventory_id' => $item->inventory_id,
|
||||||
|
'quantity' => 2,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$currentCart = $this->getJson('/api/tenants/acme/cart')
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
|
||||||
|
->assertJsonPath('data.items', [])
|
||||||
|
->assertJsonMissingPath('data.stock_reservation')
|
||||||
|
->assertJsonMissingPath('data.current_stock_reservation_id');
|
||||||
|
$newCartId = $currentCart->json('data.id');
|
||||||
|
|
||||||
|
$this->assertNotSame($cartId, $newCartId);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $cartId,
|
||||||
|
'status' => Cart::STATUS_ABANDONED,
|
||||||
|
'current_stock_reservation_id' => $reservationId,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $newCartId,
|
||||||
|
'status' => Cart::STATUS_ACTIVE,
|
||||||
|
'current_stock_reservation_id' => null,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.id', $newCartId)
|
||||||
|
->assertJsonPath('data.items.0.cantidad', 1);
|
||||||
|
|
||||||
$this->artisan('reservations:expire')
|
$this->artisan('reservations:expire')
|
||||||
->expectsOutput('Expired purchases: 0')
|
->expectsOutput('Expired purchases: 0')
|
||||||
->expectsOutput('Expired cart items: 0')
|
->expectsOutput('Expired cart reservations: 0')
|
||||||
->assertSuccessful();
|
->assertSuccessful();
|
||||||
|
|
||||||
$this->travelBack();
|
$this->travelBack();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function test_it_replaces_an_overdue_cart_before_the_expiration_job_runs(): void
|
||||||
|
{
|
||||||
|
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
$item = $this->createDirectItem($tenant, 10, '49.90');
|
||||||
|
|
||||||
|
$original = $this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])->assertOk();
|
||||||
|
$originalCartId = $original->json('data.id');
|
||||||
|
|
||||||
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
|
$replacement = $this->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'cantidad' => 1,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->assertJsonPath('data.status', Cart::STATUS_ACTIVE)
|
||||||
|
->assertJsonPath('data.items.0.cantidad', 1);
|
||||||
|
|
||||||
|
$this->assertNotSame($originalCartId, $replacement->json('data.id'));
|
||||||
|
$this->assertDatabaseHas('carritos', [
|
||||||
|
'id' => $originalCartId,
|
||||||
|
'status' => Cart::STATUS_ABANDONED,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('inventories', [
|
||||||
|
'id' => $item->inventory_id,
|
||||||
|
'reserved_stock' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_expired_cart_mutations_return_the_expiration_error_instead_of_not_found(): void
|
||||||
|
{
|
||||||
|
config()->set('catalog.stock_reservation_expiration_minutes', 30);
|
||||||
|
$tenant = $this->createTenant('acme');
|
||||||
|
$user = User::factory()->create();
|
||||||
|
[$item, $firstVariant] = $this->createVariantItem($tenant, 10, '49.90');
|
||||||
|
$secondInventory = Inventory::query()->create(['real_stock' => 10]);
|
||||||
|
$secondVariant = $item->variants()->create(['inventory_id' => $secondInventory->id]);
|
||||||
|
|
||||||
|
$cartItemId = $this->actingAs($user, 'sanctum')
|
||||||
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
|
'catalog_item_id' => $item->id,
|
||||||
|
'variant_id' => $firstVariant->id,
|
||||||
|
'cantidad' => 2,
|
||||||
|
])
|
||||||
|
->assertOk()
|
||||||
|
->json('data.items.0.id');
|
||||||
|
|
||||||
|
$this->travel(31)->minutes();
|
||||||
|
|
||||||
|
$expectedError = [
|
||||||
|
'code' => 'stock_reservation.expired',
|
||||||
|
'message' => __('api.cart.reservation_expired'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
|
||||||
|
'cantidad' => 3,
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson($expectedError);
|
||||||
|
|
||||||
|
$this->patchJson("/api/tenants/acme/cart/items/{$cartItemId}", [
|
||||||
|
'cantidad' => 2,
|
||||||
|
'variant_id' => $secondVariant->id,
|
||||||
|
])
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson($expectedError);
|
||||||
|
|
||||||
|
$this->deleteJson("/api/tenants/acme/cart/items/{$cartItemId}")
|
||||||
|
->assertUnprocessable()
|
||||||
|
->assertExactJson($expectedError);
|
||||||
|
|
||||||
|
$this->travelBack();
|
||||||
|
}
|
||||||
|
|
||||||
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
public function test_it_filters_item_images_when_the_tenant_disables_them(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('acme');
|
$tenant = $this->createTenant('acme');
|
||||||
@@ -212,10 +410,11 @@ class CartControllerTest extends TestCase
|
|||||||
'id' => $variant->inventory_id,
|
'id' => $variant->inventory_id,
|
||||||
'reserved_stock' => 5,
|
'reserved_stock' => 5,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
'cart_item_id' => $response->json('data.items.0.id'),
|
|
||||||
'inventory_id' => $variant->inventory_id,
|
'inventory_id' => $variant->inventory_id,
|
||||||
'quantity' => 5,
|
'quantity' => 5,
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'status' => 'active',
|
'status' => 'active',
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
@@ -229,6 +428,7 @@ class CartControllerTest extends TestCase
|
|||||||
$this->createPurchaseItem($tenant, $user, $item, 1);
|
$this->createPurchaseItem($tenant, $user, $item, 1);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->withHeader('Accept-Language', 'es')
|
||||||
->postJson('/api/tenants/acme/cart/items', [
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
'catalog_item_id' => $item->id,
|
'catalog_item_id' => $item->id,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
@@ -237,6 +437,7 @@ class CartControllerTest extends TestCase
|
|||||||
->assertJsonPath('data.items.0.cantidad', 2);
|
->assertJsonPath('data.items.0.cantidad', 2);
|
||||||
|
|
||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
|
->withHeader('Accept-Language', 'es')
|
||||||
->postJson('/api/tenants/acme/cart/items', [
|
->postJson('/api/tenants/acme/cart/items', [
|
||||||
'catalog_item_id' => $item->id,
|
'catalog_item_id' => $item->id,
|
||||||
'cantidad' => 2,
|
'cantidad' => 2,
|
||||||
@@ -246,7 +447,7 @@ class CartControllerTest extends TestCase
|
|||||||
->assertJsonPath('code', 'purchase.limit_exceeded')
|
->assertJsonPath('code', 'purchase.limit_exceeded')
|
||||||
->assertJsonPath('catalog_item_id', $item->id)
|
->assertJsonPath('catalog_item_id', $item->id)
|
||||||
->assertJsonPath('catalog_item_name', $item->nombre)
|
->assertJsonPath('catalog_item_name', $item->nombre)
|
||||||
->assertJsonPath('availability.maximum_quantity', 1)
|
->assertJsonPath('maximum_addable_quantity', 1)
|
||||||
->assertJsonPath(
|
->assertJsonPath(
|
||||||
'message',
|
'message',
|
||||||
"Podés agregar hasta 1 unidad más de “{$item->nombre}”.",
|
"Podés agregar hasta 1 unidad más de “{$item->nombre}”.",
|
||||||
@@ -381,10 +582,12 @@ class CartControllerTest extends TestCase
|
|||||||
'reserved_stock' => 0,
|
'reserved_stock' => 0,
|
||||||
]);
|
]);
|
||||||
$this->assertDatabaseHas('stock_reservations', [
|
$this->assertDatabaseHas('stock_reservations', [
|
||||||
'cart_item_id' => null,
|
|
||||||
'inventory_id' => $variant->inventory_id,
|
|
||||||
'quantity' => 0,
|
|
||||||
'status' => 'released',
|
'status' => 'released',
|
||||||
|
'release_reason' => 'cart_empty',
|
||||||
|
]);
|
||||||
|
$this->assertDatabaseHas('stock_reservation_lines', [
|
||||||
|
'inventory_id' => $variant->inventory_id,
|
||||||
|
'quantity' => 5,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ class BundleCatalogItemTest extends TestCase
|
|||||||
$this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}")
|
$this->getJson("/api/tenants/{$this->tenant->codigo}/catalog-items/{$bundleId}")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
|
->assertJsonPath('data.type', CatalogItemType::Bundle->value)
|
||||||
->assertJsonPath('data.availability.maximum_quantity', 4)
|
->assertJsonPath('data.maximum_addable_quantity', 4)
|
||||||
->assertJsonMissingPath('data.stock_tecnico')
|
->assertJsonMissingPath('data.stock_tecnico')
|
||||||
->assertJsonCount(1, 'data.components')
|
->assertJsonCount(1, 'data.components')
|
||||||
->assertJsonPath('data.components.0.catalog_item_id', $component->id)
|
->assertJsonPath('data.components.0.catalog_item_id', $component->id)
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ class CatalogControllerTest extends TestCase
|
|||||||
$directItem = $this->createItem($tenant, 'Direct', $directInventory);
|
$directItem = $this->createItem($tenant, 'Direct', $directInventory);
|
||||||
$row->featuredItems()->create(['catalog_item_id' => $directItem->id]);
|
$row->featuredItems()->create(['catalog_item_id' => $directItem->id]);
|
||||||
|
|
||||||
$variantItem = $this->createItem($tenant, 'Variants', withoutInventory: true);
|
$variantItem = $this->createItem($tenant, 'Variants');
|
||||||
$firstInventory = Inventory::query()->create([
|
$firstInventory = Inventory::query()->create([
|
||||||
'real_stock' => 5,
|
'real_stock' => 5,
|
||||||
'reserved_stock' => 1,
|
'reserved_stock' => 1,
|
||||||
@@ -56,7 +56,7 @@ class CatalogControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
$variantItem->variants()->create(['inventory_id' => $firstInventory->id]);
|
$variantItem->variants()->create(['inventory_id' => $firstInventory->id]);
|
||||||
$variantItem->variants()->create(['inventory_id' => $secondInventory->id]);
|
$variantItem->variants()->create(['inventory_id' => $secondInventory->id]);
|
||||||
$variantItem->variants()->create([
|
$unavailableVariant = $variantItem->variants()->create([
|
||||||
'inventory_id' => $unavailableInventory->id,
|
'inventory_id' => $unavailableInventory->id,
|
||||||
]);
|
]);
|
||||||
$cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]);
|
$cart->featuredItems()->create(['catalog_item_id' => $variantItem->id]);
|
||||||
@@ -72,16 +72,19 @@ class CatalogControllerTest extends TestCase
|
|||||||
->assertJsonPath('0.items.0.nombre', 'Variants')
|
->assertJsonPath('0.items.0.nombre', 'Variants')
|
||||||
->assertJsonPath('0.items.0.descripcion', 'Variants description')
|
->assertJsonPath('0.items.0.descripcion', 'Variants description')
|
||||||
->assertJsonPath('0.items.0.precio', '100.00')
|
->assertJsonPath('0.items.0.precio', '100.00')
|
||||||
->assertJsonPath('0.items.0.availability.state', 'visible')
|
->assertJsonPath('0.items.0.maximum_addable_quantity', 7)
|
||||||
->assertJsonPath('0.items.0.availability.maximum_quantity', 7)
|
|
||||||
->assertJsonCount(2, '0.items.0.variants')
|
->assertJsonCount(2, '0.items.0.variants')
|
||||||
->assertJsonPath('0.items.0.variants.0.availability.maximum_quantity', 4)
|
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 4)
|
||||||
->assertJsonPath('0.items.0.variants.0.availability.state', 'visible')
|
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 3)
|
||||||
->assertJsonPath('0.items.0.variants.1.availability.maximum_quantity', 3)
|
|
||||||
->assertJsonPath('1.title', 'Row')
|
->assertJsonPath('1.title', 'Row')
|
||||||
->assertJsonPath('1.items.data.0.availability.maximum_quantity', 8)
|
->assertJsonPath('1.items.data.0.maximum_addable_quantity', 8)
|
||||||
->assertJsonMissingPath('1.items.data.0.stock_tecnico')
|
->assertJsonMissingPath('1.items.data.0.stock_tecnico')
|
||||||
->assertJsonCount(0, '1.items.data.0.variants');
|
->assertJsonCount(0, '1.items.data.0.variants');
|
||||||
|
|
||||||
|
$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
|
public function test_maximum_addable_quantity_shares_the_authenticated_user_quota_between_variants(): void
|
||||||
@@ -94,7 +97,7 @@ class CatalogControllerTest extends TestCase
|
|||||||
groupLayout: GroupLayout::Simple,
|
groupLayout: GroupLayout::Simple,
|
||||||
);
|
);
|
||||||
$user = User::factory()->create();
|
$user = User::factory()->create();
|
||||||
$item = $this->createItem($tenant, 'Limited variants', withoutInventory: true);
|
$item = $this->createItem($tenant, 'Limited variants');
|
||||||
$item->update(['max_units_per_user' => 3]);
|
$item->update(['max_units_per_user' => 3]);
|
||||||
$firstVariant = $item->variants()->create([
|
$firstVariant = $item->variants()->create([
|
||||||
'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id,
|
'inventory_id' => Inventory::query()->create(['real_stock' => 10])->id,
|
||||||
@@ -115,21 +118,21 @@ class CatalogControllerTest extends TestCase
|
|||||||
$this->actingAs($user, 'sanctum')
|
$this->actingAs($user, 'sanctum')
|
||||||
->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonPath('0.items.0.availability.maximum_quantity', 0)
|
->assertJsonPath('0.items.0.variants.0.maximum_addable_quantity', 0)
|
||||||
->assertJsonPath('0.items.0.availability.allowed_actions', [])
|
->assertJsonPath('0.items.0.variants.1.maximum_addable_quantity', 0)
|
||||||
->assertJsonPath(
|
->assertJsonPath(
|
||||||
'0.items.0.availability.reasons.0.message',
|
'0.items.0.variants.0.unavailable_message',
|
||||||
|
'Alcanzaste el cupo máximo permitido para este producto.',
|
||||||
|
)
|
||||||
|
->assertJsonPath(
|
||||||
|
'0.items.0.variants.1.unavailable_message',
|
||||||
'Alcanzaste el cupo máximo permitido para este producto.',
|
'Alcanzaste el cupo máximo permitido para este producto.',
|
||||||
)
|
)
|
||||||
->assertJsonPath('0.items.0.variants.0.availability.maximum_quantity', 8)
|
|
||||||
->assertJsonPath('0.items.0.variants.1.availability.maximum_quantity', 9)
|
|
||||||
->assertJsonCount(0, '0.items.0.variants.0.availability.reasons')
|
|
||||||
->assertJsonCount(0, '0.items.0.variants.1.availability.reasons')
|
|
||||||
->assertJsonMissingPath('0.items.0.variants.0.stock_tecnico')
|
->assertJsonMissingPath('0.items.0.variants.0.stock_tecnico')
|
||||||
->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico');
|
->assertJsonMissingPath('0.items.0.variants.1.stock_tecnico');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_it_hides_out_of_stock_items_before_building_the_group_response(): void
|
public function test_it_excludes_out_of_stock_items(): void
|
||||||
{
|
{
|
||||||
$tenant = $this->createTenant('catalog-available-variants');
|
$tenant = $this->createTenant('catalog-available-variants');
|
||||||
$group = $this->createGroup(
|
$group = $this->createGroup(
|
||||||
@@ -139,7 +142,7 @@ class CatalogControllerTest extends TestCase
|
|||||||
groupLayout: GroupLayout::SimpleVertical,
|
groupLayout: GroupLayout::SimpleVertical,
|
||||||
);
|
);
|
||||||
|
|
||||||
$unavailableItem = $this->createItem($tenant, 'Unavailable', withoutInventory: true);
|
$unavailableItem = $this->createItem($tenant, 'Unavailable');
|
||||||
$unavailableInventory = Inventory::query()->create([
|
$unavailableInventory = Inventory::query()->create([
|
||||||
'real_stock' => 4,
|
'real_stock' => 4,
|
||||||
'reserved_stock' => 4,
|
'reserved_stock' => 4,
|
||||||
@@ -147,7 +150,7 @@ class CatalogControllerTest extends TestCase
|
|||||||
$unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]);
|
$unavailableItem->variants()->create(['inventory_id' => $unavailableInventory->id]);
|
||||||
$group->featuredItems()->create(['catalog_item_id' => $unavailableItem->id]);
|
$group->featuredItems()->create(['catalog_item_id' => $unavailableItem->id]);
|
||||||
|
|
||||||
$availableItem = $this->createItem($tenant, 'Available', withoutInventory: true);
|
$availableItem = $this->createItem($tenant, 'Available');
|
||||||
$availableInventory = Inventory::query()->create([
|
$availableInventory = Inventory::query()->create([
|
||||||
'real_stock' => 4,
|
'real_stock' => 4,
|
||||||
'reserved_stock' => 3,
|
'reserved_stock' => 3,
|
||||||
@@ -159,7 +162,8 @@ class CatalogControllerTest extends TestCase
|
|||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonCount(1, '0.items')
|
->assertJsonCount(1, '0.items')
|
||||||
->assertJsonPath('0.items.0.nombre', 'Available')
|
->assertJsonPath('0.items.0.nombre', 'Available')
|
||||||
->assertJsonCount(0, '0.items.0.availability.reasons');
|
->assertJsonPath('0.items.0.unavailable_message', null)
|
||||||
|
->assertJsonMissing(['nombre' => 'Unavailable']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void
|
public function test_column_with_image_uses_item_image_then_variant_image_then_null(): void
|
||||||
@@ -176,8 +180,8 @@ class CatalogControllerTest extends TestCase
|
|||||||
'order' => 0,
|
'order' => 0,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$variantItem = $this->createItem($tenant, 'Variant image', withoutInventory: true);
|
$variantItem = $this->createItem($tenant, 'Variant image');
|
||||||
$variantInventory = Inventory::query()->create(['real_stock' => 1]);
|
$variantInventory = Inventory::query()->create();
|
||||||
$variant = $variantItem->variants()->create(['inventory_id' => $variantInventory->id]);
|
$variant = $variantItem->variants()->create(['inventory_id' => $variantInventory->id]);
|
||||||
$variantImage = $this->createAttachment('variant');
|
$variantImage = $this->createAttachment('variant');
|
||||||
$variant->attachments()->attach($variantImage, ['orden' => 0]);
|
$variant->attachments()->attach($variantImage, ['orden' => 0]);
|
||||||
@@ -344,8 +348,10 @@ class CatalogControllerTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
$food = $this->createItem($tenant, 'Hamburger');
|
$food = $this->createItem($tenant, 'Hamburger');
|
||||||
|
$food->update(['group_order' => 2]);
|
||||||
$food->category()->associate($category)->save();
|
$food->category()->associate($category)->save();
|
||||||
$this->createItem($tenant, 'Parking');
|
$parking = $this->createItem($tenant, 'Parking');
|
||||||
|
$parking->update(['group_order' => 1]);
|
||||||
|
|
||||||
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
$this->getJson("/api/tenants/{$tenant->codigo}/catalog")
|
||||||
->assertOk()
|
->assertOk()
|
||||||
@@ -354,6 +360,9 @@ class CatalogControllerTest extends TestCase
|
|||||||
->assertJsonPath('0.items.0.nombre', 'Hamburger')
|
->assertJsonPath('0.items.0.nombre', 'Hamburger')
|
||||||
->assertJsonPath('1.title', 'All products')
|
->assertJsonPath('1.title', 'All products')
|
||||||
->assertJsonCount(2, '1.items.data')
|
->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);
|
->assertJsonPath('1.items.meta.total', 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,12 +433,7 @@ class CatalogControllerTest extends TestCase
|
|||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
string $name,
|
string $name,
|
||||||
?Inventory $inventory = null,
|
?Inventory $inventory = null,
|
||||||
bool $withoutInventory = false,
|
|
||||||
): CatalogItem {
|
): CatalogItem {
|
||||||
$inventory ??= $withoutInventory
|
|
||||||
? null
|
|
||||||
: Inventory::query()->create(['real_stock' => 10]);
|
|
||||||
|
|
||||||
return CatalogItem::query()->create([
|
return CatalogItem::query()->create([
|
||||||
'tenant_code' => $tenant->codigo,
|
'tenant_code' => $tenant->codigo,
|
||||||
'inventory_id' => $inventory?->id,
|
'inventory_id' => $inventory?->id,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user