refactor(backend): reorganize domains into Core, Commerce, Ticketing and Shared
This commit is contained in:
494
app/Domains/Commerce/Cart/Models/Cart.php
Normal file
494
app/Domains/Commerce/Cart/Models/Cart.php
Normal file
@@ -0,0 +1,494 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Cart\Models;
|
||||
|
||||
use App\Domains\Auth\Models\User;
|
||||
use App\Domains\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Catalog\Models\Inventory;
|
||||
use App\Domains\Catalog\Models\StockReservation;
|
||||
use App\Domains\Catalog\Models\Variant;
|
||||
use App\Domains\Catalog\Services\CatalogInventoryService;
|
||||
use App\Domains\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Purchase\Models\Purchase;
|
||||
use App\Domains\Purchase\Services\UserPurchaseLimitService;
|
||||
use App\Domains\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_codigo',
|
||||
'user_id',
|
||||
'guest_token',
|
||||
'status',
|
||||
'origin',
|
||||
'current_purchase_id',
|
||||
'current_stock_reservation_id',
|
||||
])]
|
||||
class Cart extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
use SoftDeletes;
|
||||
|
||||
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_DIRECT_CHECKOUT = 'direct_checkout';
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'user_id' => 'integer',
|
||||
'current_purchase_id' => 'integer',
|
||||
'current_stock_reservation_id' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<Tenant, $this>
|
||||
*/
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<CartItem, $this>
|
||||
*/
|
||||
public function items(): HasMany
|
||||
{
|
||||
return $this->hasMany(CartItem::class, 'cart_id');
|
||||
}
|
||||
|
||||
/** @return HasMany<Purchase, $this> */
|
||||
public function purchases(): HasMany
|
||||
{
|
||||
return $this->hasMany(Purchase::class, 'cart_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Purchase, $this> */
|
||||
public function currentPurchase(): BelongsTo
|
||||
{
|
||||
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
|
||||
{
|
||||
$items = $this->relationLoaded('items')
|
||||
? $this->getRelation('items')
|
||||
: $this->items()->with(['catalogItem', 'variant'])->get();
|
||||
|
||||
return (float) $items->reduce(
|
||||
fn (float $carry, CartItem $item): float => $carry
|
||||
+ (($item->selectedItem()?->getPrice() ?? 0) * $item->cantidad),
|
||||
0.0,
|
||||
);
|
||||
}
|
||||
|
||||
public function addItem(int $catalogItemId, ?int $variantId, int $quantity): CartItem
|
||||
{
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($catalogItemId, $variantId, $quantity): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
$selectedItem = $this->resolveScopedItem($catalogItemId, $variantId, true);
|
||||
$cartQuantity = (int) $this->items()
|
||||
->where('catalog_item_id', $catalogItemId)
|
||||
->sum('cantidad');
|
||||
$inventoryService = app(CatalogInventoryService::class);
|
||||
$availableQuantity = $inventoryService->availableQuantity($selectedItem);
|
||||
$this->assertUserPurchaseLimit(
|
||||
$selectedItem,
|
||||
$cartQuantity + $quantity,
|
||||
heldQuantity: $cartQuantity,
|
||||
maximumAddableCeiling: $availableQuantity,
|
||||
);
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var CartItem|null $item */
|
||||
$item = $this->items()
|
||||
->where('catalog_item_id', $catalogItemId)
|
||||
->where('variant_id', $variantId)
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($item === null) {
|
||||
$item = $this->items()->create([
|
||||
'catalog_item_id' => $catalogItemId,
|
||||
'variant_id' => $variantId,
|
||||
'cantidad' => $quantity,
|
||||
]);
|
||||
} else {
|
||||
$item->cantidad += $quantity;
|
||||
$item->save();
|
||||
}
|
||||
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function updateItem(
|
||||
int $cartItemId,
|
||||
int $quantity,
|
||||
?int $variantId = null,
|
||||
bool $updateVariant = false,
|
||||
?int $excludedPurchaseId = null,
|
||||
): CartItem {
|
||||
if ($quantity <= 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => __('api.cart.positive_quantity'),
|
||||
]);
|
||||
}
|
||||
|
||||
return DB::transaction(function () use (
|
||||
$cartItemId,
|
||||
$quantity,
|
||||
$variantId,
|
||||
$updateVariant,
|
||||
$excludedPurchaseId,
|
||||
): CartItem {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('id', $cartItemId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$currentSelection = $this->resolveScopedItem(
|
||||
$item->catalog_item_id,
|
||||
$item->variant_id,
|
||||
true,
|
||||
);
|
||||
$inventoryService = app(CatalogInventoryService::class);
|
||||
|
||||
if ($updateVariant && $variantId !== $item->variant_id) {
|
||||
$nextSelection = $this->resolveScopedItem(
|
||||
$item->catalog_item_id,
|
||||
$variantId,
|
||||
true,
|
||||
);
|
||||
$otherVariantsQuantity = (int) $this->items()
|
||||
->where('catalog_item_id', $item->catalog_item_id)
|
||||
->whereKeyNot($item->getKey())
|
||||
->sum('cantidad');
|
||||
$nextAvailableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||
$this->assertUserPurchaseLimit(
|
||||
$nextSelection,
|
||||
$otherVariantsQuantity + $quantity,
|
||||
$excludedPurchaseId,
|
||||
$otherVariantsQuantity + $item->cantidad,
|
||||
$nextAvailableQuantity,
|
||||
);
|
||||
|
||||
$availableQuantity = $inventoryService->availableQuantity($nextSelection);
|
||||
|
||||
if ($availableQuantity !== null && $availableQuantity < $quantity) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => __('api.cart.insufficient_stock', ['max' => $availableQuantity]),
|
||||
]);
|
||||
}
|
||||
|
||||
$targetItem = $this->items()
|
||||
->where('catalog_item_id', $item->catalog_item_id)
|
||||
->where('variant_id', $variantId)
|
||||
->whereKeyNot($item->getKey())
|
||||
->lockForUpdate()
|
||||
->first();
|
||||
|
||||
if ($targetItem !== null) {
|
||||
$targetItem->cantidad += $quantity;
|
||||
$targetItem->save();
|
||||
$item->delete();
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
|
||||
return $targetItem->fresh();
|
||||
}
|
||||
|
||||
$item->variant_id = $variantId;
|
||||
$item->cantidad = $quantity;
|
||||
$item->save();
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
|
||||
return $item->fresh();
|
||||
}
|
||||
|
||||
$delta = $quantity - $item->cantidad;
|
||||
$availableQuantity = $inventoryService->availableQuantity($currentSelection);
|
||||
|
||||
if ($delta > 0) {
|
||||
$otherVariantsQuantity = (int) $this->items()
|
||||
->where('catalog_item_id', $item->catalog_item_id)
|
||||
->whereKeyNot($item->getKey())
|
||||
->sum('cantidad');
|
||||
$this->assertUserPurchaseLimit(
|
||||
$currentSelection,
|
||||
$otherVariantsQuantity + $quantity,
|
||||
$excludedPurchaseId,
|
||||
$otherVariantsQuantity + $item->cantidad,
|
||||
$availableQuantity,
|
||||
);
|
||||
}
|
||||
|
||||
if ($delta > 0 && $availableQuantity !== null && $availableQuantity < $delta) {
|
||||
$maxAvailable = $availableQuantity + $item->cantidad;
|
||||
throw ValidationException::withMessages([
|
||||
'cantidad' => __('api.cart.max_quantity', ['max' => $maxAvailable]),
|
||||
]);
|
||||
}
|
||||
|
||||
$item->cantidad = $quantity;
|
||||
$item->save();
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
|
||||
return $item->fresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function removeItem(int $cartItemId): void
|
||||
{
|
||||
DB::transaction(function () use ($cartItemId): void {
|
||||
$this->invalidateCurrentCheckout();
|
||||
app(StockReservationService::class)->assertCartReservationUsable($this);
|
||||
|
||||
/** @var CartItem $item */
|
||||
$item = $this->items()
|
||||
->where('id', $cartItemId)
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
|
||||
$item->delete();
|
||||
app(StockReservationService::class)->syncCart($this);
|
||||
});
|
||||
}
|
||||
|
||||
private function invalidateCurrentCheckout(): void
|
||||
{
|
||||
$candidatePurchaseId = self::query()
|
||||
->whereKey($this->getKey())
|
||||
->value('current_purchase_id');
|
||||
$currentPurchase = $candidatePurchaseId === null
|
||||
? null
|
||||
: Purchase::query()->lockForUpdate()->find($candidatePurchaseId);
|
||||
|
||||
/** @var self $cart */
|
||||
$cart = self::query()->lockForUpdate()->findOrFail($this->getKey());
|
||||
|
||||
if ($cart->current_purchase_id !== $candidatePurchaseId) {
|
||||
if ($cart->current_purchase_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart' => __('api.purchase.checkout_in_progress'),
|
||||
]);
|
||||
}
|
||||
|
||||
$this->current_purchase_id = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($currentPurchase === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($currentPurchase->status === Purchase::STATUS_PAID) {
|
||||
throw ValidationException::withMessages([
|
||||
'cart' => __('api.cart.editing_disabled'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (in_array($currentPurchase->status, [
|
||||
Purchase::STATUS_CREATED,
|
||||
Purchase::STATUS_PENDING_PAYMENT,
|
||||
], true)) {
|
||||
app(StockReservationService::class)->returnToCart($currentPurchase, $cart);
|
||||
$currentPurchase->update([
|
||||
'status' => Purchase::STATUS_SUPERSEDED,
|
||||
]);
|
||||
|
||||
$this->current_purchase_id = null;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
app(StockReservationService::class)->releaseForPurchase(
|
||||
$currentPurchase,
|
||||
reason: StockReservationService::REASON_PURCHASE_SUPERSEDED,
|
||||
);
|
||||
self::query()
|
||||
->whereKey($cart->getKey())
|
||||
->where('current_purchase_id', $currentPurchase->getKey())
|
||||
->update(['current_purchase_id' => null]);
|
||||
$this->current_purchase_id = null;
|
||||
}
|
||||
|
||||
protected function resolveScopedItem(
|
||||
int $catalogItemId,
|
||||
?int $variantId,
|
||||
bool $lockForUpdate = false,
|
||||
): CatalogItem|Variant {
|
||||
$catalogItemQuery = CatalogItem::query()
|
||||
->whereKey($catalogItemId)
|
||||
->where('tenant_code', $this->tenant_codigo);
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$catalogItemQuery->lockForUpdate();
|
||||
}
|
||||
|
||||
$catalogItem = $catalogItemQuery->first();
|
||||
|
||||
if ($catalogItem === null) {
|
||||
throw new NotFoundHttpException('Catalog item not found for tenant.');
|
||||
}
|
||||
|
||||
if ($catalogItem->isBundle()) {
|
||||
if ($variantId !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => __('api.cart.bundle_variant_forbidden'),
|
||||
]);
|
||||
}
|
||||
|
||||
if (! $catalogItem->bundleComponents()->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'catalog_item_id' => __('api.cart.empty_bundle'),
|
||||
]);
|
||||
}
|
||||
|
||||
if ($catalogItem->bundleComponents()
|
||||
->whereNotNull('component_variant_id')
|
||||
->whereHas('variant', fn ($query) => $query
|
||||
->whereNotNull('sales_disabled_at')
|
||||
->orWhereNotNull('replaced_by_variant_id'))
|
||||
->exists()) {
|
||||
throw ValidationException::withMessages([
|
||||
'catalog_item_id' => [__('api.cart.bundle_component_unavailable')],
|
||||
]);
|
||||
}
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
if ($variantId === null) {
|
||||
if ($catalogItem->inventory_id === null) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => __('api.cart.variant_required'),
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory = $this->resolveInventory($catalogItem->inventory_id, $lockForUpdate);
|
||||
$catalogItem->setRelation('inventory', $inventory);
|
||||
|
||||
return $catalogItem;
|
||||
}
|
||||
|
||||
$variantQuery = Variant::query()
|
||||
->whereKey($variantId)
|
||||
->where('catalog_item_id', $catalogItem->id);
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$variantQuery->lockForUpdate();
|
||||
}
|
||||
|
||||
$variant = $variantQuery->first();
|
||||
|
||||
if ($variant === null) {
|
||||
throw new NotFoundHttpException('Variant not found for catalog item.');
|
||||
}
|
||||
|
||||
if (! $variant->isSellable()) {
|
||||
throw ValidationException::withMessages([
|
||||
'variant_id' => [__('api.cart.variant_unavailable')],
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory = $this->resolveInventory($variant->inventory_id, $lockForUpdate);
|
||||
$variant->setRelation('catalogItem', $catalogItem);
|
||||
$variant->setRelation('inventory', $inventory);
|
||||
|
||||
return $variant;
|
||||
}
|
||||
|
||||
private function assertUserPurchaseLimit(
|
||||
CatalogItem|Variant $selectedItem,
|
||||
int $cartQuantity,
|
||||
?int $excludedPurchaseId = null,
|
||||
int $heldQuantity = 0,
|
||||
?int $maximumAddableCeiling = null,
|
||||
): void {
|
||||
if ($this->user_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$catalogItem = $selectedItem instanceof Variant
|
||||
? $selectedItem->catalogItem
|
||||
: $selectedItem;
|
||||
|
||||
app(UserPurchaseLimitService::class)->assertCanPurchase(
|
||||
$catalogItem,
|
||||
$this->user_id,
|
||||
$cartQuantity,
|
||||
$excludedPurchaseId,
|
||||
$this->getKey(),
|
||||
$heldQuantity,
|
||||
$maximumAddableCeiling,
|
||||
field: 'cantidad',
|
||||
);
|
||||
}
|
||||
|
||||
protected function resolveInventory(int $inventoryId, bool $lockForUpdate): Inventory
|
||||
{
|
||||
$query = Inventory::query()->whereKey($inventoryId);
|
||||
|
||||
if ($lockForUpdate) {
|
||||
$query->lockForUpdate();
|
||||
}
|
||||
|
||||
return $query->firstOrFail();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user