Compare commits

..

6 Commits

116 changed files with 1120 additions and 1913 deletions

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Domains\Commerce\Catalog\Enums;
enum InventoryMovementOperation: string
{
case StockInitialized = 'stock_initialized';
case StockIncreased = 'stock_increased';
case StockDecreased = 'stock_decreased';
case StockReserved = 'stock_reserved';
case StockReleased = 'stock_released';
case PurchaseCommitted = 'purchase_committed';
case StockRefunded = 'stock_refunded';
case EntryStockReserved = 'entry_stock_reserved';
case EntryStockReleased = 'entry_stock_released';
case InventoryReset = 'inventory_reset';
case InventoryTransferred = 'inventory_transferred';
}

View File

@@ -19,6 +19,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Schema;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
@@ -213,10 +214,14 @@ class CatalogItem extends Model
/** @param Builder<CatalogItem> $query */ /** @param Builder<CatalogItem> $query */
public function scopeWhereAvailable(Builder $query): Builder public function scopeWhereAvailable(Builder $query): Builder
{ {
$availableInventory = static fn (Builder $inventoryQuery): Builder => Schema::hasColumn('inventories', 'available_stock')
? $inventoryQuery->where('inventories.available_stock', '>', 0)
: $inventoryQuery->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock');
return $query->where(function (Builder $query): void { return $query->where(function (Builder $query): void {
$query->whereNull('catalog_items.sales_end_at') $query->whereNull('catalog_items.sales_end_at')
->orWhere('catalog_items.sales_end_at', '>', now()); ->orWhere('catalog_items.sales_end_at', '>', now());
})->where(function (Builder $query): void { })->where(function (Builder $query) use ($availableInventory): void {
$query $query
->where(function (Builder $unlimitedQuery): void { ->where(function (Builder $unlimitedQuery): void {
$unlimitedQuery $unlimitedQuery
@@ -236,20 +241,18 @@ class CatalogItem extends Model
->whereNull('replaced_by_variant_id') ->whereNull('replaced_by_variant_id')
->whereHas( ->whereHas(
'inventory', 'inventory',
fn (Builder $inventoryQuery): Builder => $inventoryQuery $availableInventory
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
) )
) )
->orWhere(function (Builder $directItemQuery): void { ->orWhere(function (Builder $directItemQuery) use ($availableInventory): void {
$directItemQuery $directItemQuery
->whereDoesntHave('variants') ->whereDoesntHave('variants')
->where(function (Builder $inventoryQuery): void { ->where(function (Builder $inventoryQuery) use ($availableInventory): void {
$inventoryQuery $inventoryQuery
->whereNull('catalog_items.inventory_id') ->whereNull('catalog_items.inventory_id')
->orWhereHas( ->orWhereHas(
'inventory', 'inventory',
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery $availableInventory
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
); );
}); });
}); });

View File

@@ -2,11 +2,17 @@
namespace App\Domains\Commerce\Catalog\Models; namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Commerce\Catalog\Enums\InventoryMovementOperation;
use App\Domains\Core\Auth\Models\User;
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\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Validation\ValidationException;
#[Fillable([ #[Fillable([
'sold_units', 'sold_units',
@@ -38,6 +44,7 @@ class Inventory extends Model
'reserved_stock' => 'integer', 'reserved_stock' => 'integer',
'entry_reserved_stock' => 'integer', 'entry_reserved_stock' => 'integer',
'real_stock' => 'integer', 'real_stock' => 'integer',
'available_stock' => 'integer',
]; ];
} }
@@ -59,70 +66,295 @@ class Inventory extends Model
return $this->hasMany(StockReservationLine::class); return $this->hasMany(StockReservationLine::class);
} }
/** @return HasMany<InventoryMovement, $this> */
public function movements(): HasMany
{
return $this->hasMany(InventoryMovement::class);
}
public function availableStock(): int public function availableStock(): int
{ {
return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_stock); if (! array_key_exists('available_stock', $this->attributes)) {
return max(0, $this->real_stock - $this->reserved_stock - ($this->entry_reserved_stock ?? 0));
}
return (int) $this->getAttribute('available_stock');
} }
public function reserveEntry(int $amount, bool $tracksInventory): void public function reserveEntry(int $amount, bool $tracksInventory): void
{ {
if ($amount < 1 || ($tracksInventory && $this->availableStock() < $amount)) { $this->mutate(
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.'); InventoryMovementOperation::EntryStockReserved,
} ['entry_reserved_stock' => $amount],
$this->entry_reserved_stock += $amount; function (self $inventory) use ($amount, $tracksInventory): void {
$this->save(); if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) {
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.');
}
},
);
} }
public function releaseEntry(int $amount): void public function releaseEntry(int $amount): void
{ {
if ($amount < 1 || $this->entry_reserved_stock < $amount) { $this->mutate(
throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.'); InventoryMovementOperation::EntryStockReleased,
} ['entry_reserved_stock' => -$amount],
function (self $inventory) use ($amount): void {
$this->entry_reserved_stock -= $amount; if ($amount < 1 || $inventory->entry_reserved_stock < $amount) {
$this->save(); throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.');
}
},
);
} }
public function reserve(int $amount, bool $tracksInventory): void public function reserve(int $amount, bool $tracksInventory): void
{ {
if ($amount < 0) { $this->mutate(
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.'); InventoryMovementOperation::StockReserved,
} ['reserved_stock' => $amount],
function (self $inventory) use ($amount, $tracksInventory): void {
if ($tracksInventory && $this->availableStock() < $amount) { if ($amount < 0) {
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.'); throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
} }
if ($tracksInventory && $inventory->availableStock() < $amount) {
$this->reserved_stock += $amount; throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
$this->save(); }
},
);
} }
public function release(int $amount): void public function release(int $amount): void
{ {
if ($amount < 0 || $this->reserved_stock < $amount) { $this->mutate(
throw new \InvalidArgumentException('La cantidad reservada no es válida.'); InventoryMovementOperation::StockReleased,
} ['reserved_stock' => -$amount],
function (self $inventory) use ($amount): void {
$this->reserved_stock -= $amount; if ($amount < 0 || $inventory->reserved_stock < $amount) {
$this->save(); throw new \InvalidArgumentException('La cantidad reservada no es válida.');
}
},
);
} }
public function buy(int $amount, bool $tracksInventory): void public function buy(int $amount, bool $tracksInventory): void
{ {
if ($amount < 0 || $this->reserved_stock < $amount) { $counterDeltas = ['reserved_stock' => -$amount, 'sold_units' => $amount];
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.'); if (! array_key_exists('available_stock', $this->attributes) && $tracksInventory) {
$counterDeltas['real_stock'] = -$amount;
} }
if ($tracksInventory && $this->real_stock - $this->entry_reserved_stock < $amount) { $this->mutate(
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); InventoryMovementOperation::PurchaseCommitted,
$counterDeltas,
function (self $inventory) use ($amount, $tracksInventory): void {
if ($amount < 0 || $inventory->reserved_stock < $amount) {
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
}
if ($tracksInventory && $inventory->availableStock() < 0) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
}
},
);
}
public function adjustAvailableStock(
int $delta,
?User $responsibleUser = null,
?string $idempotencyKey = null,
): void {
if ($delta === 0) {
return;
} }
if ($tracksInventory) { $operation = $delta > 0
$this->real_stock -= $amount; ? InventoryMovementOperation::StockIncreased
: InventoryMovementOperation::StockDecreased;
$this->mutate(
$operation,
['real_stock' => $delta],
function (self $inventory) use ($delta): void {
if ($inventory->real_stock + $delta < 0 || $inventory->availableStock() + $delta < 0) {
throw ValidationException::withMessages([
'stock_difference' => ['El ajuste dejaría el stock en un valor inválido.'],
]);
}
},
$responsibleUser,
$idempotencyKey,
);
}
public function refundStock(int $amount, ?User $responsibleUser = null): void
{
$counterDeltas = ['refunded_units' => $amount];
if (! array_key_exists('available_stock', $this->attributes)) {
$counterDeltas['real_stock'] = $amount;
} }
$this->reserved_stock -= $amount; $this->mutate(
$this->sold_units += $amount; InventoryMovementOperation::StockRefunded,
$this->save(); $counterDeltas,
function () use ($amount): void {
if ($amount < 1) {
throw new \InvalidArgumentException('La cantidad devuelta debe ser positiva.');
}
},
$responsibleUser,
);
}
public function commitDirectSale(int $amount, bool $tracksInventory = true): void
{
$this->mutate(
InventoryMovementOperation::PurchaseCommitted,
['sold_units' => $amount],
function (self $inventory) use ($amount, $tracksInventory): void {
if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) {
throw new \InvalidArgumentException('No hay suficiente stock para confirmar la compra.');
}
},
);
}
public function recordInitialization(?User $responsibleUser = null): void
{
$this->recordCurrentState(InventoryMovementOperation::StockInitialized, $responsibleUser);
}
public function recordTransferInitialization(?User $responsibleUser = null): void
{
$this->recordCurrentState(InventoryMovementOperation::InventoryTransferred, $responsibleUser);
}
/** @param array<string, int> $counterDeltas */
public function transferCounters(array $counterDeltas, ?User $responsibleUser = null): void
{
$counterDeltas = array_filter($counterDeltas, fn (int $delta): bool => $delta !== 0);
if ($counterDeltas === []) {
return;
}
$this->mutate(
InventoryMovementOperation::InventoryTransferred,
$counterDeltas,
function (self $inventory) use ($counterDeltas): void {
foreach ($counterDeltas as $counter => $delta) {
if ((int) $inventory->getAttribute($counter) + $delta < 0) {
throw new \LogicException("El traslado dejaría {$counter} en un valor negativo.");
}
}
},
$responsibleUser,
);
}
private function recordCurrentState(
InventoryMovementOperation $operation,
?User $responsibleUser,
): void {
if (! Schema::hasTable('inventory_movements')) {
return;
}
$this->refresh();
$counterDeltas = array_filter([
'real_stock' => $this->real_stock,
'sold_units' => $this->sold_units,
'reserved_stock' => $this->reserved_stock,
'entry_reserved_stock' => $this->entry_reserved_stock,
'refunded_units' => $this->refunded_units,
], fn (int $delta): bool => $delta !== 0);
$this->movements()->create([
'operation' => $operation,
'available_stock_delta' => $this->availableStock(),
'available_stock_before' => 0,
'available_stock_after' => $this->availableStock(),
'counter_deltas' => $counterDeltas,
'responsible_user_id' => $responsibleUser?->getKey() ?? Auth::id(),
]);
}
public function resetTransactionCounters(?User $responsibleUser = null): void
{
$this->refresh();
$counterDeltas = array_filter([
'sold_units' => -$this->sold_units,
'reserved_stock' => -$this->reserved_stock,
'entry_reserved_stock' => -$this->entry_reserved_stock,
'refunded_units' => -$this->refunded_units,
], fn (int $delta): bool => $delta !== 0);
if ($counterDeltas === []) {
return;
}
$this->mutate(
InventoryMovementOperation::InventoryReset,
$counterDeltas,
static function (): void {},
$responsibleUser,
);
}
/**
* @param array<string, int> $counterDeltas
* @param callable(self): void $validate
*/
private function mutate(
InventoryMovementOperation $operation,
array $counterDeltas,
callable $validate,
?User $responsibleUser = null,
?string $idempotencyKey = null,
): void {
DB::transaction(function () use ($operation, $counterDeltas, $validate, $responsibleUser, $idempotencyKey): void {
/** @var self $inventory */
$inventory = self::query()->lockForUpdate()->findOrFail($this->getKey());
$this->setRawAttributes($inventory->getAttributes(), true);
$recordsMovements = Schema::hasTable('inventory_movements');
if ($recordsMovements && $idempotencyKey !== null) {
$existing = $this->movements()
->where('idempotency_key', $idempotencyKey)
->first();
if ($existing !== null) {
if ($existing->counter_deltas !== $counterDeltas) {
throw ValidationException::withMessages([
'stock_adjustment_id' => ['La operación ya fue utilizada con otro ajuste.'],
]);
}
return;
}
}
$validate($this);
$counterDeltas = array_filter($counterDeltas, fn (int $delta): bool => $delta !== 0);
if ($counterDeltas === []) {
return;
}
$availableBefore = $this->availableStock();
foreach ($counterDeltas as $counter => $delta) {
$this->setAttribute($counter, (int) $this->getAttribute($counter) + $delta);
}
$this->save();
$this->refresh();
$availableAfter = $this->availableStock();
if ($recordsMovements) {
$this->movements()->create([
'operation' => $operation,
'available_stock_delta' => $availableAfter - $availableBefore,
'available_stock_before' => $availableBefore,
'available_stock_after' => $availableAfter,
'counter_deltas' => $counterDeltas,
'responsible_user_id' => $responsibleUser?->getKey() ?? Auth::id(),
'idempotency_key' => $idempotencyKey,
]);
}
});
} }
} }

View File

@@ -0,0 +1,45 @@
<?php
namespace App\Domains\Commerce\Catalog\Models;
use App\Domains\Commerce\Catalog\Enums\InventoryMovementOperation;
use App\Domains\Core\Auth\Models\User;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'inventory_id',
'operation',
'available_stock_delta',
'available_stock_before',
'available_stock_after',
'counter_deltas',
'responsible_user_id',
'idempotency_key',
])]
class InventoryMovement extends Model
{
protected function casts(): array
{
return [
'operation' => InventoryMovementOperation::class,
'available_stock_delta' => 'integer',
'available_stock_before' => 'integer',
'available_stock_after' => 'integer',
'counter_deltas' => 'array',
];
}
/** @return BelongsTo<Inventory, $this> */
public function inventory(): BelongsTo
{
return $this->belongsTo(Inventory::class);
}
/** @return BelongsTo<User, $this> */
public function responsibleUser(): BelongsTo
{
return $this->belongsTo(User::class, 'responsible_user_id');
}
}

View File

@@ -64,6 +64,7 @@ class CatalogInventoryService
$selection->loadMissing('variants.inventory'); $selection->loadMissing('variants.inventory');
return $selection->variants return $selection->variants
->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $selection))
->filter(fn (Variant $variant): bool => $variant->isSellable()) ->filter(fn (Variant $variant): bool => $variant->isSellable())
->unique(fn (Variant $variant): string => $variant->inventory_id === null ->unique(fn (Variant $variant): string => $variant->inventory_id === null
? 'object:'.spl_object_id($variant->inventory) ? 'object:'.spl_object_id($variant->inventory)
@@ -153,7 +154,7 @@ class CatalogInventoryService
if ($operation === 'commit' if ($operation === 'commit'
&& $requirement['tracks_inventory'] && $requirement['tracks_inventory']
&& $inventory->real_stock - $inventory->entry_reserved_stock < $requiredQuantity) { && $inventory->availableStock() < 0) {
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.'); throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
} }
} }

View File

@@ -370,9 +370,12 @@ class CatalogService
private function createInventory(int $realStock): Inventory private function createInventory(int $realStock): Inventory
{ {
return Inventory::query()->create([ $inventory = Inventory::query()->create([
'real_stock' => $realStock, 'real_stock' => $realStock,
]); ]);
$inventory->recordInitialization();
return $inventory;
} }
/** /**

View File

@@ -225,7 +225,7 @@ class StockReservationService
$inventory = $inventories->get($line->inventory_id) $inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.'); ?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
if ($inventory->reserved_stock < $line->quantity if ($inventory->reserved_stock < $line->quantity
|| ($line->tracks_inventory && $inventory->real_stock - $inventory->entry_reserved_stock < $line->quantity)) { || ($line->tracks_inventory && $inventory->availableStock() < 0)) {
throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.'); throw new \InvalidArgumentException('La reserva de stock no alcanza para confirmar la compra.');
} }
} }

View File

@@ -194,6 +194,7 @@ class VariantReplacementService
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock, 'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
'real_stock' => $sourceInventory->real_stock, 'real_stock' => $sourceInventory->real_stock,
]); ]);
$replacementInventory->recordTransferInitialization();
if ($activeLines->isNotEmpty()) { if ($activeLines->isNotEmpty()) {
StockReservationLine::query() StockReservationLine::query()
@@ -202,7 +203,10 @@ class VariantReplacementService
} }
EntryReservation::query()->where('inventory_id', $sourceInventory->id) EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $replacementInventory->id]); ->update(['inventory_id' => $replacementInventory->id]);
$sourceInventory->update(['reserved_stock' => 0, 'entry_reserved_stock' => 0]); $sourceInventory->transferCounters([
'reserved_stock' => -$sourceInventory->reserved_stock,
'entry_reserved_stock' => -$sourceInventory->entry_reserved_stock,
]);
return $replacementInventory; return $replacementInventory;
} }
@@ -236,13 +240,14 @@ class VariantReplacementService
throw new \LogicException('El inventario reservado de la variante es inconsistente.'); throw new \LogicException('El inventario reservado de la variante es inconsistente.');
} }
$destinationInventory->update([ $transferredCounters = [
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock, 'real_stock' => $sourceInventory->real_stock,
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock, 'reserved_stock' => $sourceInventory->reserved_stock,
'entry_reserved_stock' => $destinationInventory->entry_reserved_stock + $sourceInventory->entry_reserved_stock, 'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units, 'sold_units' => $sourceInventory->sold_units,
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units, 'refunded_units' => $sourceInventory->refunded_units,
]); ];
$destinationInventory->transferCounters($transferredCounters);
if ($activeLines->isNotEmpty()) { if ($activeLines->isNotEmpty()) {
StockReservationLine::query() StockReservationLine::query()
->whereKey($activeLines->modelKeys()) ->whereKey($activeLines->modelKeys())
@@ -250,13 +255,10 @@ class VariantReplacementService
} }
EntryReservation::query()->where('inventory_id', $sourceInventory->id) EntryReservation::query()->where('inventory_id', $sourceInventory->id)
->update(['inventory_id' => $destinationInventory->id]); ->update(['inventory_id' => $destinationInventory->id]);
$sourceInventory->update([ $sourceInventory->transferCounters(array_map(
'real_stock' => 0, fn (int $value): int => -$value,
'reserved_stock' => 0, $transferredCounters,
'entry_reserved_stock' => 0, ));
'sold_units' => 0,
'refunded_units' => 0,
]);
} }
/** @return list<string> */ /** @return list<string> */

View File

@@ -34,8 +34,3 @@ Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenan
## Dependencias y reglas ## Dependencias y reglas
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad. Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
## Menús deprecados
Los menús `adminapp.combos` y `adminapp.categories` están retirados; las
categorías del catálogo y sus datos comerciales no se eliminan.

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Commerce\Purchase\Controllers; namespace App\Domains\Commerce\Purchase\Controllers;
use App\Shared\Integration\Services\TelepagosIntegrationService;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest; use App\Domains\Commerce\Purchase\Requests\PaymentIntentRequest;
use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest; use App\Domains\Commerce\Purchase\Requests\StartCheckoutRequest;
@@ -12,7 +13,6 @@ use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard; use App\Domains\Commerce\Purchase\Services\PurchaseStateGuard;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Shared\Integration\Services\TelepagosIntegrationService;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -151,14 +151,11 @@ class PurchaseController extends Controller
} }
$compra->refresh(); $compra->refresh();
$compra->loadMissing('event');
$totalAmount = (float) $compra->total; $totalAmount = (float) $compra->total;
if ($method === 'transfer') { if ($method === 'transfer') {
$telepagosService = new TelepagosIntegrationService; $telepagosService = new TelepagosIntegrationService;
$compra->event $telepagosService->forTenant($tenant->codigo);
? $telepagosService->forEvent($compra->event)
: $telepagosService->forTenant($tenant->codigo);
try { try {
$accountInfo = $telepagosService->getAccountInfo(); $accountInfo = $telepagosService->getAccountInfo();
@@ -194,9 +191,7 @@ class PurchaseController extends Controller
if ($method === 'qr') { if ($method === 'qr') {
$telepagosService = new TelepagosIntegrationService; $telepagosService = new TelepagosIntegrationService;
$compra->event $telepagosService->forTenant($tenant->codigo);
? $telepagosService->forEvent($compra->event)
: $telepagosService->forTenant($tenant->codigo);
try { try {
Log::channel('telepagos')->info('Generating Telepagos QR.', [ Log::channel('telepagos')->info('Generating Telepagos QR.', [

View File

@@ -2,14 +2,13 @@
namespace App\Domains\Commerce\Purchase\Models; namespace App\Domains\Commerce\Purchase\Models;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Models\Cart; use App\Domains\Commerce\Cart\Models\Cart;
use App\Domains\Commerce\Catalog\Models\StockReservation; use App\Domains\Commerce\Catalog\Models\StockReservation;
use App\Domains\Commerce\Purchase\Events\PurchasePaid;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Shared\Logging\Models\Concerns\LogsValueChanges; use App\Shared\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Commerce\Purchase\Events\PurchasePaid;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket;
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;
@@ -22,7 +21,6 @@ use Illuminate\Support\Facades\DB;
#[Fillable([ #[Fillable([
'cart_id', 'cart_id',
'stock_reservation_id', 'stock_reservation_id',
'event_id',
'tenant_codigo', 'tenant_codigo',
'user_id', 'user_id',
'status', 'status',
@@ -151,7 +149,6 @@ class Purchase extends Model
return [ return [
'cart_id' => 'integer', 'cart_id' => 'integer',
'stock_reservation_id' => 'integer', 'stock_reservation_id' => 'integer',
'event_id' => 'integer',
'user_id' => 'integer', 'user_id' => 'integer',
'total' => 'decimal:2', 'total' => 'decimal:2',
]; ];
@@ -165,12 +162,6 @@ class Purchase extends Model
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
} }
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/** /**
* @return BelongsTo<User, $this> * @return BelongsTo<User, $this>
*/ */

View File

@@ -36,11 +36,11 @@ class PurchaseResource extends JsonResource
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
/** @var Event|null $checkoutEvent */ /** @var Event|null $checkoutEvent */
$checkoutEvent = $usesEventSummary $checkoutEvent = $usesEventSummary
? ($this->event ?? $items ? $items
->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event) ->map(fn (PurchaseItem $item) => $item->sourceCatalogItem?->event)
->filter() ->filter()
->unique('id') ->unique('id')
->first()) ->first()
: null; : null;
$subtotal = $items->isNotEmpty() $subtotal = $items->isNotEmpty()
@@ -62,7 +62,6 @@ class PurchaseResource extends JsonResource
return [ return [
'id' => $this->id, 'id' => $this->id,
'cart_id' => $this->cart_id, 'cart_id' => $this->cart_id,
'event_id' => $this->event_id,
'tenant_codigo' => $this->tenant_codigo, 'tenant_codigo' => $this->tenant_codigo,
'user_id' => $this->user_id, 'user_id' => $this->user_id,
'created_at' => $this->created_at, 'created_at' => $this->created_at,

View File

@@ -14,7 +14,6 @@ class PurchaseResponseLoader
=== StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT; === StorefrontWebsiteType::CHECKOUT_SUMMARY_EVENT;
$relations = [ $relations = [
'tenant', 'tenant',
'event.attachment',
'items.sourceCatalogItem.event.attachment', 'items.sourceCatalogItem.event.attachment',
'stockReservation', 'stockReservation',
]; ];

View File

@@ -206,7 +206,6 @@ class StartCheckoutService
fn (array $line): float => $line['selection']->getPrice() * $line['quantity'], fn (array $line): float => $line['selection']->getPrice() * $line['quantity'],
), ),
$cart->getKey(), $cart->getKey(),
$cartItems->first()?->catalogItem?->event_id,
); );
$cart->update(['current_purchase_id' => $purchase->getKey()]); $cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
@@ -274,7 +273,6 @@ class StartCheckoutService
$purchaseData, $purchaseData,
$cart->getTotalAmount(), $cart->getTotalAmount(),
$cart->getKey(), $cart->getKey(),
$cartItems->first()?->catalogItem?->event_id,
); );
$cart->update(['current_purchase_id' => $purchase->getKey()]); $cart->update(['current_purchase_id' => $purchase->getKey()]);
$this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration()); $this->reservations->attachToPurchase($cart, $purchase, $this->checkoutExpiration());
@@ -412,12 +410,10 @@ class StartCheckoutService
array $purchaseData, array $purchaseData,
float $total, float $total,
?int $cartId, ?int $cartId,
?int $eventId,
): Purchase { ): Purchase {
return Purchase::query()->create([ return Purchase::query()->create([
...$purchaseData, ...$purchaseData,
'cart_id' => $cartId, 'cart_id' => $cartId,
'event_id' => $eventId,
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
'user_id' => $userId, 'user_id' => $userId,
'status' => Purchase::STATUS_CREATED, 'status' => Purchase::STATUS_CREATED,

View File

@@ -8,13 +8,12 @@ use Illuminate\Database\Eloquent\Builder;
class PurchaseRefundSummaryService class PurchaseRefundSummaryService
{ {
public function totalForTenant(Tenant $tenant, ?int $eventId = null): string public function totalForTenant(Tenant $tenant): string
{ {
$total = TicketRefund::query() $total = TicketRefund::query()
->whereHas( ->whereHas(
'purchaseItem.purchase', 'purchaseItem.purchase',
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo) fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $purchase) => $purchase->where('event_id', $eventId))
) )
->sum('amount'); ->sum('amount');

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Commerce\Purchase\Services; namespace App\Domains\Commerce\Purchase\Services;
use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use Illuminate\Database\Query\Builder; use Illuminate\Database\Query\Builder;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -69,15 +70,12 @@ class TenantTransactionResetService
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(), 'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
]; ];
DB::table('inventories') Inventory::query()
->whereIn('id', $scope['inventory_ids']) ->whereKey($scope['inventory_ids'])
->update([ ->orderBy('id')
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'), ->lockForUpdate()
'reserved_stock' => 0, ->get()
'entry_reserved_stock' => 0, ->each(fn (Inventory $inventory) => $inventory->resetTransactionCounters());
'sold_units' => 0,
'refunded_units' => 0,
]);
return $summary; return $summary;
}); });

View File

@@ -13,7 +13,6 @@ use App\Domains\Commerce\Sale\Resources\AdminApp\SaleTicketResource;
use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService; use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService;
use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService; use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService;
use App\Domains\Commerce\Sale\Services\AdminAppSaleService; use App\Domains\Commerce\Sale\Services\AdminAppSaleService;
use App\Domains\Core\Auth\Services\EventScopeService;
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;
@@ -28,20 +27,15 @@ class SaleController extends Controller
protected AdminAppSaleExcelService $saleExcelService, protected AdminAppSaleExcelService $saleExcelService,
) {} ) {}
private function eventId(Request $request): ?int
{
return app(EventScopeService::class)->eventId($request->user());
}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return SaleResource::collection( return SaleResource::collection(
$this->saleService->sales($tenant, $request->validated(), $this->eventId($request)) $this->saleService->sales($tenant, $request->validated())
)->additional([ )->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $this->eventId($request)), 'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
'refunded_total' => $this->saleService->refundedTotal($tenant, $this->eventId($request)), 'refunded_total' => $this->saleService->refundedTotal($tenant),
]); ]);
} }
@@ -49,7 +43,7 @@ class SaleController extends Controller
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleDetailResource($this->saleService->detail($tenant, $sale, $this->eventId($request))); return new SaleDetailResource($this->saleService->detail($tenant, $sale));
} }
public function tickets(Request $request, int $sale): AnonymousResourceCollection public function tickets(Request $request, int $sale): AnonymousResourceCollection
@@ -57,7 +51,7 @@ class SaleController extends Controller
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return SaleTicketResource::collection( return SaleTicketResource::collection(
$this->saleService->tickets($tenant, $sale, $this->eventId($request)) $this->saleService->tickets($tenant, $sale)
); );
} }
@@ -65,14 +59,14 @@ class SaleController extends Controller
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->confirm($tenant, $sale, $this->eventId($request))); return new SaleResource($this->saleService->confirm($tenant, $sale));
} }
public function cancel(Request $request, int $sale): SaleResource public function cancel(Request $request, int $sale): SaleResource
{ {
$tenant = $request->user()->tenant()->firstOrFail(); $tenant = $request->user()->tenant()->firstOrFail();
return new SaleResource($this->saleService->cancel($tenant, $sale, $this->eventId($request))); return new SaleResource($this->saleService->cancel($tenant, $sale));
} }
public function modifications( public function modifications(
@@ -82,7 +76,6 @@ class SaleController extends Controller
$this->saleService->modifications( $this->saleService->modifications(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
$this->eventId($request),
) )
); );
} }
@@ -93,7 +86,7 @@ class SaleController extends Controller
return $this->salePdfService->downloadSales( return $this->salePdfService->downloadSales(
$tenant, $tenant,
$this->saleService->salesForExport($tenant, $request->validated(), $this->eventId($request)), $this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -104,7 +97,7 @@ class SaleController extends Controller
return $this->salePdfService->downloadModifications( return $this->salePdfService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant, $request->validated(), $this->eventId($request)), $this->saleService->modificationsForExport($tenant, $request->validated()),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -115,7 +108,7 @@ class SaleController extends Controller
return $this->saleExcelService->downloadSales( return $this->saleExcelService->downloadSales(
$tenant, $tenant,
$this->saleService->salesForExport($tenant, $request->validated(), $this->eventId($request)), $this->saleService->salesForExport($tenant, $request->validated()),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }
@@ -127,7 +120,7 @@ class SaleController extends Controller
return $this->saleExcelService->downloadModifications( return $this->saleExcelService->downloadModifications(
$tenant, $tenant,
$this->saleService->modificationsForExport($tenant, $request->validated(), $this->eventId($request)), $this->saleService->modificationsForExport($tenant, $request->validated()),
$request->validated('timezone'), $request->validated('timezone'),
); );
} }

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Commerce\Sale\Services; namespace App\Domains\Commerce\Sale\Services;
use App\Shared\Logging\Models\ValueChange;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService;
@@ -9,7 +10,6 @@ use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
use App\Shared\Logging\Models\ValueChange;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
@@ -21,20 +21,19 @@ class AdminAppSaleService
protected PurchaseRefundSummaryService $refundSummaryService, protected PurchaseRefundSummaryService $refundSummaryService,
) {} ) {}
public function confirmedSalesTotal(Tenant $tenant, ?int $eventId = null): string public function confirmedSalesTotal(Tenant $tenant): string
{ {
$total = Purchase::query() $total = Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->where('status', Purchase::STATUS_PAID) ->where('status', Purchase::STATUS_PAID)
->sum('total'); ->sum('total');
return number_format((float) $total, 2, '.', ''); return number_format((float) $total, 2, '.', '');
} }
public function refundedTotal(Tenant $tenant, ?int $eventId = null): string public function refundedTotal(Tenant $tenant): string
{ {
return $this->refundSummaryService->totalForTenant($tenant, $eventId); return $this->refundSummaryService->totalForTenant($tenant);
} }
/** /**
@@ -48,44 +47,43 @@ class AdminAppSaleService
* } $filters * } $filters
* @return LengthAwarePaginator<Purchase> * @return LengthAwarePaginator<Purchase>
*/ */
public function sales(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator
{ {
return $this->salesQuery($tenant, $filters, $eventId) return $this->salesQuery($tenant, $filters)
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
} }
public function detail(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase public function detail(Tenant $tenant, int $saleId): Purchase
{ {
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->with('items') ->with('items')
->findOrFail($saleId); ->findOrFail($saleId);
} }
/** @return Collection<int, Ticket> */ /** @return Collection<int, Ticket> */
public function tickets(Tenant $tenant, int $saleId, ?int $eventId = null): Collection public function tickets(Tenant $tenant, int $saleId): Collection
{ {
return $this->findForTenant($tenant, $saleId, $eventId) return $this->findForTenant($tenant, $saleId)
->tickets() ->tickets()
->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund']) ->with([...TicketValidityResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS, 'refund'])
->orderBy('id') ->orderBy('id')
->get(); ->get();
} }
public function confirm(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase public function confirm(Tenant $tenant, int $saleId): Purchase
{ {
$sale = $this->findForTenant($tenant, $saleId, $eventId); $sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse( return $this->saleForResponse(
$this->checkoutService->confirmPaidPurchase($sale) $this->checkoutService->confirmPaidPurchase($sale)
); );
} }
public function cancel(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase public function cancel(Tenant $tenant, int $saleId): Purchase
{ {
$sale = $this->findForTenant($tenant, $saleId, $eventId); $sale = $this->findForTenant($tenant, $saleId);
return $this->saleForResponse( return $this->saleForResponse(
$this->checkoutService->cancelPurchaseFromAdmin($sale) $this->checkoutService->cancelPurchaseFromAdmin($sale)
@@ -96,18 +94,18 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Collection<int, Purchase> * @return Collection<int, Purchase>
*/ */
public function salesForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection public function salesForExport(Tenant $tenant, array $filters = []): Collection
{ {
return $this->salesQuery($tenant, $filters, $eventId)->get(); return $this->salesQuery($tenant, $filters)->get();
} }
/** /**
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return LengthAwarePaginator<ValueChange> * @return LengthAwarePaginator<ValueChange>
*/ */
public function modifications(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
{ {
return $this->modificationsQuery($tenant, $filters, $eventId) return $this->modificationsQuery($tenant, $filters)
->paginateFromRequest() ->paginateFromRequest()
->withQueryString(); ->withQueryString();
} }
@@ -116,13 +114,13 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Collection<int, ValueChange> * @return Collection<int, ValueChange>
*/ */
public function modificationsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection public function modificationsForExport(Tenant $tenant, array $filters = []): Collection
{ {
return $this->modificationsQuery($tenant, $filters, $eventId)->get(); return $this->modificationsQuery($tenant, $filters)->get();
} }
/** @param array<string, mixed> $filters */ /** @param array<string, mixed> $filters */
protected function salesQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder protected function salesQuery(Tenant $tenant, array $filters): Builder
{ {
$sortColumns = [ $sortColumns = [
'id' => 'id', 'id' => 'id',
@@ -141,7 +139,6 @@ class AdminAppSaleService
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->when($filters['q'] ?? null, function (Builder $query, string $search): void { ->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search); $term = trim($search);
@@ -179,14 +176,11 @@ class AdminAppSaleService
* @param array<string, mixed> $filters * @param array<string, mixed> $filters
* @return Builder<ValueChange> * @return Builder<ValueChange>
*/ */
protected function modificationsQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder protected function modificationsQuery(Tenant $tenant, array $filters): Builder
{ {
return ValueChange::query() return ValueChange::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->where('trackable_type', (new Purchase)->getMorphClass()) ->where('trackable_type', (new Purchase)->getMorphClass())
->when($eventId !== null, fn (Builder $query) => $query->whereHasMorph(
'trackable', [Purchase::class],
fn (Builder $sales) => $sales->where('event_id', $eventId)))
->when($filters['q'] ?? null, function (Builder $query, string $search): void { ->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search); $term = trim($search);
@@ -227,11 +221,10 @@ class AdminAppSaleService
->orderByDesc('id'); ->orderByDesc('id');
} }
protected function findForTenant(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase protected function findForTenant(Tenant $tenant, int $saleId): Purchase
{ {
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
->findOrFail($saleId); ->findOrFail($saleId);
} }

View File

@@ -30,10 +30,3 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel. La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta. El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta.
## Alcance por evento
Las rutas usan `adminapp.tenant:event`. Para admins de evento, listados, totales,
detalles, tickets de compras, confirmación, cancelación, historial y exportaciones
se filtran por `user.event_id` además del tenant. Las compras pertenecen a un
único evento. Un admin de tenant conserva acceso a sus compras de todos los eventos.

View File

@@ -4,7 +4,7 @@ use App\Domains\Commerce\Sale\Controllers\AdminApp\SaleController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant:event']) ->middleware(['auth:sanctum', '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']);

View File

@@ -1,9 +0,0 @@
<?php
namespace App\Domains\Core\Auth\Enums;
enum AdminScope: string
{
case Tenant = 'tenant';
case Event = 'event';
}

View File

@@ -2,13 +2,11 @@
namespace App\Domains\Core\Auth\Models; namespace App\Domains\Core\Auth\Models;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Auth\Enums\AdminScope;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Core\Authorization\Models\Role; use App\Domains\Core\Authorization\Models\Role;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventDateChangeView; use App\Domains\Ticketing\Event\Models\EventDateChangeView;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\ScanAttempt; use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
use Database\Factories\UserFactory; use Database\Factories\UserFactory;
use Illuminate\Database\Eloquent\Attributes\Fillable; use Illuminate\Database\Eloquent\Attributes\Fillable;
@@ -22,7 +20,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo', 'admin_scope', 'event_id'])] #[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo'])]
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])] #[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
class User extends Authenticatable class User extends Authenticatable
{ {
@@ -31,7 +29,6 @@ class User extends Authenticatable
protected $attributes = [ protected $attributes = [
'rol_codigo' => RoleCode::User->value, 'rol_codigo' => RoleCode::User->value,
'admin_scope' => AdminScope::Tenant->value,
]; ];
protected static function newFactory(): UserFactory protected static function newFactory(): UserFactory
@@ -89,19 +86,6 @@ class User extends Authenticatable
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
} }
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
public function isTenantAdministrator(): bool
{
return $this->rol_codigo === RoleCode::AdminApp->value
&& $this->admin_scope === AdminScope::Tenant->value
&& $this->event_id === null;
}
/** @return BelongsToMany<Category, $this> */ /** @return BelongsToMany<Category, $this> */
public function scanCategories(): BelongsToMany public function scanCategories(): BelongsToMany
{ {
@@ -119,7 +103,6 @@ class User extends Authenticatable
protected function casts(): array protected function casts(): array
{ {
return [ return [
'event_id' => 'integer',
'email_verified_at' => 'datetime', 'email_verified_at' => 'datetime',
'password' => 'hashed', 'password' => 'hashed',
'failed_login_attempts' => 'integer', 'failed_login_attempts' => 'integer',

View File

@@ -20,11 +20,6 @@ class AdminAppMeResource extends JsonResource
return [ return [
'user' => UserResource::make($this->resource), 'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant), 'tenant' => TenantResource::make($this->tenant),
'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [
'id' => $this->event->id,
'title' => $this->event->title,
'tenant_code' => $this->event->tenant_code,
]),
]; ];
} }
} }

View File

@@ -16,9 +16,6 @@ class ScannerMeResource extends JsonResource
return [ return [
'user' => UserResource::make($this->resource), 'user' => UserResource::make($this->resource),
'tenant' => TenantResource::make($this->tenant), 'tenant' => TenantResource::make($this->tenant),
'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [
'id' => $this->event->id, 'title' => $this->event->title,
]),
]; ];
} }
} }

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Core\Auth\Resources; namespace App\Domains\Core\Auth\Resources;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
@@ -25,8 +24,6 @@ class UserResource extends JsonResource
'telefono' => $this->telefono, 'telefono' => $this->telefono,
'rol_codigo' => $this->rol_codigo, 'rol_codigo' => $this->rol_codigo,
'tenant_codigo' => $this->tenant_codigo, 'tenant_codigo' => $this->tenant_codigo,
'admin_scope' => $this->when($this->rol_codigo === RoleCode::AdminApp->value, $this->admin_scope),
'event_id' => $this->when(in_array($this->rol_codigo, [RoleCode::AdminApp->value, RoleCode::Scanner->value], true), $this->event_id),
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories 'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
->map(fn ($category) => [ ->map(fn ($category) => [
'id' => $category->id, 'id' => $category->id,

View File

@@ -1,27 +0,0 @@
<?php
namespace App\Domains\Core\Auth\Services;
use App\Domains\Core\Auth\Enums\AdminScope;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode;
class AdminAppAccessService
{
public function hasValidScope(User $user): bool
{
if ($user->rol_codigo !== RoleCode::AdminApp->value
|| ! $user->tenant_codigo
|| ! $user->tenant()->exists()) {
return false;
}
if ($user->isTenantAdministrator()) {
return true;
}
return $user->admin_scope === AdminScope::Event->value
&& $user->event_id !== null
&& $user->event()->where('tenant_code', $user->tenant_codigo)->exists();
}
}

View File

@@ -20,7 +20,6 @@ class AdminAppContextService
->firstOrFail(); ->firstOrFail();
$user->setRelation('tenant', $tenant); $user->setRelation('tenant', $tenant);
$user->load('event');
return $user; return $user;
} }

View File

@@ -1,24 +0,0 @@
<?php
namespace App\Domains\Core\Auth\Services;
use App\Domains\Core\Auth\Enums\AdminScope;
use App\Domains\Core\Auth\Models\User;
use Illuminate\Auth\Access\AuthorizationException;
class EventScopeService
{
public function eventId(User $user): ?int
{
if ($user->admin_scope === AdminScope::Event->value || $user->event_id !== null) {
if ($user->event_id === null
|| ! $user->event()->where('tenant_code', $user->tenant_codigo)->exists()) {
throw new AuthorizationException;
}
return $user->event_id;
}
return null;
}
}

View File

@@ -10,7 +10,6 @@ use App\Domains\Core\Authorization\Enums\PermissionCode;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Shared\Notification\Events\PasswordResetRequested; use App\Shared\Notification\Events\PasswordResetRequested;
use Carbon\CarbonImmutable; use Carbon\CarbonImmutable;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -20,7 +19,6 @@ class PasswordLoginService
{ {
public function __construct( public function __construct(
private readonly ResetPasswordAttemptService $resetPasswordAttemptService, private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
private readonly AdminAppAccessService $adminAppAccessService,
) {} ) {}
/** /**
@@ -198,26 +196,6 @@ class PasswordLoginService
]; ];
} }
if ($requiredRole === RoleCode::AdminApp && ! $this->adminAppAccessService->hasValidScope($user)) {
$this->recordAttempt(
$user, $normalizedEmail, $attemptTenantCode,
LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent,
);
return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null];
}
if ($requiredRole === RoleCode::Scanner) {
try {
app(EventScopeService::class)->eventId($user);
} catch (AuthorizationException) {
$this->recordAttempt($user, $normalizedEmail, $attemptTenantCode,
LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent);
return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null];
}
}
$user->forceFill([ $user->forceFill([
'failed_login_attempts' => 0, 'failed_login_attempts' => 0,
'last_failed_login_at' => null, 'last_failed_login_at' => null,

View File

@@ -18,7 +18,6 @@ class ScannerContextService
->firstOrFail(); ->firstOrFail();
$user->setRelation('tenant', $tenant); $user->setRelation('tenant', $tenant);
$user->load('event');
if ($tenant->requiresScannerCategoryValidation()) { if ($tenant->requiresScannerCategoryValidation()) {
$categories = $user->scanCategories() $categories = $user->scanCategories()

View File

@@ -17,6 +17,6 @@ Route::prefix('v1/adminapp')->group(function (): void {
Route::post('password/reset', ResetPasswordController::class) Route::post('password/reset', ResetPasswordController::class)
->defaults('reset_role', 'adminapp') ->defaults('reset_role', 'adminapp')
->middleware('throttle:5,1'); ->middleware('throttle:5,1');
Route::middleware(['auth:sanctum', 'adminapp.tenant:context']) Route::middleware(['auth:sanctum', 'adminapp.tenant'])
->get('me', AdminAppMeController::class); ->get('me', AdminAppMeController::class);
}); });

View File

@@ -2,9 +2,8 @@
namespace App\Domains\Core\Client\Models; namespace App\Domains\Core\Client\Models;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Shared\Integration\Models\ClientIntegration; use App\Shared\Integration\Models\ClientIntegration;
use App\Domains\Core\Tenant\Models\Tenant;
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\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
@@ -23,12 +22,6 @@ class Client extends Model
return $this->hasMany(Tenant::class); return $this->hasMany(Tenant::class);
} }
/** @return HasMany<Event, $this> */
public function events(): HasMany
{
return $this->hasMany(Event::class);
}
/** @return HasMany<ClientIntegration, $this> */ /** @return HasMany<ClientIntegration, $this> */
public function integrations(): HasMany public function integrations(): HasMany
{ {

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Core\Staff\Controllers; namespace App\Domains\Core\Staff\Controllers;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Staff\Requests\StoreStaffRequest; use App\Domains\Core\Staff\Requests\StoreStaffRequest;
use App\Domains\Core\Staff\Requests\UpdateStaffRequest; use App\Domains\Core\Staff\Requests\UpdateStaffRequest;
use App\Domains\Core\Staff\Resources\StaffResource; use App\Domains\Core\Staff\Resources\StaffResource;
@@ -22,17 +21,11 @@ class AdminAppStaffController extends Controller
private readonly ScannerTicketService $scannerTicketService, private readonly ScannerTicketService $scannerTicketService,
) {} ) {}
private function eventId(Request $request): ?int
{
return app(EventScopeService::class)->eventId($request->user());
}
public function index(Request $request): AnonymousResourceCollection public function index(Request $request): AnonymousResourceCollection
{ {
return StaffResource::collection($this->staffService->list( return StaffResource::collection($this->staffService->list(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->string('search')->trim()->toString() ?: null, $request->string('search')->trim()->toString() ?: null,
$this->eventId($request),
)); ));
} }
@@ -41,7 +34,6 @@ class AdminAppStaffController extends Controller
return StaffResource::make($this->staffService->create( return StaffResource::make($this->staffService->create(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
$this->eventId($request),
)); ));
} }
@@ -51,13 +43,12 @@ class AdminAppStaffController extends Controller
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$staff, $staff,
$request->validated(), $request->validated(),
$this->eventId($request),
)); ));
} }
public function destroy(Request $request, int $staff): Response public function destroy(Request $request, int $staff): Response
{ {
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $this->eventId($request)); $this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff);
return response()->noContent(); return response()->noContent();
} }
@@ -69,7 +60,6 @@ class AdminAppStaffController extends Controller
$scanner = $this->staffService->find( $scanner = $this->staffService->find(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$staff, $staff,
$this->eventId($request),
); );
return ScanAttemptResource::collection( return ScanAttemptResource::collection(

View File

@@ -14,7 +14,6 @@ class StaffResource extends JsonResource
{ {
return [ return [
'id' => $this->id, 'id' => $this->id,
'event_id' => $this->event_id,
'nombre_apellido' => $this->nombre_apellido, 'nombre_apellido' => $this->nombre_apellido,
'dni' => $this->dni, 'dni' => $this->dni,
'email' => $this->email, 'email' => $this->email,

View File

@@ -2,13 +2,12 @@
namespace App\Domains\Core\Staff\Services; namespace App\Domains\Core\Staff\Services;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\ResetPasswordAttempt;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService; use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Commerce\Catalog\Models\Category;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Arr; use Illuminate\Support\Arr;
@@ -23,9 +22,9 @@ class StaffService
) {} ) {}
/** @return Collection<int, User> */ /** @return Collection<int, User> */
public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection public function list(Tenant $tenant, ?string $search = null): Collection
{ {
return $this->staffQuery($tenant, $eventId) return $this->staffQuery($tenant)
->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')]) ->with(['role', 'scanCategories' => fn ($query) => $query->orderBy('nombre')])
->when($search, function (Builder $query, string $search): void { ->when($search, function (Builder $query, string $search): void {
$query->where(function (Builder $query) use ($search): void { $query->where(function (Builder $query) use ($search): void {
@@ -39,7 +38,7 @@ class StaffService
} }
/** @return Collection<int, Category> */ /** @return Collection<int, Category> */
private function assignableCategories(Tenant $tenant, ?int $eventId = null): Collection private function assignableCategories(Tenant $tenant): Collection
{ {
return Category::query() return Category::query()
->whereNull('categoria_id') ->whereNull('categoria_id')
@@ -48,30 +47,23 @@ class StaffService
->orWhereHas('catalogItems', fn (Builder $items) => $items ->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo)); ->where('tenant_code', $tenant->codigo));
}) })
->when($eventId !== null, fn (Builder $query) => $query->whereHas(
'catalogItems', fn (Builder $items) => $items->where('tenant_code', $tenant->codigo)->where('event_id', $eventId)))
->orderBy('nombre') ->orderBy('nombre')
->get(); ->get();
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function create(Tenant $tenant, array $data, ?int $eventId = null): User public function create(Tenant $tenant, array $data): User
{ {
$eventId ??= $tenant->active_event_id;
Event::query()
->where('tenant_code', $tenant->codigo)->findOrFail($eventId);
$categoryIds = $this->categoryIdsFor($tenant, $data); $categoryIds = $this->categoryIdsFor($tenant, $data);
$this->assertCategoriesBelongToTenant($tenant, $categoryIds, $eventId); $this->assertCategoriesBelongToTenant($tenant, $categoryIds);
return DB::transaction(function () use ($tenant, $data, $categoryIds, $eventId): User { return DB::transaction(function () use ($tenant, $data, $categoryIds): User {
$staff = User::query()->create([ $staff = User::query()->create([
...Arr::only($data, ['nombre_apellido', 'dni', 'email']), ...Arr::only($data, ['nombre_apellido', 'dni', 'email']),
'email' => mb_strtolower(trim((string) $data['email'])), 'email' => mb_strtolower(trim((string) $data['email'])),
'password' => Str::random(64), 'password' => Str::random(64),
'rol_codigo' => RoleCode::Scanner->value, 'rol_codigo' => RoleCode::Scanner->value,
'tenant_codigo' => $tenant->codigo, 'tenant_codigo' => $tenant->codigo,
'event_id' => $eventId,
'admin_scope' => $eventId === null ? 'tenant' : 'event',
]); ]);
$staff->scanCategories()->sync($categoryIds); $staff->scanCategories()->sync($categoryIds);
$this->resetPasswordAttemptService->createForScannerEmail( $this->resetPasswordAttemptService->createForScannerEmail(
@@ -84,11 +76,11 @@ class StaffService
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function update(Tenant $tenant, int $staffId, array $data, ?int $eventId = null): User public function update(Tenant $tenant, int $staffId, array $data): User
{ {
$staff = $this->find($tenant, $staffId, $eventId); $staff = $this->find($tenant, $staffId);
$categoryIds = $this->categoryIdsFor($tenant, $data); $categoryIds = $this->categoryIdsFor($tenant, $data);
$this->assertCategoriesBelongToTenant($tenant, $categoryIds, $eventId); $this->assertCategoriesBelongToTenant($tenant, $categoryIds);
return DB::transaction(function () use ($staff, $data, $categoryIds): User { return DB::transaction(function () use ($staff, $data, $categoryIds): User {
$attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']); $attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']);
@@ -100,9 +92,9 @@ class StaffService
}); });
} }
public function delete(Tenant $tenant, int $staffId, ?int $eventId = null): void public function delete(Tenant $tenant, int $staffId): void
{ {
$staff = $this->find($tenant, $staffId, $eventId); $staff = $this->find($tenant, $staffId);
DB::transaction(function () use ($staff): void { DB::transaction(function () use ($staff): void {
$staff->tokens()->delete(); $staff->tokens()->delete();
@@ -110,24 +102,23 @@ class StaffService
}); });
} }
public function find(Tenant $tenant, int $staffId, ?int $eventId = null): User public function find(Tenant $tenant, int $staffId): User
{ {
return $this->staffQuery($tenant, $eventId)->findOrFail($staffId); return $this->staffQuery($tenant)->findOrFail($staffId);
} }
private function staffQuery(Tenant $tenant, ?int $eventId = null): Builder private function staffQuery(Tenant $tenant): Builder
{ {
return User::query() return User::query()
->where('tenant_codigo', $tenant->codigo) ->where('tenant_codigo', $tenant->codigo)
->where('rol_codigo', RoleCode::Scanner->value) ->where('rol_codigo', RoleCode::Scanner->value);
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId));
} }
/** /**
* @param array<string, mixed> $data * @param array<string, mixed> $data
* @return array<int, int> * @return array<int, int>
*/ */
private function categoryIdsFor(Tenant $tenant, array $data, ?int $eventId = null): array private function categoryIdsFor(Tenant $tenant, array $data): array
{ {
if (! $tenant->requiresScannerCategoryValidation()) { if (! $tenant->requiresScannerCategoryValidation()) {
return []; return [];
@@ -137,9 +128,9 @@ class StaffService
} }
/** @param array<int, int> $categoryIds */ /** @param array<int, int> $categoryIds */
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds, ?int $eventId = null): void private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void
{ {
$validIds = $this->assignableCategories($tenant, $eventId) $validIds = $this->assignableCategories($tenant)
->whereIn('id', $categoryIds) ->whereIn('id', $categoryIds)
->pluck('id'); ->pluck('id');

View File

@@ -18,13 +18,3 @@ Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido
## Dependencias y reglas ## Dependencias y reglas
Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado. Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado.
## Alcance por evento
Los endpoints de Staff y su formulario aceptan `adminapp.tenant:event`. Un admin
de evento lista, edita, elimina y consulta el historial solo de scanners de su
evento. El backend asigna el evento al crear un scanner y no acepta cambios de
asignación desde el formulario. Las categorías autorizables se limitan a las
usadas por productos del evento. Los scanners aplican además su evento en
lectura de tickets, escaneo e historial. Los intentos registran `event_id` para
conservar el aislamiento aunque cambie la asignación del scanner.

View File

@@ -4,7 +4,7 @@ use App\Domains\Core\Staff\Controllers\AdminAppStaffController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant:event']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']); Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']);
Route::apiResource('staff', AdminAppStaffController::class)->except('show'); Route::apiResource('staff', AdminAppStaffController::class)->except('show');

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Ticketing\Desfile\Services; namespace App\Domains\Ticketing\Desfile\Services;
use App\Domains\Commerce\Catalog\Models\Inventory;
use DateTimeInterface; use DateTimeInterface;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
@@ -393,14 +394,22 @@ class InvitationPurchaseProvisioner
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first(); $inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0 || ($inventory->entry_reserved_stock ?? 0) > 0) { $available = $inventory?->available_stock
?? ($inventory === null ? 0 : $inventory->real_stock - $inventory->reserved_stock - ($inventory->entry_reserved_stock ?? 0));
if ($available < 1) {
throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible."); throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible.");
} }
DB::table('inventories')->where('id', $inventory->id)->update([ if (property_exists($inventory, 'available_stock')) {
'real_stock' => $inventory->real_stock - 1, Inventory::query()->findOrFail($inventory->id)->commitDirectSale(1);
'sold_units' => $inventory->sold_units + 1, } else {
]); // This provisioner is also used by historical migrations that run
// before generated availability and the movement ledger exist.
DB::table('inventories')->where('id', $inventory->id)->update([
'real_stock' => $inventory->real_stock - 1,
'sold_units' => $inventory->sold_units + 1,
]);
}
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id'); $reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
if ($reservationId === null) { if ($reservationId === null) {

View File

@@ -2,7 +2,6 @@
namespace App\Domains\Ticketing\Event\Controllers\AdminApp; namespace App\Domains\Ticketing\Event\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Requests\RescheduleEventDateRequest; use App\Domains\Ticketing\Event\Requests\RescheduleEventDateRequest;
use App\Domains\Ticketing\Event\Requests\StoreEventDateRequest; use App\Domains\Ticketing\Event\Requests\StoreEventDateRequest;
@@ -20,8 +19,7 @@ class EventController extends Controller
public function show(Request $request): EventResource public function show(Request $request): EventResource
{ {
return EventResource::make( return EventResource::make(
$this->eventService->forTenant($request->user()->tenant()->firstOrFail(), $this->eventService->forTenant($request->user()->tenant()->firstOrFail())
app(EventScopeService::class)->eventId($request->user()))
); );
} }
@@ -30,8 +28,7 @@ class EventController extends Controller
return EventResource::make( return EventResource::make(
$this->eventService->updateForTenant( $this->eventService->updateForTenant(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated()
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }
@@ -42,7 +39,6 @@ class EventController extends Controller
$this->eventService->createDateForTenant( $this->eventService->createDateForTenant(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated(), $request->validated(),
app(EventScopeService::class)->eventId($request->user()),
) )
); );
} }

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Ticketing\Event\Models; namespace App\Domains\Ticketing\Event\Models;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
@@ -15,27 +14,14 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
#[Fillable(['client_id', 'tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id', 'allow_ticket_refund', 'allow_ticket_total_refund', 'allow_ticket_partial_refund', 'ticket_partial_refund_percentage'])] #[Fillable(['tenant_code', 'slug', 'event_category_id', 'title', 'subtitle', 'description', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])]
class Event extends Model class Event extends Model
{ {
use HasFactory; use HasFactory;
protected function casts(): array protected function casts(): array
{ {
return ['allow_ticket_refund' => 'boolean', 'allow_ticket_total_refund' => 'boolean', return ['published_at' => 'datetime', 'exact_location' => 'array'];
'allow_ticket_partial_refund' => 'boolean', 'ticket_partial_refund_percentage' => 'decimal:2', 'client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array'];
}
public function allow_refund(): bool
{
return (bool) $this->allow_ticket_refund
&& ((bool) $this->allow_ticket_total_refund || $this->allow_partial_refund());
}
public function allow_partial_refund(): bool
{
return (bool) $this->allow_ticket_refund && (bool) $this->allow_ticket_partial_refund
&& (float) $this->ticket_partial_refund_percentage > 0;
} }
/** @return BelongsTo<Tenant, $this> */ /** @return BelongsTo<Tenant, $this> */
@@ -44,17 +30,6 @@ class Event extends Model
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo'); return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
} }
/** @return BelongsTo<Client, $this> */
public function client(): BelongsTo
{
return $this->belongsTo(Client::class);
}
public function effectiveClient(): Client
{
return $this->client ?? $this->tenant->client;
}
/** @return BelongsTo<EventCategory, $this> */ /** @return BelongsTo<EventCategory, $this> */
public function eventCategory(): BelongsTo public function eventCategory(): BelongsTo
{ {

View File

@@ -26,10 +26,10 @@ class EventResource extends JsonResource
'date_text' => $this->date_text, 'date_text' => $this->date_text,
'published_at' => $this->published_at?->toIso8601String(), 'published_at' => $this->published_at?->toIso8601String(),
'attachment_id' => $this->attachment_id, 'attachment_id' => $this->attachment_id,
'allow_ticket_refund' => $this->allow_ticket_refund, 'allow_ticket_refund' => $this->tenant->allow_ticket_refund,
'allow_ticket_total_refund' => $this->allow_ticket_total_refund, 'allow_ticket_total_refund' => $this->tenant->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund, 'allow_ticket_partial_refund' => $this->tenant->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage, 'ticket_partial_refund_percentage' => $this->tenant->ticket_partial_refund_percentage,
'dates' => EventDateResource::collection( 'dates' => EventDateResource::collection(
app(EventDateGroupingService::class)->group($this->dates) app(EventDateGroupingService::class)->group($this->dates)
), ),

View File

@@ -2,19 +2,18 @@
namespace App\Domains\Ticketing\Event\Services; namespace App\Domains\Ticketing\Event\Services;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Commerce\Cart\Services\InvalidateEventDateCartsService; use App\Domains\Commerce\Cart\Services\InvalidateEventDateCartsService;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\StockReservationService; use App\Domains\Commerce\Catalog\Services\StockReservationService;
use App\Domains\Commerce\Catalog\Services\VariantReplacementService; use App\Domains\Commerce\Catalog\Services\VariantReplacementService;
use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Enums\EventDateChangeType; use App\Domains\Ticketing\Event\Enums\EventDateChangeType;
use App\Domains\Ticketing\Event\Events\EventDateRescheduled; use App\Domains\Ticketing\Event\Events\EventDateRescheduled;
use App\Domains\Ticketing\Event\Events\EventDateSuspended; use App\Domains\Ticketing\Event\Events\EventDateSuspended;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Ticketing\Event\Models\EventDateChange; use App\Domains\Ticketing\Event\Models\EventDateChange;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@@ -35,22 +34,22 @@ class EventService
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts, private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
) {} ) {}
public function forTenant(Tenant $tenant, ?int $eventId = null): Event public function forTenant(Tenant $tenant): Event
{ {
return $this->resolveEvent($tenant, $eventId)->load(['dates.validityTime', 'socialMedia']); return $tenant->activeEvent->load(['dates.validityTime', 'socialMedia']);
} }
/** @param array<string, mixed> $data */ /** @param array<string, mixed> $data */
public function updateForTenant(Tenant $tenant, array $data, ?int $eventId = null): Event public function updateForTenant(Tenant $tenant, array $data): Event
{ {
return DB::transaction(function () use ($tenant, $data, $eventId): Event { return DB::transaction(function () use ($tenant, $data): Event {
$event = $this->resolveEvent($tenant, $eventId); $event = $tenant->activeEvent;
$event->update([ $event->update([
'title' => $data['title'], 'title' => $data['title'],
'location' => $data['location'], 'location' => $data['location'],
...array_intersect_key($data, ['attachment_id' => true, 'exact_location' => true]), ...array_intersect_key($data, ['attachment_id' => true, 'exact_location' => true]),
]); ]);
$event->update([ $tenant->update([
...array_intersect_key($data, array_flip([ ...array_intersect_key($data, array_flip([
'allow_ticket_refund', 'allow_ticket_refund',
'allow_ticket_total_refund', 'allow_ticket_total_refund',
@@ -70,10 +69,10 @@ class EventService
} }
/** @param array{date: string, start_time: string, end_time: string} $data */ /** @param array{date: string, start_time: string, end_time: string} $data */
public function createDateForTenant(Tenant $tenant, array $data, ?int $eventId = null): EventDate public function createDateForTenant(Tenant $tenant, array $data): EventDate
{ {
return DB::transaction(function () use ($tenant, $data, $eventId): EventDate { return DB::transaction(function () use ($tenant, $data): EventDate {
$event = $this->resolveEvent($tenant, $eventId); $event = $tenant->activeEvent;
$attributes = $this->dateAttributes($data); $attributes = $this->dateAttributes($data);
if ($event->dates()->where($attributes)->exists()) { if ($event->dates()->where($attributes)->exists()) {
@@ -94,7 +93,7 @@ class EventService
?User $createdBy = null, ?User $createdBy = null,
): EventDate { ): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate { return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
$source = $this->lockedDateForTenant($tenant, $eventDate, $createdBy); $source = $this->lockedDateForTenant($tenant, $eventDate);
if ($source->suspended_at !== null) { if ($source->suspended_at !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -173,7 +172,7 @@ class EventService
?User $createdBy = null, ?User $createdBy = null,
): EventDate { ): EventDate {
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate { return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
$date = $this->lockedDateForTenant($tenant, $eventDate, $createdBy); $date = $this->lockedDateForTenant($tenant, $eventDate);
if ($date->rescheduled_to_event_date_id !== null) { if ($date->rescheduled_to_event_date_id !== null) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -217,18 +216,10 @@ class EventService
}); });
} }
private function resolveEvent(Tenant $tenant, ?int $eventId): Event private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
{
return Event::query()->where('tenant_code', $tenant->codigo)
->findOrFail($eventId ?? $tenant->active_event_id);
}
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate, ?User $actor = null): EventDate
{ {
return $tenant->eventDates() return $tenant->eventDates()
->whereKey($eventDate->getKey()) ->whereKey($eventDate->getKey())
->when($actor !== null && ! $actor->isTenantAdministrator(),
fn ($query) => $query->where('event_id', app(EventScopeService::class)->eventId($actor)))
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
} }

View File

@@ -37,10 +37,10 @@ se guardan en el evento. Sin evento activo se conservan las redes propias del te
## API ## API
Los endpoints de AdminApp usan `auth:sanctum` y `adminapp.tenant:event`: Los endpoints de AdminApp usan `auth:sanctum` y `adminapp.tenant`:
- `GET/PUT /v1/adminapp/tenant/event`: acceso al evento asociado al usuario con scope de evento; - `GET/PUT /v1/adminapp/tenant/event`: acceso al evento seleccionado para el
para admins de tenant se conserva el evento activo. storefront de evento único.
- `POST /v1/adminapp/tenant/event-dates`: crea una fecha para ese evento. - `POST /v1/adminapp/tenant/event-dates`: crea una fecha para ese evento.
- `POST /v1/adminapp/tenant/event-dates/{eventDate}/reschedule` y - `POST /v1/adminapp/tenant/event-dates/{eventDate}/reschedule` y
`POST /v1/adminapp/tenant/event-dates/{eventDate}/suspend`: cambios de fecha. `POST /v1/adminapp/tenant/event-dates/{eventDate}/suspend`: cambios de fecha.
@@ -53,8 +53,3 @@ una lista de eventos ni existe todavía una pantalla para gestionarlos.
Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar
tickets. Los avisos por suspensión y reprogramación se construyen dinámicamente tickets. Los avisos por suspensión y reprogramación se construyen dinámicamente
después de excluir los cambios que el usuario ya vio tres veces. después de excluir los cambios que el usuario ya vio tres veces.
La configuración de devoluciones se persiste en `events`, se entrega en
`EventResource` y se aplica al evento de cada ticket. La migración inicial copia
los valores anteriores del tenant a sus eventos. Las fechas se autorizan por
tenant y por evento antes de cualquier suspensión o reprogramación.

View File

@@ -4,7 +4,7 @@ use App\Domains\Ticketing\Event\Controllers\AdminApp\EventController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant:event']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('event', [EventController::class, 'show']); Route::get('event', [EventController::class, 'show']);
Route::put('event', [EventController::class, 'update']); Route::put('event', [EventController::class, 'update']);

View File

@@ -28,6 +28,7 @@ class AccommodationController extends Controller
$this->accommodationService->upsertMany( $this->accommodationService->upsertMany(
$tenant, $tenant,
$request->validated('variants'), $request->validated('variants'),
$request->validated('stock_adjustment_id'),
) )
); );
} }

View File

@@ -29,6 +29,7 @@ class EntryController extends Controller
$entries = $this->entryService->upsertMany( $entries = $this->entryService->upsertMany(
$tenant, $tenant,
$request->validated('entries'), $request->validated('entries'),
$request->validated('stock_adjustment_id'),
); );
return EntryResource::collection($entries) return EntryResource::collection($entries)

View File

@@ -29,6 +29,7 @@ class FoodController extends Controller
$this->foodService->upsertMany( $this->foodService->upsertMany(
$tenant, $tenant,
$request->validated('variants'), $request->validated('variants'),
$request->validated('stock_adjustment_id'),
) )
); );
} }
@@ -39,6 +40,7 @@ class FoodController extends Controller
$this->foodService->updateHistoricalStock( $this->foodService->updateHistoricalStock(
$request->user()->tenant()->firstOrFail(), $request->user()->tenant()->firstOrFail(),
$request->validated('variants'), $request->validated('variants'),
$request->validated('stock_adjustment_id'),
) )
); );
} }

View File

@@ -28,6 +28,7 @@ class MerchandiseController extends Controller
$items = $this->merchandiseService->upsertMany( $items = $this->merchandiseService->upsertMany(
$tenant, $tenant,
$request->validated('items'), $request->validated('items'),
$request->validated('stock_adjustment_id'),
); );
return MerchandiseResource::collection($items) return MerchandiseResource::collection($items)

View File

@@ -16,9 +16,10 @@ class UpdateHistoricalFoodStockRequest extends FormRequest
{ {
return [ return [
'variants' => ['required', 'array', 'min:1', 'max:500'], 'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,stock'], 'stock_adjustment_id' => ['nullable', 'uuid'],
'variants.*' => ['required', 'array:id,stock_difference'],
'variants.*.id' => ['required', 'integer', 'distinct'], 'variants.*.id' => ['required', 'integer', 'distinct'],
'variants.*.stock' => ['required', 'integer', 'min:0'], 'variants.*.stock_difference' => ['required', 'integer'],
]; ];
} }
} }

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Ticketing\FiestaFutbolInfantil\Requests; namespace App\Domains\Ticketing\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Validator;
class UpsertAccommodationVariantsRequest extends FormRequest class UpsertAccommodationVariantsRequest extends FormRequest
{ {
@@ -16,12 +17,42 @@ class UpsertAccommodationVariantsRequest extends FormRequest
{ {
return [ return [
'variants' => ['required', 'array', 'min:1', 'max:500'], 'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,title,description,stock,price'], 'stock_adjustment_id' => ['nullable', 'uuid'],
'variants.*' => ['required', 'array:id,title,description,stock,stock_difference,price'],
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], 'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'variants.*.title' => ['required', 'string', 'max:255'], 'variants.*.title' => ['required', 'string', 'max:255'],
'variants.*.description' => ['sometimes', 'nullable', 'string'], 'variants.*.description' => ['sometimes', 'nullable', 'string'],
'variants.*.stock' => ['required', 'integer', 'min:0'], 'variants.*.stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.stock_difference' => ['sometimes', 'integer'],
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
]; ];
} }
/** @return array<int, callable> */
public function after(): array
{
return [
function (Validator $validator): void {
foreach ($this->input('variants', []) as $index => $variant) {
if (! is_array($variant)) {
continue;
}
if (isset($variant['id']) && ! array_key_exists('stock_difference', $variant)) {
$validator->errors()->add(
"variants.{$index}.stock_difference",
'La diferencia de stock es obligatoria al actualizar una variante.',
);
}
if (! isset($variant['id']) && ! array_key_exists('stock', $variant)) {
$validator->errors()->add(
"variants.{$index}.stock",
'El stock inicial es obligatorio al crear una variante.',
);
}
}
},
];
}
} }

View File

@@ -20,7 +20,8 @@ class UpsertEntriesRequest extends FormRequest
return [ return [
'entries' => ['required', 'array', 'min:1', 'max:100'], 'entries' => ['required', 'array', 'min:1', 'max:100'],
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'], 'stock_adjustment_id' => ['nullable', 'uuid'],
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,stock_difference,price'],
'entries.*.id' => [ 'entries.*.id' => [
'sometimes', 'sometimes',
'nullable', 'nullable',
@@ -46,7 +47,8 @@ class UpsertEntriesRequest extends FormRequest
fn ($query) => $query->where('tenant_code', $tenantCode) fn ($query) => $query->where('tenant_code', $tenantCode)
), ),
], ],
'entries.*.stock' => ['required', 'integer', 'min:0'], 'entries.*.stock' => ['sometimes', 'integer', 'min:0'],
'entries.*.stock_difference' => ['sometimes', 'integer'],
'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], 'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
]; ];
} }
@@ -61,6 +63,20 @@ class UpsertEntriesRequest extends FormRequest
continue; continue;
} }
if (isset($entry['id']) && ! array_key_exists('stock_difference', $entry)) {
$validator->errors()->add(
"entries.{$index}.stock_difference",
'La diferencia de stock es obligatoria al actualizar una entrada.',
);
}
if (! isset($entry['id']) && ! array_key_exists('stock', $entry)) {
$validator->errors()->add(
"entries.{$index}.stock",
'El stock inicial es obligatorio al crear una entrada.',
);
}
$dateIds = $entry['event_date_ids'] ?? []; $dateIds = $entry['event_date_ids'] ?? [];
if (! is_array($dateIds)) { if (! is_array($dateIds)) {

View File

@@ -20,7 +20,8 @@ class UpsertFoodVariantsRequest extends FormRequest
return [ return [
'variants' => ['required', 'array', 'min:1', 'max:500'], 'variants' => ['required', 'array', 'min:1', 'max:500'],
'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'], 'stock_adjustment_id' => ['nullable', 'uuid'],
'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,stock_difference,price'],
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], 'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'variants.*.event_date_id' => [ 'variants.*.event_date_id' => [
'required', 'required',
@@ -32,7 +33,8 @@ class UpsertFoodVariantsRequest extends FormRequest
'variants.*.schedule' => ['required', 'string', 'max:255'], 'variants.*.schedule' => ['required', 'string', 'max:255'],
'variants.*.service' => ['required', 'string', 'max:255'], 'variants.*.service' => ['required', 'string', 'max:255'],
'variants.*.description' => ['sometimes', 'nullable', 'string'], 'variants.*.description' => ['sometimes', 'nullable', 'string'],
'variants.*.stock' => ['required', 'integer', 'min:0'], 'variants.*.stock' => ['sometimes', 'integer', 'min:0'],
'variants.*.stock_difference' => ['sometimes', 'integer'],
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], 'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
]; ];
} }
@@ -49,6 +51,20 @@ class UpsertFoodVariantsRequest extends FormRequest
continue; continue;
} }
if (isset($variant['id']) && ! array_key_exists('stock_difference', $variant)) {
$validator->errors()->add(
"variants.{$index}.stock_difference",
'La diferencia de stock es obligatoria al actualizar una variante.',
);
}
if (! isset($variant['id']) && ! array_key_exists('stock', $variant)) {
$validator->errors()->add(
"variants.{$index}.stock",
'El stock inicial es obligatorio al crear una variante.',
);
}
$key = implode('|', [ $key = implode('|', [
$variant['event_date_id'] ?? '', $variant['event_date_id'] ?? '',
mb_strtolower(trim((string) ($variant['schedule'] ?? ''))), mb_strtolower(trim((string) ($variant['schedule'] ?? ''))),

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Ticketing\FiestaFutbolInfantil\Requests;
use Illuminate\Foundation\Http\FormRequest; use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\Validator;
class UpsertMerchandiseRequest extends FormRequest class UpsertMerchandiseRequest extends FormRequest
{ {
@@ -39,12 +40,49 @@ class UpsertMerchandiseRequest extends FormRequest
'items.*.description' => ['sometimes', 'nullable', 'string'], 'items.*.description' => ['sometimes', 'nullable', 'string'],
'items.*.max_units_per_user' => ['required', 'integer', 'min:1'], 'items.*.max_units_per_user' => ['required', 'integer', 'min:1'],
'items.*.variants' => ['required', 'array', 'min:1', 'max:500'], 'items.*.variants' => ['required', 'array', 'min:1', 'max:500'],
'items.*.variants.*' => ['required', 'array:id,color,size,stock,price'], 'stock_adjustment_id' => ['nullable', 'uuid'],
'items.*.variants.*' => ['required', 'array:id,color,size,stock,stock_difference,price'],
'items.*.variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'], 'items.*.variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
'items.*.variants.*.color' => ['required', 'string', 'max:255'], 'items.*.variants.*.color' => ['required', 'string', 'max:255'],
'items.*.variants.*.size' => ['required', 'string', 'max:255'], 'items.*.variants.*.size' => ['required', 'string', 'max:255'],
'items.*.variants.*.stock' => ['required', 'integer', 'min:0'], 'items.*.variants.*.stock' => ['sometimes', 'integer', 'min:0'],
'items.*.variants.*.stock_difference' => ['sometimes', 'integer'],
'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'], 'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
]; ];
} }
/** @return array<int, callable> */
public function after(): array
{
return [
function (Validator $validator): void {
foreach ($this->input('items', []) as $itemIndex => $item) {
if (! is_array($item)) {
continue;
}
foreach ($item['variants'] ?? [] as $variantIndex => $variant) {
if (! is_array($variant)) {
continue;
}
$prefix = "items.{$itemIndex}.variants.{$variantIndex}";
if (isset($variant['id']) && ! array_key_exists('stock_difference', $variant)) {
$validator->errors()->add(
"{$prefix}.stock_difference",
'La diferencia de stock es obligatoria al actualizar una variante.',
);
}
if (! isset($variant['id']) && ! array_key_exists('stock', $variant)) {
$validator->errors()->add(
"{$prefix}.stock",
'El stock inicial es obligatorio al crear una variante.',
);
}
}
}
},
];
}
} }

View File

@@ -37,7 +37,7 @@ class AccommodationResource extends JsonResource
'title' => $options->get($value)?->label ?? $value, 'title' => $options->get($value)?->label ?? $value,
'value' => $value, 'value' => $value,
'description' => $variant->descripcion, 'description' => $variant->descripcion,
'stock' => $variant->inventory->real_stock, 'stock' => $variant->inventory->availableStock(),
'price' => number_format($variant->getPrice(), 2, '.', ''), 'price' => number_format($variant->getPrice(), 2, '.', ''),
]; ];
})->values(), })->values(),

View File

@@ -33,7 +33,7 @@ class EntryResource extends JsonResource
'title' => $this->nombre, 'title' => $this->nombre,
'description' => $this->descripcion, 'description' => $this->descripcion,
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(), 'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
'stock' => $variant->inventory->real_stock, 'stock' => $variant->inventory->availableStock(),
'price' => $this->precio, 'price' => $this->precio,
]; ];
} }

View File

@@ -81,7 +81,7 @@ class FoodResource extends JsonResource
'schedule' => $values->get('horario'), 'schedule' => $values->get('horario'),
'service' => $values->get('servicio'), 'service' => $values->get('servicio'),
'description' => $variant->descripcion, 'description' => $variant->descripcion,
'stock' => $variant->inventory->real_stock, 'stock' => $variant->inventory->availableStock(),
'price' => number_format($variant->getPrice(), 2, '.', ''), 'price' => number_format($variant->getPrice(), 2, '.', ''),
]; ];
} }

View File

@@ -44,7 +44,7 @@ class MerchandiseResource extends JsonResource
'color_value' => $colorValue, 'color_value' => $colorValue,
'size' => $sizeOptions->get($sizeValue)?->label ?? $sizeValue, 'size' => $sizeOptions->get($sizeValue)?->label ?? $sizeValue,
'size_value' => $sizeValue, 'size_value' => $sizeValue,
'stock' => $variant->inventory->real_stock, 'stock' => $variant->inventory->availableStock(),
'price' => number_format($variant->getPrice(), 2, '.', ''), 'price' => number_format($variant->getPrice(), 2, '.', ''),
]; ];
})->values(), })->values(),

View File

@@ -39,9 +39,9 @@ class AccommodationService
/** /**
* @param array<int, array<string, mixed>> $variants * @param array<int, array<string, mixed>> $variants
*/ */
public function upsertMany(Tenant $tenant, array $variants): CatalogItem public function upsertMany(Tenant $tenant, array $variants, ?string $stockAdjustmentId = null): CatalogItem
{ {
return DB::transaction(function () use ($tenant, $variants): CatalogItem { return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem {
$attribute = $this->attribute($tenant); $attribute = $this->attribute($tenant);
$accommodation = $this->accommodation($tenant, $variants); $accommodation = $this->accommodation($tenant, $variants);
$itemAttribute = $accommodation->itemAttributes()->firstOrCreate( $itemAttribute = $accommodation->itemAttributes()->firstOrCreate(
@@ -70,7 +70,7 @@ class AccommodationService
if ($variant === null) { if ($variant === null) {
$this->createVariant($attribute, $accommodation, $itemAttribute, $data); $this->createVariant($attribute, $accommodation, $itemAttribute, $data);
} else { } else {
$this->updateVariant($attribute, $variant, $itemAttribute, $data, $index); $this->updateVariant($attribute, $variant, $itemAttribute, $data, $index, $stockAdjustmentId);
} }
} }
@@ -169,7 +169,7 @@ class AccommodationService
'title' => trim($variant['title']), 'title' => trim($variant['title']),
'value' => $this->valueCode($variant['title']), 'value' => $this->valueCode($variant['title']),
'description' => $variant['description'] ?? null, 'description' => $variant['description'] ?? null,
'stock' => (int) $variant['stock'], ...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
])->all(); ])->all();
} }
@@ -212,6 +212,7 @@ class AccommodationService
$this->createOption($attribute, $data['value'], $data['title']); $this->createOption($attribute, $data['value'], $data['title']);
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]); $inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$inventory->recordInitialization();
$variant = $accommodation->variants()->create([ $variant = $accommodation->variants()->create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'descripcion' => $data['description'], 'descripcion' => $data['description'],
@@ -230,20 +231,13 @@ class AccommodationService
ItemAttribute $itemAttribute, ItemAttribute $itemAttribute,
array $data, array $data,
int $index, int $index,
?string $stockAdjustmentId,
): void { ): void {
$inventory = Inventory::query() $inventory = Inventory::query()
->whereKey($variant->inventory_id) ->whereKey($variant->inventory_id)
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$definition = $variant->definitions $definition = $variant->definitions
->firstWhere('item_attribute_id', $itemAttribute->id); ->firstWhere('item_attribute_id', $itemAttribute->id);
$option = $definition === null $option = $definition === null
@@ -263,7 +257,10 @@ class AccommodationService
'descripcion' => $data['description'], 'descripcion' => $data['description'],
'precio' => $data['price'], 'precio' => $data['price'],
]); ]);
$inventory->update(['real_stock' => $data['stock']]); $inventory->adjustAvailableStock(
(int) $data['stock_difference'],
idempotencyKey: $stockAdjustmentId,
);
$variant->definitions()->updateOrCreate( $variant->definitions()->updateOrCreate(
['item_attribute_id' => $itemAttribute->id], ['item_attribute_id' => $itemAttribute->id],
['value' => $data['value']], ['value' => $data['value']],

View File

@@ -43,18 +43,18 @@ class EntryService
* @param array<int, array<string, mixed>> $entries * @param array<int, array<string, mixed>> $entries
* @return Collection<int, CatalogItem> * @return Collection<int, CatalogItem>
*/ */
public function upsertMany(Tenant $tenant, array $entries): Collection public function upsertMany(Tenant $tenant, array $entries, ?string $stockAdjustmentId = null): Collection
{ {
return DB::transaction(function () use ($tenant, $entries): Collection { return DB::transaction(function () use ($tenant, $entries, $stockAdjustmentId): Collection {
$reservedSlugs = []; $reservedSlugs = [];
$category = Category::query()->firstOrCreate([ $category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
'nombre' => 'Entradas', 'nombre' => 'Entradas',
]); ]);
return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, &$reservedSlugs): CatalogItem { return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, $stockAdjustmentId, &$reservedSlugs): CatalogItem {
if (isset($entry['id'])) { if (isset($entry['id'])) {
return $this->update($tenant, $category, $entry, $index); return $this->update($tenant, $category, $entry, $index, $stockAdjustmentId);
} }
$slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs); $slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs);
@@ -92,7 +92,7 @@ class EntryService
} }
/** @param array<string, mixed> $entry */ /** @param array<string, mixed> $entry */
private function update(Tenant $tenant, Category $category, array $entry, int $index): CatalogItem private function update(Tenant $tenant, Category $category, array $entry, int $index, ?string $stockAdjustmentId): CatalogItem
{ {
$catalogItem = CatalogItem::query() $catalogItem = CatalogItem::query()
->whereKey($entry['id']) ->whereKey($entry['id'])
@@ -121,14 +121,6 @@ class EntryService
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
if ((int) $entry['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"entries.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$eventDateIds = collect($entry['event_date_ids']) $eventDateIds = collect($entry['event_date_ids'])
->map(fn ($id): int => (int) $id) ->map(fn ($id): int => (int) $id)
->unique() ->unique()
@@ -149,7 +141,10 @@ class EntryService
$catalogItem->itemAttributes() $catalogItem->itemAttributes()
->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date')) ->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date'))
->update(['allow_multi_select' => true]); ->update(['allow_multi_select' => true]);
$inventory->update(['real_stock' => $entry['stock']]); $inventory->adjustAvailableStock(
(int) $entry['stock_difference'],
idempotencyKey: $stockAdjustmentId,
);
return $catalogItem->load([ return $catalogItem->load([
'variants.inventory', 'variants.inventory',

View File

@@ -11,9 +11,9 @@ use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\ItemAttribute; use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogService; use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Enums\EventDateStatus; use App\Domains\Ticketing\Event\Enums\EventDateStatus;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Core\Tenant\Models\Tenant;
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;
@@ -50,9 +50,9 @@ class FoodService
/** /**
* @param array<int, array<string, mixed>> $variants * @param array<int, array<string, mixed>> $variants
*/ */
public function upsertMany(Tenant $tenant, array $variants): CatalogItem public function upsertMany(Tenant $tenant, array $variants, ?string $stockAdjustmentId = null): CatalogItem
{ {
return DB::transaction(function () use ($tenant, $variants): CatalogItem { return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem {
$attributes = $this->attributes($tenant); $attributes = $this->attributes($tenant);
$food = $this->food($tenant, $variants); $food = $this->food($tenant, $variants);
$itemAttributes = $this->itemAttributes($food, $attributes); $itemAttributes = $this->itemAttributes($food, $attributes);
@@ -85,7 +85,7 @@ class FoodService
if ($variant === null) { if ($variant === null) {
$this->createVariant($food, $itemAttributes, $data); $this->createVariant($food, $itemAttributes, $data);
} else { } else {
$this->updateVariant($variant, $itemAttributes, $data, $index); $this->updateVariant($variant, $itemAttributes, $data, $index, $stockAdjustmentId);
} }
} }
@@ -113,11 +113,11 @@ class FoodService
} }
/** /**
* @param array<int, array{id: int, stock: int}> $variants * @param array<int, array{id: int, stock_difference: int}> $variants
*/ */
public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem public function updateHistoricalStock(Tenant $tenant, array $variants, ?string $stockAdjustmentId = null): CatalogItem
{ {
return DB::transaction(function () use ($tenant, $variants): CatalogItem { return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem {
$food = CatalogItem::query() $food = CatalogItem::query()
->forTenantCatalog($tenant) ->forTenantCatalog($tenant)
->where('slug', 'comida') ->where('slug', 'comida')
@@ -142,17 +142,11 @@ class FoodService
]); ]);
} }
$stock = (int) $data['stock'];
$inventory = $this->inventoryForHistoricalStockUpdate($variant); $inventory = $this->inventoryForHistoricalStockUpdate($variant);
if ($stock < $inventory->reserved_stock) { $inventory->adjustAvailableStock(
throw ValidationException::withMessages([ (int) $data['stock_difference'],
"variants.{$index}.stock" => [ idempotencyKey: $stockAdjustmentId,
'El stock no puede ser menor que la cantidad actualmente reservada.', );
],
]);
}
$inventory->update(['real_stock' => $stock]);
} }
return $this->current($tenant) ?? $food; return $this->current($tenant) ?? $food;
@@ -181,6 +175,7 @@ class FoodService
'reserved_stock' => 0, 'reserved_stock' => 0,
'real_stock' => $inventory->real_stock, 'real_stock' => $inventory->real_stock,
]); ]);
$historicalInventory->recordTransferInitialization();
$variant->update(['inventory_id' => $historicalInventory->getKey()]); $variant->update(['inventory_id' => $historicalInventory->getKey()]);
return $historicalInventory; return $historicalInventory;
@@ -301,7 +296,7 @@ class FoodService
'schedule' => $schedule->value, 'schedule' => $schedule->value,
'service' => $service->value, 'service' => $service->value,
'description' => (string) ($variant['description'] ?? ''), 'description' => (string) ($variant['description'] ?? ''),
'stock' => (int) $variant['stock'], ...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
]; ];
})->all(); })->all();
} }
@@ -384,6 +379,7 @@ class FoodService
private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void
{ {
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]); $inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$inventory->recordInitialization();
$variant = $food->variants()->create([ $variant = $food->variants()->create([
'event_date_id' => $data['event_date_id'], 'event_date_id' => $data['event_date_id'],
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
@@ -400,27 +396,23 @@ class FoodService
Collection $itemAttributes, Collection $itemAttributes,
array $data, array $data,
int $index, int $index,
?string $stockAdjustmentId,
): void { ): void {
$inventory = Inventory::query() $inventory = Inventory::query()
->whereKey($variant->inventory_id) ->whereKey($variant->inventory_id)
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"variants.{$index}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$variant->update([ $variant->update([
'event_date_id' => $data['event_date_id'], 'event_date_id' => $data['event_date_id'],
'descripcion' => $data['description'], 'descripcion' => $data['description'],
'precio' => $data['price'], 'precio' => $data['price'],
]); ]);
$variant->eventDates()->sync([$data['event_date_id']]); $variant->eventDates()->sync([$data['event_date_id']]);
$inventory->update(['real_stock' => $data['stock']]); $inventory->adjustAvailableStock(
(int) $data['stock_difference'],
idempotencyKey: $stockAdjustmentId,
);
$this->syncDefinitions($variant, $itemAttributes, $data); $this->syncDefinitions($variant, $itemAttributes, $data);
} }

View File

@@ -43,9 +43,9 @@ class MerchandiseService
* @param array<int, array<string, mixed>> $items * @param array<int, array<string, mixed>> $items
* @return Collection<int, CatalogItem> * @return Collection<int, CatalogItem>
*/ */
public function upsertMany(Tenant $tenant, array $items): Collection public function upsertMany(Tenant $tenant, array $items, ?string $stockAdjustmentId = null): Collection
{ {
return DB::transaction(function () use ($tenant, $items): Collection { return DB::transaction(function () use ($tenant, $items, $stockAdjustmentId): Collection {
$attributes = $this->attributes($tenant); $attributes = $this->attributes($tenant);
$category = Category::query()->firstOrCreate([ $category = Category::query()->firstOrCreate([
'tenant_code' => $tenant->codigo, 'tenant_code' => $tenant->codigo,
@@ -57,6 +57,7 @@ class MerchandiseService
$tenant, $tenant,
$attributes, $attributes,
$category, $category,
$stockAdjustmentId,
&$reservedSlugs, &$reservedSlugs,
): CatalogItem { ): CatalogItem {
$item = isset($data['id']) $item = isset($data['id'])
@@ -101,7 +102,7 @@ class MerchandiseService
if ($variant === null) { if ($variant === null) {
$this->createVariant($item, $itemAttributes, $variantData); $this->createVariant($item, $itemAttributes, $variantData);
} else { } else {
$this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex); $this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex, $stockAdjustmentId);
} }
} }
@@ -244,7 +245,7 @@ class MerchandiseService
...$variant, ...$variant,
'color' => $color->value, 'color' => $color->value,
'size' => $size->value, 'size' => $size->value,
'stock' => (int) $variant['stock'], ...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
]; ];
})->all(); })->all();
} }
@@ -342,6 +343,7 @@ class MerchandiseService
array $data, array $data,
): void { ): void {
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]); $inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
$inventory->recordInitialization();
$variant = $item->variants()->create([ $variant = $item->variants()->create([
'inventory_id' => $inventory->id, 'inventory_id' => $inventory->id,
'precio' => $data['price'], 'precio' => $data['price'],
@@ -359,22 +361,18 @@ class MerchandiseService
array $data, array $data,
int $itemIndex, int $itemIndex,
int $variantIndex, int $variantIndex,
?string $stockAdjustmentId,
): void { ): void {
$inventory = Inventory::query() $inventory = Inventory::query()
->whereKey($variant->inventory_id) ->whereKey($variant->inventory_id)
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
if ($data['stock'] < $inventory->reserved_stock) {
throw ValidationException::withMessages([
"items.{$itemIndex}.variants.{$variantIndex}.stock" => [
'El stock no puede ser menor que la cantidad actualmente reservada.',
],
]);
}
$variant->update(['precio' => $data['price']]); $variant->update(['precio' => $data['price']]);
$inventory->update(['real_stock' => $data['stock']]); $inventory->adjustAvailableStock(
(int) $data['stock_difference'],
idempotencyKey: $stockAdjustmentId,
);
$this->syncDefinitions($variant, $itemAttributes, $data); $this->syncDefinitions($variant, $itemAttributes, $data);
} }

View File

@@ -11,7 +11,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([ #[Fillable([
'tenant_code', 'tenant_code',
'event_id',
'scanner_user_id', 'scanner_user_id',
'ticket_id', 'ticket_id',
'data', 'data',
@@ -44,7 +43,6 @@ class ScanAttempt extends Model
{ {
return [ return [
'scanner_user_id' => 'integer', 'scanner_user_id' => 'integer',
'event_id' => 'integer',
'ticket_id' => 'integer', 'ticket_id' => 'integer',
'result' => ScanAttemptResult::class, 'result' => ScanAttemptResult::class,
'created_at' => 'datetime', 'created_at' => 'datetime',

View File

@@ -148,9 +148,7 @@ class Ticket extends Model
public function allow_refund(): bool public function allow_refund(): bool
{ {
return $this->event_id === null return $this->tenant?->allow_refund() ?? false;
? ($this->tenant?->allow_refund() ?? false)
: ($this->event?->allow_refund() ?? false);
} }
public function allowRefund(): bool public function allowRefund(): bool

View File

@@ -32,7 +32,7 @@ class ScanAttemptResource extends JsonResource
ScanAttemptResult::Processing => 'Error', ScanAttemptResult::Processing => 'Error',
ScanAttemptResult::Accepted => 'Verificado', ScanAttemptResult::Accepted => 'Verificado',
ScanAttemptResult::InvalidQr => 'QR no pertenece al evento', ScanAttemptResult::InvalidQr => 'QR no pertenece al evento',
ScanAttemptResult::TicketNotFound => 'QR no pertenece al evento', ScanAttemptResult::TicketNotFound => 'Error',
ScanAttemptResult::CategoryForbidden => 'Error', ScanAttemptResult::CategoryForbidden => 'Error',
ScanAttemptResult::AlreadyScanned => 'Usado', ScanAttemptResult::AlreadyScanned => 'Usado',
ScanAttemptResult::Expired => 'Vencido', ScanAttemptResult::Expired => 'Vencido',

View File

@@ -9,7 +9,6 @@ use App\Domains\Commerce\Purchase\Models\PurchaseItem;
use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService; use App\Domains\Commerce\Purchase\Services\PurchaseRefundSummaryService;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Models\TicketRefund; use App\Domains\Ticketing\Ticket\Models\TicketRefund;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
@@ -24,7 +23,6 @@ class AdminAppTicketService
...TicketValidityResolver::RELATIONS, ...TicketValidityResolver::RELATIONS,
...TicketPresentationResolver::RELATIONS, ...TicketPresentationResolver::RELATIONS,
'tenant', 'tenant',
'event',
'user', 'user',
'scannerUser', 'scannerUser',
'sourceCatalogItem.category', 'sourceCatalogItem.category',
@@ -147,20 +145,19 @@ class AdminAppTicketService
]); ]);
} }
$configuration = $ticket->event_id === null ? $tenant : $ticket->event;
$unitPrice = (float) $purchaseItem->precio_unitario; $unitPrice = (float) $purchaseItem->precio_unitario;
$itemTotal = (float) $purchaseItem->total; $itemTotal = (float) $purchaseItem->total;
$itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem); $itemRefundedAmount = $this->refundedAmountForPurchaseItem($purchaseItem);
$remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2)); $remainingItemAmount = max(0.0, round($itemTotal - $itemRefundedAmount, 2));
$total = null; $total = null;
if ($configuration->allow_refund() && $configuration->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) { if ($tenant->allow_refund() && $tenant->allow_ticket_total_refund && $unitPrice <= $remainingItemAmount) {
$total = number_format($unitPrice, 2, '.', ''); $total = number_format($unitPrice, 2, '.', '');
} }
$partial = null; $partial = null;
if ($configuration->allow_refund() && $configuration->allow_partial_refund()) { if ($tenant->allow_refund() && $tenant->allow_partial_refund()) {
$partialAmount = $this->refundAmount($purchaseItem, $configuration, 'partial'); $partialAmount = $this->refundAmount($purchaseItem, $tenant, 'partial');
if ($partialAmount <= $remainingItemAmount) { if ($partialAmount <= $remainingItemAmount) {
$partial = number_format($partialAmount, 2, '.', ''); $partial = number_format($partialAmount, 2, '.', '');
} }
@@ -178,15 +175,14 @@ class AdminAppTicketService
string $refundType, string $refundType,
?User $createdBy = null, ?User $createdBy = null,
): Ticket { ): Ticket {
$this->ensureRefundIsAllowed($tenant, $refundType);
return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket { return DB::transaction(function () use ($tenant, $ticketId, $refundType, $createdBy): Ticket {
$ticket = Ticket::query() $ticket = Ticket::query()
->where('tenant_code', $tenant->codigo) ->where('tenant_code', $tenant->codigo)
->lockForUpdate() ->lockForUpdate()
->findOrFail($ticketId); ->findOrFail($ticketId);
$configuration = $ticket->event_id === null ? $tenant : $ticket->event;
$this->ensureRefundIsAllowed($configuration, $refundType);
if (! $ticket->can_refund()) { if (! $ticket->can_refund()) {
if ($ticket->status !== Ticket::STATUS_ACTIVE) { if ($ticket->status !== Ticket::STATUS_ACTIVE) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
@@ -209,7 +205,7 @@ class AdminAppTicketService
]); ]);
} }
$refundAmount = $this->refundAmount($purchaseItem, $configuration, $refundType); $refundAmount = $this->refundAmount($purchaseItem, $tenant, $refundType);
$refundedAmount = round( $refundedAmount = round(
$this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount, $this->refundedAmountForPurchaseItem($purchaseItem) + $refundAmount,
2, 2,
@@ -232,13 +228,13 @@ class AdminAppTicketService
'amount' => number_format($refundAmount, 2, '.', ''), 'amount' => number_format($refundAmount, 2, '.', ''),
]); ]);
$this->restoreInventory($ticket, $purchaseItem); $this->restoreInventory($ticket, $purchaseItem, $createdBy);
return $ticket->refresh()->load(self::RELATIONS); return $ticket->refresh()->load(self::RELATIONS);
}); });
} }
private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem): void private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem, ?User $createdBy): void
{ {
$catalogItem = $ticket->sourceCatalogItem; $catalogItem = $ticket->sourceCatalogItem;
if ($catalogItem === null) { if ($catalogItem === null) {
@@ -276,11 +272,7 @@ class AdminAppTicketService
throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']); throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']);
} }
if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) { $inventory->refundStock(1, $createdBy);
$inventory->real_stock++;
}
$inventory->refunded_units++;
$inventory->save();
} }
private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float
@@ -290,7 +282,7 @@ class AdminAppTicketService
->sum('amount'), 2); ->sum('amount'), 2);
} }
private function ensureRefundIsAllowed(Tenant|Event $tenant, string $refundType): void private function ensureRefundIsAllowed(Tenant $tenant, string $refundType): void
{ {
$isAllowed = match ($refundType) { $isAllowed = match ($refundType) {
TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(), TicketRefund::TYPE_PARTIAL => $tenant->allow_refund() && $tenant->allow_partial_refund(),
@@ -304,7 +296,7 @@ class AdminAppTicketService
} }
} }
private function refundAmount(PurchaseItem $purchaseItem, Tenant|Event $tenant, string $refundType): float private function refundAmount(PurchaseItem $purchaseItem, Tenant $tenant, string $refundType): float
{ {
$ticketAmount = (float) $purchaseItem->precio_unitario; $ticketAmount = (float) $purchaseItem->precio_unitario;

View File

@@ -2,6 +2,7 @@
namespace App\Domains\Ticketing\Ticket\Services; namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Commerce\Catalog\Models\Inventory;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use RuntimeException; use RuntimeException;
@@ -64,14 +65,9 @@ class BackfillRefundedUnitsService
}, 'refunds.id', 'id'); }, 'refunds.id', 'id');
foreach ($counts as $inventoryId => $count) { foreach ($counts as $inventoryId => $count) {
$updates = ['refunded_units' => DB::raw('refunded_units + '.$count['refunded'])]; $inventory = Inventory::query()->lockForUpdate()->find($inventoryId)
if (($count['stock'] ?? 0) > 0) { ?? throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo.");
$updates['real_stock'] = DB::raw('real_stock + '.$count['stock']); $inventory->refundStock($count['refunded']);
}
if (DB::table('inventories')->where('id', $inventoryId)->update($updates) !== 1) {
throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo.");
}
} }
$refundedUnitsAdded = array_sum(array_column($counts, 'refunded')); $refundedUnitsAdded = array_sum(array_column($counts, 'refunded'));
@@ -82,7 +78,8 @@ class BackfillRefundedUnitsService
'bundles_skipped' => $bundlesSkipped, 'bundles_skipped' => $bundlesSkipped,
'inventories_updated' => count($counts), 'inventories_updated' => count($counts),
'refunded_units_added' => $refundedUnitsAdded, 'refunded_units_added' => $refundedUnitsAdded,
'real_stock_added' => array_sum(array_column($counts, 'stock')), 'real_stock_added' => 0,
'available_stock_added' => $refundedUnitsAdded,
]; ];
}); });

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Ticketing\Ticket\Services; namespace App\Domains\Ticketing\Ticket\Services;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Ticketing\Ticket\Enums\ScanAttemptResult; use App\Domains\Ticketing\Ticket\Enums\ScanAttemptResult;
use App\Domains\Ticketing\Ticket\Models\ScanAttempt; use App\Domains\Ticketing\Ticket\Models\ScanAttempt;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
@@ -28,7 +27,6 @@ class ScannerTicketService
->with('ticket.sourceCatalogItem.category') ->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->when($search !== '', function (Builder $query) use ($search): void { ->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search); $attemptedAtDate = $this->parseSearchDate($search);
@@ -62,7 +60,6 @@ class ScannerTicketService
->with('ticket.sourceCatalogItem.category') ->with('ticket.sourceCatalogItem.category')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->when($search !== '', function (Builder $query) use ($search): void { ->when($search !== '', function (Builder $query) use ($search): void {
$attemptedAtDate = $this->parseSearchDate($search); $attemptedAtDate = $this->parseSearchDate($search);
$attemptedAtDayMonth = $this->parseSearchDayMonth($search); $attemptedAtDayMonth = $this->parseSearchDayMonth($search);
@@ -109,7 +106,6 @@ class ScannerTicketService
->with('ticket') ->with('ticket')
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('scanner_user_id', $scanner->getKey()) ->where('scanner_user_id', $scanner->getKey())
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->findOrFail($scanAttemptId); ->findOrFail($scanAttemptId);
$scanAttempt->ticket?->loadMissing($this->relations()); $scanAttempt->ticket?->loadMissing($this->relations());
@@ -117,11 +113,6 @@ class ScannerTicketService
return $scanAttempt; return $scanAttempt;
} }
private function eventId(User $scanner): ?int
{
return app(EventScopeService::class)->eventId($scanner);
}
private function parseSearchDate(string $search): ?string private function parseSearchDate(string $search): ?string
{ {
if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) { if (preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $search, $matches) === 1) {
@@ -163,8 +154,7 @@ class ScannerTicketService
{ {
$query = $this->baseQuery() $query = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $ticketUuid) ->where('ticket', $ticketUuid);
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)));
if ($this->requiresCategoryValidation($scanner)) { if ($this->requiresCategoryValidation($scanner)) {
$categoryIds = $this->scannerCategoryIds($scanner); $categoryIds = $this->scannerCategoryIds($scanner);
@@ -188,7 +178,6 @@ class ScannerTicketService
$scanAttempt = ScanAttempt::query()->create([ $scanAttempt = ScanAttempt::query()->create([
'tenant_code' => $scanner->tenant_codigo, 'tenant_code' => $scanner->tenant_codigo,
'scanner_user_id' => $scanner->getKey(), 'scanner_user_id' => $scanner->getKey(),
'event_id' => $this->eventId($scanner),
'data' => $this->serializeScannedData($scannedData), 'data' => $this->serializeScannedData($scannedData),
'result' => ScanAttemptResult::Processing, 'result' => ScanAttemptResult::Processing,
]); ]);
@@ -211,7 +200,6 @@ class ScannerTicketService
$ticket = $this->baseQuery() $ticket = $this->baseQuery()
->where('tenant_code', $scanner->tenant_codigo) ->where('tenant_code', $scanner->tenant_codigo)
->where('ticket', $scannedData) ->where('ticket', $scannedData)
->when($this->eventId($scanner) !== null, fn (Builder $query) => $query->where('event_id', $this->eventId($scanner)))
->lockForUpdate() ->lockForUpdate()
->firstOrFail(); ->firstOrFail();
$ticketId = (int) $ticket->getKey(); $ticketId = (int) $ticket->getKey();

View File

@@ -2,7 +2,6 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Domains\Core\Auth\Services\AdminAppAccessService;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use Closure; use Closure;
use Illuminate\Auth\Access\AuthorizationException; use Illuminate\Auth\Access\AuthorizationException;
@@ -11,12 +10,10 @@ use Symfony\Component\HttpFoundation\Response;
class EnsureAdminAppTenant class EnsureAdminAppTenant
{ {
public function __construct(private readonly AdminAppAccessService $accessService) {}
/** /**
* Ensure the authenticated user is an AdminApp user bound to a tenant. * Ensure the authenticated user is an AdminApp user bound to a tenant.
*/ */
public function handle(Request $request, Closure $next, string $access = 'tenant'): Response public function handle(Request $request, Closure $next): Response
{ {
$user = $request->user(); $user = $request->user();
@@ -24,9 +21,6 @@ class EnsureAdminAppTenant
! $user ! $user
|| $user->rol_codigo !== RoleCode::AdminApp->value || $user->rol_codigo !== RoleCode::AdminApp->value
|| ! $user->tenant_codigo || ! $user->tenant_codigo
|| ! $this->accessService->hasValidScope($user)
// Only routes implementing event authorization may accept event administrators.
|| (! in_array($access, ['context', 'event'], true) && ! $user->isTenantAdministrator())
) { ) {
throw new AuthorizationException; throw new AuthorizationException;
} }

View File

@@ -2,7 +2,6 @@
namespace App\Http\Middleware; namespace App\Http\Middleware;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Domains\Core\Authorization\Enums\PermissionCode; use App\Domains\Core\Authorization\Enums\PermissionCode;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use Closure; use Closure;
@@ -28,8 +27,6 @@ class EnsureScannerTenant
throw new AuthorizationException; throw new AuthorizationException;
} }
app(EventScopeService::class)->eventId($user);
return $next($request); return $next($request);
} }
} }

View File

@@ -2,10 +2,9 @@
namespace App\Shared\Forms\Controllers\AdminApp; namespace App\Shared\Forms\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Http\Controllers\Controller;
use App\Shared\Forms\Resources\EventFormResource; use App\Shared\Forms\Resources\EventFormResource;
use App\Shared\Forms\Services\EventFormService; use App\Shared\Forms\Services\EventFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class EventFormController extends Controller class EventFormController extends Controller
@@ -16,8 +15,7 @@ class EventFormController extends Controller
{ {
return EventFormResource::make( return EventFormResource::make(
$this->eventFormService->get( $this->eventFormService->get(
$request->user('sanctum')->tenant()->firstOrFail(), $request->user('sanctum')->tenant()->firstOrFail()
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }

View File

@@ -2,10 +2,9 @@
namespace App\Shared\Forms\Controllers\AdminApp; namespace App\Shared\Forms\Controllers\AdminApp;
use App\Domains\Core\Auth\Services\EventScopeService;
use App\Http\Controllers\Controller;
use App\Shared\Forms\Resources\StaffFormResource; use App\Shared\Forms\Resources\StaffFormResource;
use App\Shared\Forms\Services\StaffFormService; use App\Shared\Forms\Services\StaffFormService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request; use Illuminate\Http\Request;
class StaffFormController extends Controller class StaffFormController extends Controller
@@ -16,8 +15,7 @@ class StaffFormController extends Controller
{ {
return StaffFormResource::make( return StaffFormResource::make(
$this->staffFormService->get( $this->staffFormService->get(
$request->user('sanctum')->tenant()->firstOrFail(), $request->user('sanctum')->tenant()->firstOrFail()
app(EventScopeService::class)->eventId($request->user())
) )
); );
} }

View File

@@ -4,17 +4,14 @@ namespace App\Shared\Forms\Services;
use App\Domains\Core\Tenant\Models\SocialMedia; use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
class EventFormService class EventFormService
{ {
/** @return array{social_media: Collection<int, SocialMedia>} */ /** @return array{social_media: Collection<int, SocialMedia>} */
public function get(Tenant $tenant, ?int $eventId = null): array public function get(Tenant $tenant): array
{ {
$event = $eventId === null ? $tenant->activeEvent $event = $tenant->activeEvent;
: Event::query()
->where('tenant_code', $tenant->codigo)->findOrFail($eventId);
$urls = $event?->socialMedia() $urls = $event?->socialMedia()
->pluck('event_social_media.url', 'social_media.code') ?? collect(); ->pluck('event_social_media.url', 'social_media.code') ?? collect();

View File

@@ -10,7 +10,7 @@ use Illuminate\Database\Eloquent\Collection;
class StaffFormService class StaffFormService
{ {
/** @return array{categories: Collection<int, Category>} */ /** @return array{categories: Collection<int, Category>} */
public function get(Tenant $tenant, ?int $eventId = null): array public function get(Tenant $tenant): array
{ {
return [ return [
'categories' => Category::query() 'categories' => Category::query()
@@ -20,8 +20,6 @@ class StaffFormService
->orWhereHas('catalogItems', fn (Builder $items) => $items ->orWhereHas('catalogItems', fn (Builder $items) => $items
->where('tenant_code', $tenant->codigo)); ->where('tenant_code', $tenant->codigo));
}) })
->when($eventId !== null, fn (Builder $query) => $query->whereHas(
'catalogItems', fn (Builder $items) => $items->where('tenant_code', $tenant->codigo)->where('event_id', $eventId)))
->orderBy('nombre') ->orderBy('nombre')
->get(), ->get(),
]; ];

View File

@@ -12,33 +12,33 @@ use App\Shared\Forms\Controllers\AdminApp\TicketFormController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms') Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant:event']) ->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void { ->group(function (): void {
Route::get('event', EventFormController::class); Route::get('event', EventFormController::class);
Route::get( Route::get(
'desfile/entry-reservation', 'desfile/entry-reservation',
DesfileEntryReservationFormController::class DesfileEntryReservationFormController::class
)->middleware('adminapp.tenant')->middleware('tenant.menu:adminapp.desfile.reservas') )->middleware('tenant.menu:adminapp.desfile.reservas')
->name('adminapp.forms.desfile.entry-reservation'); ->name('adminapp.forms.desfile.entry-reservation');
Route::get('sale', SaleFormController::class); Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class); Route::get('staff', StaffFormController::class);
Route::get('tickets-filter', TicketFilterFormController::class)->middleware('adminapp.tenant') Route::get('tickets-filter', TicketFilterFormController::class)
->middleware('tenant.menu:adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')
->name('adminapp.forms.tickets-filter'); ->name('adminapp.forms.tickets-filter');
Route::get( Route::get(
'fiesta-futbol-infantil/ticket', 'fiesta-futbol-infantil/ticket',
TicketFormController::class TicketFormController::class
)->middleware('adminapp.tenant'); );
Route::get( Route::get(
'fiesta-futbol-infantil/entry', 'fiesta-futbol-infantil/entry',
EntryFormController::class EntryFormController::class
)->middleware('adminapp.tenant'); );
Route::get( Route::get(
'fiesta-futbol-infantil/merchandise', 'fiesta-futbol-infantil/merchandise',
MerchandiseFormController::class MerchandiseFormController::class
)->middleware('adminapp.tenant'); );
Route::get( Route::get(
'fiesta-futbol-infantil/food', 'fiesta-futbol-infantil/food',
FoodFormController::class FoodFormController::class
)->middleware('adminapp.tenant'); );
}); });

View File

@@ -3,12 +3,11 @@
namespace App\Shared\Integration\Services; namespace App\Shared\Integration\Services;
use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event;
use App\Shared\Integration\Models\AdminWebsiteTypeIntegration;
use App\Shared\Integration\Models\ClientIntegration; use App\Shared\Integration\Models\ClientIntegration;
use App\Shared\Integration\Models\Integration; use App\Shared\Integration\Models\Integration;
use App\Shared\Integration\Models\IntegrationInstance; use App\Shared\Integration\Models\IntegrationInstance;
use App\Shared\Integration\Models\AdminWebsiteTypeIntegration;
use App\Domains\Core\Tenant\Models\Tenant;
use Exception; use Exception;
use Illuminate\Http\Client\PendingRequest; use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Http;
@@ -87,17 +86,6 @@ abstract class BaseIntegrationService
return $this; return $this;
} }
public function forEvent(Event $event): self
{
$event->loadMissing(['client', 'tenant.client']);
$this->tenant = $event->tenant;
$this->tenantCode = $event->tenant_code;
$this->clientContext = $event->effectiveClient();
$this->loadIntegration();
return $this;
}
/** /**
* Load the integration definition and its effective instance configuration. * Load the integration definition and its effective instance configuration.
* *

View File

@@ -3,9 +3,8 @@
namespace App\Shared\Integration\Services; namespace App\Shared\Integration\Services;
use App\Domains\Core\Client\Models\Client; use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Core\Tenant\Models\AdminWebsiteType;
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;
@@ -40,7 +39,14 @@ class MailService extends BaseIntegrationService
public function forTenant(string $tenantCode): self public function forTenant(string $tenantCode): self
{ {
parent::forTenant($tenantCode); parent::forTenant($tenantCode);
$this->configureMailer();
if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true;
} else {
$this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false;
}
return $this; return $this;
} }
@@ -49,30 +55,16 @@ class MailService extends BaseIntegrationService
{ {
parent::forClient($client); parent::forClient($client);
$this->tenant = $this->clientContext?->tenants()->first(); $this->tenant = $this->clientContext?->tenants()->first();
$this->configureMailer();
return $this;
}
public function forEvent(Event $event): self
{
parent::forEvent($event);
$this->configureMailer();
return $this;
}
private function configureMailer(): void
{
if ($this->integrationInstance) { if ($this->integrationInstance) {
$this->mailer = $this->resolveMailer(); $this->mailer = $this->resolveMailer();
$this->usesInstanceMailer = true; $this->usesInstanceMailer = true;
} else {
return; $this->mailer = $this->mailFactory->mailer();
$this->usesInstanceMailer = false;
} }
$this->mailer = $this->mailFactory->mailer(); return $this;
$this->usesInstanceMailer = false;
} }
public function getHeaders(): array public function getHeaders(): array
@@ -91,7 +83,7 @@ class MailService extends BaseIntegrationService
array $attachments = [], array $attachments = [],
): void { ): void {
if (! $this->mailer || ! $this->tenant) { if (! $this->mailer || ! $this->tenant) {
throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.'); throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
} }
$brand ??= $this->tenant; $brand ??= $this->tenant;
@@ -168,7 +160,7 @@ class MailService extends BaseIntegrationService
public function onSetup(): void public function onSetup(): void
{ {
if (! $this->mailer || ! $this->clientContext) { if (! $this->mailer || ! $this->clientContext) {
throw new Exception('MailService no está configurado. Llamá a forTenant(), forClient() o forEvent() primero.'); throw new Exception('MailService no está configurado. Llamá a forTenant() o forClient() primero.');
} }
$recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS'); $recipient = $this->getIntegrationSetting('MAIL_FROM_ADDRESS');

View File

@@ -46,7 +46,7 @@ class TelepagosIntegrationService extends BaseIntegrationService
public function getToken(): string public function getToken(): string
{ {
if (! $this->integrationInstance) { if (! $this->integrationInstance) {
throw new Exception('Client integration is not loaded. Call forTenant(), forClient(), or forEvent() first.'); throw new Exception('Client integration is not loaded. Call forTenant() or forClient() first.');
} }
$cacheKey = $this->integrationInstance->tokenCacheKey(); $cacheKey = $this->integrationInstance->tokenCacheKey();

View File

@@ -2,12 +2,12 @@
namespace App\Shared\Integration\Services; namespace App\Shared\Integration\Services;
use App\Domains\Core\Client\Models\Client;
use App\Domains\Commerce\Purchase\Models\Purchase; use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Commerce\Purchase\Models\TelepagosPayment; use App\Domains\Commerce\Purchase\Models\TelepagosPayment;
use App\Domains\Commerce\Purchase\Models\TelepagosQr; use App\Domains\Commerce\Purchase\Models\TelepagosQr;
use App\Domains\Commerce\Purchase\Services\CheckoutService; use App\Domains\Commerce\Purchase\Services\CheckoutService;
use App\Domains\Commerce\Purchase\Services\DniDistanceService; use App\Domains\Commerce\Purchase\Services\DniDistanceService;
use App\Domains\Core\Client\Models\Client;
use Exception; use Exception;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
@@ -75,30 +75,9 @@ class TelepagosWebhookService
$dni = substr($cuit, 2, -1); $dni = substr($cuit, 2, -1);
$tenantCodes = $client->tenants()->pluck('codigo');
$eligiblePurchases = Purchase::query() $eligiblePurchases = Purchase::query()
->where(function (Builder $purchases) use ($client): void { ->whereIn('tenant_codigo', $tenantCodes)
$purchases
->whereHas('event', function (Builder $events) use ($client): void {
$events
->where('client_id', $client->id)
->orWhere(function (Builder $fallback) use ($client): void {
$fallback
->whereNull('client_id')
->whereHas(
'tenant',
fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id),
);
});
})
->orWhere(function (Builder $legacy) use ($client): void {
$legacy
->whereNull('event_id')
->whereHas(
'tenant',
fn (Builder $tenants): Builder => $tenants->where('client_id', $client->id),
);
});
})
->whereIn('status', [ ->whereIn('status', [
Purchase::STATUS_CREATED, Purchase::STATUS_CREATED,
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_PENDING_PAYMENT,
@@ -191,7 +170,7 @@ class TelepagosWebhookService
return; return;
} }
if (! $this->purchaseBelongsToClient($compra, $client)) { if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [ Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
'client_code' => $client->code, 'client_code' => $client->code,
'cashin_id' => $cashinId, 'cashin_id' => $cashinId,
@@ -265,16 +244,6 @@ class TelepagosWebhookService
return number_format((float) $amount, 2, '.', ''); return number_format((float) $amount, 2, '.', '');
} }
private function purchaseBelongsToClient(Purchase $purchase, Client $client): bool
{
$purchase->loadMissing(['event.client', 'event.tenant.client', 'tenant.client']);
$effectiveClient = $purchase->event?->effectiveClient()
?? $purchase->tenant?->client;
return $effectiveClient?->is($client) ?? false;
}
/** /**
* @param Builder<Purchase> $eligiblePurchases * @param Builder<Purchase> $eligiblePurchases
* @return Collection<int, Purchase> * @return Collection<int, Purchase>

View File

@@ -2,16 +2,16 @@
namespace App\Shared\Notification\Services; namespace App\Shared\Notification\Services;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Auth\Models\ResetPasswordAttempt; use App\Domains\Core\Auth\Models\ResetPasswordAttempt;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Shared\Integration\Services\MailService;
use App\Shared\Notification\Events\PasswordResetRequested;
use App\Domains\Commerce\Purchase\Models\Purchase;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Ticket\Models\Ticket; use App\Domains\Ticketing\Ticket\Models\Ticket;
use App\Domains\Ticketing\Ticket\Services\TicketPdfService; use App\Domains\Ticketing\Ticket\Services\TicketPdfService;
use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver; use App\Domains\Ticketing\Ticket\Services\TicketPresentationResolver;
use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver; use App\Domains\Ticketing\Ticket\Services\TicketValidityResolver;
use App\Shared\Integration\Services\MailService;
use App\Shared\Notification\Events\PasswordResetRequested;
use Closure; use Closure;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
@@ -158,7 +158,7 @@ class NotificationMailService
$context = ['purchase_id' => $purchaseId]; $context = ['purchase_id' => $purchaseId];
$purchase = Purchase::query() $purchase = Purchase::query()
->with(['event', 'tenant', 'user', 'items']) ->with(['tenant', 'user', 'items'])
->find($purchaseId); ->find($purchaseId);
if ($purchase === null) { if ($purchase === null) {
@@ -197,7 +197,8 @@ class NotificationMailService
'mime' => 'application/pdf', 'mime' => 'application/pdf',
]]; ]];
$this->mailForPurchase($purchase) $this->mailService
->forTenant($purchase->tenant_codigo)
->send( ->send(
$recipient, $recipient,
"Compra confirmada - Compra #{$purchase->getKey()}", "Compra confirmada - Compra #{$purchase->getKey()}",
@@ -285,6 +286,7 @@ class NotificationMailService
$context, $context,
$recipient, $recipient,
function () use ( function () use (
$tenantCode,
$purchase, $purchase,
$recipient, $recipient,
$previousDate, $previousDate,
@@ -293,7 +295,8 @@ class NotificationMailService
): array { ): array {
$brand = $purchase->tenant; $brand = $purchase->tenant;
$this->mailForPurchase($purchase) $this->mailService
->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}", "Tu evento fue reprogramado - N° de Orden #{$purchase->getKey()}",
@@ -384,6 +387,7 @@ class NotificationMailService
$context, $context,
$recipient, $recipient,
function () use ( function () use (
$tenantCode,
$purchase, $purchase,
$recipient, $recipient,
$date, $date,
@@ -392,7 +396,8 @@ class NotificationMailService
): array { ): array {
$brand = $purchase->tenant; $brand = $purchase->tenant;
$this->mailForPurchase($purchase) $this->mailService
->forTenant($tenantCode)
->send( ->send(
$recipient, $recipient,
"Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}", "Una fecha de tu evento fue suspendida - N° de Orden #{$purchase->getKey()}",
@@ -416,17 +421,10 @@ class NotificationMailService
{ {
return Purchase::query() return Purchase::query()
->where('tenant_codigo', $tenantCode) ->where('tenant_codigo', $tenantCode)
->with(['event', 'tenant', 'user']) ->with(['tenant', 'user'])
->find($purchaseId); ->find($purchaseId);
} }
private function mailForPurchase(Purchase $purchase): MailService
{
return $purchase->event
? $this->mailService->forEvent($purchase->event)
: $this->mailService->forTenant($purchase->tenant_codigo);
}
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);

View File

@@ -41,7 +41,7 @@ return new class extends Migration
return; return;
} }
$sourcePath = public_path('images/website_types/'.self::FILENAME); $sourcePath = public_path('images/website_types/onticket/'.self::FILENAME);
if (! is_file($sourcePath)) { if (! is_file($sourcePath)) {
throw new RuntimeException("Favicon not found at path: {$sourcePath}"); throw new RuntimeException("Favicon not found at path: {$sourcePath}");

View File

@@ -0,0 +1,89 @@
<?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 $withinTransaction = false;
private const FORMULA = 'CAST(real_stock AS SIGNED) - CAST(sold_units AS SIGNED) - CAST(reserved_stock AS SIGNED) - CAST(entry_reserved_stock AS SIGNED) + CAST(refunded_units AS SIGNED)';
public function up(): void
{
$conversion = 'CAST(real_stock AS SIGNED) + CAST(sold_units AS SIGNED) - CAST(refunded_units AS SIGNED)';
$this->assertNonNegative($conversion, 'la conversión produciría un stock real negativo');
$this->assertNonNegative(
"({$conversion}) - CAST(sold_units AS SIGNED) - CAST(reserved_stock AS SIGNED) - CAST(entry_reserved_stock AS SIGNED) + CAST(refunded_units AS SIGNED)",
'el stock disponible actual es negativo',
);
if (DB::getDriverName() === 'sqlite') {
Schema::table('inventories', function (Blueprint $table): void {
$table->bigInteger('available_stock')->virtualAs(self::FORMULA);
});
DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]);
} else {
Schema::table('inventories', function (Blueprint $table): void {
$table->unsignedBigInteger('real_stock')->default(0)->change();
$table->bigInteger('available_stock')->storedAs(self::FORMULA);
});
DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]);
}
}
public function down(): void
{
$conversion = 'CAST(real_stock AS SIGNED) - CAST(sold_units AS SIGNED) + CAST(refunded_units AS SIGNED)';
$this->assertNonNegative($conversion, 'el rollback produciría un stock real negativo');
if (DB::getDriverName() === 'sqlite') {
DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]);
Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('available_stock'));
return;
}
DB::table('inventories')->update(['real_stock' => DB::raw($conversion)]);
Schema::table('inventories', fn (Blueprint $table) => $table->dropColumn('available_stock'));
}
private function assertNonNegative(string $expression, string $reason): void
{
$invalidInventories = DB::table('inventories')
->select([
'id',
'real_stock',
'sold_units',
'refunded_units',
'reserved_stock',
'entry_reserved_stock',
])
->selectRaw("({$expression}) as calculated_stock")
->whereRaw("({$expression}) < 0")
->orderBy('id')
->limit(20)
->get();
if ($invalidInventories->isNotEmpty()) {
$details = $invalidInventories
->map(static fn (object $inventory): string => sprintf(
'id=%d [real=%d, vendidas=%d, reintegradas=%d, reservadas=%d, reservas_entradas=%d, resultado=%d]',
$inventory->id,
$inventory->real_stock,
$inventory->sold_units,
$inventory->refunded_units,
$inventory->reserved_stock,
$inventory->entry_reserved_stock,
$inventory->calculated_stock,
))
->implode('; ');
throw new RuntimeException(
"Hay inventarios inconsistentes: {$reason}. Inventarios detectados (máximo 20): {$details}."
);
}
}
};

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('inventory_movements', function (Blueprint $table): void {
$table->id();
$table->foreignId('inventory_id')->constrained('inventories')->restrictOnDelete();
$table->string('operation', 40);
$table->bigInteger('available_stock_delta');
$table->bigInteger('available_stock_before');
$table->bigInteger('available_stock_after');
$table->json('counter_deltas');
$table->foreignId('responsible_user_id')->nullable()->constrained('users')->restrictOnDelete();
$table->uuid('idempotency_key')->nullable();
$table->timestamps();
$table->unique(['inventory_id', 'idempotency_key']);
$table->index(['inventory_id', 'created_at']);
$table->index(['operation', 'created_at']);
});
}
public function down(): void
{
Schema::dropIfExists('inventory_movements');
}
};

View File

@@ -1,26 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table): void {
$table->foreignId('client_id')
->nullable()
->after('id')
->constrained('clients')
->restrictOnDelete();
});
}
public function down(): void
{
Schema::table('events', function (Blueprint $table): void {
$table->dropConstrainedForeignId('client_id');
});
}
};

View File

@@ -0,0 +1,47 @@
<?php
use App\Domains\Core\Tenant\Models\AdminWebsiteType;
use App\Domains\Core\Tenant\Services\AdminWebsiteTypeService;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Storage;
return new class extends Migration
{
public function up(): void
{
if (app()->environment('testing')) {
Storage::fake('s3');
}
if (! AdminWebsiteType::query()->where('codigo', 'shopit')->exists()) {
return;
}
app(AdminWebsiteTypeService::class)->updateOrCreate(
['codigo' => 'shopit'],
[
'primary_color' => '#2FD3AC',
'site_logo' => $this->uploadedImage('shopit_logo.png', 'image/png'),
'footer_logo' => $this->uploadedImage('shopit_footer_logo.png', 'image/png'),
'favicon' => $this->uploadedImage('shopit-favicon.svg', 'image/svg+xml'),
],
);
}
public function down(): void
{
// Brand assets are operational data and are intentionally preserved.
}
private function uploadedImage(string $filename, string $mimeType): UploadedFile
{
$path = public_path("images/website_types/shopit/{$filename}");
if (! is_file($path)) {
throw new RuntimeException("ShopIt image not found at path: {$path}");
}
return new UploadedFile($path, $filename, $mimeType, null, true);
}
};

View File

@@ -1,44 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->foreignId('event_id')
->nullable()
->after('tenant_codigo')
->constrained('events')
->nullOnDelete();
});
DB::table('compras')
->orderBy('id')
->each(function (object $purchase): void {
$eventIds = DB::table('compra_items')
->join('catalog_items', 'catalog_items.id', '=', 'compra_items.source_catalog_item_id')
->where('compra_items.compra_id', $purchase->id)
->whereNotNull('catalog_items.event_id')
->distinct()
->pluck('catalog_items.event_id');
if ($eventIds->count() === 1) {
DB::table('compras')
->where('id', $purchase->id)
->update(['event_id' => $eventIds->first()]);
}
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
});
}
};

View File

@@ -1,42 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
private const EVENT_TITLE = '26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas';
public function up(): void
{
$clientId = DB::table('clients')
->where('code', 'pyme_rural')
->value('id');
if ($clientId === null) {
return;
}
DB::table('events')
->where('tenant_code', 'onticket')
->where('title', self::EVENT_TITLE)
->update(['client_id' => $clientId]);
}
public function down(): void
{
$clientId = DB::table('clients')
->where('code', 'pyme_rural')
->value('id');
if ($clientId === null) {
return;
}
DB::table('events')
->where('tenant_code', 'onticket')
->where('title', self::EVENT_TITLE)
->where('client_id', $clientId)
->update(['client_id' => null]);
}
};

View File

@@ -1,25 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table): void {
// Existing administrators keep their tenant-wide access.
$table->string('admin_scope', 20)->default('tenant');
$table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete();
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
$table->dropColumn('admin_scope');
});
}
};

View File

@@ -1,44 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::transaction(function (): void {
DB::table('tenants')
->join('events', 'events.id', '=', 'tenants.active_event_id')
->whereColumn('events.tenant_code', 'tenants.codigo')
->select('tenants.codigo', 'tenants.active_event_id')
->get()
->each(function (object $tenant): void {
DB::table('users')
->whereIn('rol_codigo', ['adminapp', 'scanner'])
->where('tenant_codigo', $tenant->codigo)
->where('admin_scope', 'tenant')
->whereNull('event_id')
->whereNull('deleted_at')
->update([
'admin_scope' => 'event',
'event_id' => $tenant->active_event_id,
'updated_at' => now(),
]);
});
// Missing or mismatched active events must not leave legacy staff with tenant-wide access.
DB::table('users')
->whereIn('rol_codigo', ['adminapp', 'scanner'])
->where('admin_scope', 'tenant')
->whereNull('event_id')
->whereNull('deleted_at')
->update(['admin_scope' => 'event', 'updated_at' => now()]);
});
}
public function down(): void
{
// Do not broaden permissions or overwrite subsequent assignments on rollback.
}
};

View File

@@ -1,38 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('events', function (Blueprint $table): void {
$table->boolean('allow_ticket_refund')->default(false);
$table->boolean('allow_ticket_total_refund')->default(false);
$table->boolean('allow_ticket_partial_refund')->default(false);
$table->decimal('ticket_partial_refund_percentage', 5, 2)->default(0);
});
DB::table('tenants')->select([
'codigo', 'allow_ticket_refund', 'allow_ticket_total_refund',
'allow_ticket_partial_refund', 'ticket_partial_refund_percentage',
])->get()->each(function (object $tenant): void {
DB::table('events')->where('tenant_code', $tenant->codigo)->update([
'allow_ticket_refund' => $tenant->allow_ticket_refund,
'allow_ticket_total_refund' => $tenant->allow_ticket_total_refund,
'allow_ticket_partial_refund' => $tenant->allow_ticket_partial_refund,
'ticket_partial_refund_percentage' => $tenant->ticket_partial_refund_percentage ?? 0,
]);
});
}
public function down(): void
{
Schema::table('events', fn (Blueprint $table) => $table->dropColumn([
'allow_ticket_refund', 'allow_ticket_total_refund',
'allow_ticket_partial_refund', 'ticket_partial_refund_percentage',
]));
}
};

View File

@@ -1,22 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::transaction(function (): void {
$codes = ['adminapp.combos', 'adminapp.categories'];
DB::table('roles_menues')->whereIn('menu_codigo', $codes)->delete();
DB::table('tenants_menues')->whereIn('menu_code', $codes)->delete();
DB::table('menues')->whereIn('code', $codes)->delete();
});
}
public function down(): void
{
// Deprecated menus must not be restored by rollback.
}
};

View File

@@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('scan_attempts', function (Blueprint $table): void {
$table->foreignId('event_id')->nullable()->constrained('events')->nullOnDelete();
$table->index(['scanner_user_id', 'event_id']);
});
DB::table('scan_attempts')->update([
'event_id' => DB::raw('(SELECT tickets.event_id FROM tickets WHERE tickets.id = scan_attempts.ticket_id)'),
]);
}
public function down(): void
{
Schema::table('scan_attempts', function (Blueprint $table): void {
$table->dropIndex(['scanner_user_id', 'event_id']);
$table->dropConstrainedForeignId('event_id');
});
}
};

View File

@@ -6,7 +6,6 @@ use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
use App\Domains\Commerce\Catalog\Models\Attribute; use App\Domains\Commerce\Catalog\Models\Attribute;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Commerce\Catalog\Services\CatalogService; use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Core\Client\Models\Client;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Event\Models\Event;
use App\Shared\Attachable\Services\AttachmentService; use App\Shared\Attachable\Services\AttachmentService;
@@ -36,12 +35,6 @@ class FiestaTradicionArrufoSeeder extends Seeder
throw new RuntimeException("Tenant 'onticket' no encontrado."); throw new RuntimeException("Tenant 'onticket' no encontrado.");
} }
$client = Client::query()->where('code', 'pyme_rural')->first();
if ($client === null) {
throw new RuntimeException("Client 'pyme_rural' no encontrado.");
}
Attribute::query()->firstOrCreate( Attribute::query()->firstOrCreate(
['tenant_codigo' => $tenant->codigo, 'codigo' => 'event_date'], ['tenant_codigo' => $tenant->codigo, 'codigo' => 'event_date'],
['nombre' => 'Fecha', 'type' => FieldType::EventDate, 'is_required' => true], ['nombre' => 'Fecha', 'type' => FieldType::EventDate, 'is_required' => true],
@@ -52,7 +45,6 @@ class FiestaTradicionArrufoSeeder extends Seeder
['published_at' => now()], ['published_at' => now()],
); );
$event->update([ $event->update([
'client_id' => $client->id,
'subtitle' => 'Una noche para celebrar nuestras raíces y mantener viva la tradición gaucha.', 'subtitle' => 'Una noche para celebrar nuestras raíces y mantener viva la tradición gaucha.',
'description' => 'La 26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas reunirá a agrupaciones, artesanos, pilcheros y público en general para compartir una jornada dedicada a nuestras costumbres y cultura.' 'description' => 'La 26.º Fiesta de la Tradición y 4.º Encuentro de Agrupaciones Gauchas reunirá a agrupaciones, artesanos, pilcheros y público en general para compartir una jornada dedicada a nuestras costumbres y cultura.'
."\n\n".'Un encuentro para disfrutar de la tradición, la identidad gaucha y el espíritu de camaradería, en el Predio de Doma del Club Unión Deportiva Arrufó.' ."\n\n".'Un encuentro para disfrutar de la tradición, la identidad gaucha y el espíritu de camaradería, en el Predio de Doma del Club Unión Deportiva Arrufó.'

View File

@@ -70,6 +70,18 @@ class MenuSeeder extends Seeder
'parent_menu_code' => 'main.adminapp', 'parent_menu_code' => 'main.adminapp',
'route' => '/admin/catalog', 'route' => '/admin/catalog',
], ],
[
'code' => 'adminapp.combos',
'label' => 'Combos',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/combos',
],
[
'code' => 'adminapp.categories',
'label' => 'Categorías',
'parent_menu_code' => 'main.adminapp',
'route' => '/admin/categories',
],
[ [
'code' => 'adminapp.ventas', 'code' => 'adminapp.ventas',
'label' => 'Ventas', 'label' => 'Ventas',
@@ -230,8 +242,6 @@ class MenuSeeder extends Seeder
->whereIn('code', [ ->whereIn('code', [
'admin.event', 'admin.event',
'admin.catalog', 'admin.catalog',
'adminapp.combos',
'adminapp.categories',
'admin.combos', 'admin.combos',
'admin.categories', 'admin.categories',
'admin.ventas', 'admin.ventas',

View File

@@ -40,7 +40,7 @@ class WebsiteTypeSeeder extends Seeder
public function run(): void public function run(): void
{ {
$shopIt = $this->websiteTypeService->updateOrCreate( $this->websiteTypeService->updateOrCreate(
['codigo' => 'shopit'], ['codigo' => 'shopit'],
[ [
'nombre' => 'ShopIt', 'nombre' => 'ShopIt',
@@ -48,9 +48,10 @@ class WebsiteTypeSeeder extends Seeder
'scanner_domain' => 'scanner.localhost', 'scanner_domain' => 'scanner.localhost',
'site_title' => 'ShopIt', 'site_title' => 'ShopIt',
...self::PRESENTATION, ...self::PRESENTATION,
'site_logo' => $this->onTicketLogo(), 'primary_color' => '#2FD3AC',
'footer_logo' => $this->onTicketFooterLogo(), 'site_logo' => $this->shopItLogo(),
'favicon' => $this->onTicketFavicon(), 'footer_logo' => $this->shopItFooterLogo(),
'favicon' => $this->shopItFavicon(),
], ],
); );
@@ -83,7 +84,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, 'favicon' => $this->onTicketFavicon(),
], ],
); );
@@ -204,7 +205,7 @@ class WebsiteTypeSeeder extends Seeder
private function onTicketLogo(): UploadedFile private function onTicketLogo(): UploadedFile
{ {
$path = public_path('images/website_types/onticket_logo.png'); $path = public_path('images/website_types/onticket/onticket_logo.png');
if (! file_exists($path)) { if (! file_exists($path)) {
throw new RuntimeException("OnTicket logo not found at path: {$path}"); throw new RuntimeException("OnTicket logo not found at path: {$path}");
@@ -221,7 +222,7 @@ class WebsiteTypeSeeder extends Seeder
private function onTicketFooterLogo(): UploadedFile private function onTicketFooterLogo(): UploadedFile
{ {
$path = public_path('images/website_types/onticket_footer_logo.png'); $path = public_path('images/website_types/onticket/onticket_footer_logo.png');
if (! file_exists($path)) { if (! file_exists($path)) {
throw new RuntimeException("OnTicket footer logo not found at path: {$path}"); throw new RuntimeException("OnTicket footer logo not found at path: {$path}");
@@ -238,7 +239,7 @@ class WebsiteTypeSeeder extends Seeder
private function onTicketFavicon(): UploadedFile private function onTicketFavicon(): UploadedFile
{ {
$path = public_path('images/website_types/onticket_favicon.svg'); $path = public_path('images/website_types/onticket/onticket_favicon.svg');
if (! file_exists($path)) { if (! file_exists($path)) {
throw new RuntimeException("OnTicket favicon not found at path: {$path}"); throw new RuntimeException("OnTicket favicon not found at path: {$path}");
@@ -252,4 +253,30 @@ class WebsiteTypeSeeder extends Seeder
true, true,
); );
} }
private function shopItLogo(): UploadedFile
{
return $this->uploadedImage('shopit', 'shopit_logo.png', 'image/png');
}
private function shopItFooterLogo(): UploadedFile
{
return $this->uploadedImage('shopit', 'shopit_footer_logo.png', 'image/png');
}
private function shopItFavicon(): UploadedFile
{
return $this->uploadedImage('shopit', 'shopit-favicon.svg', 'image/svg+xml');
}
private function uploadedImage(string $websiteType, string $filename, string $mimeType): UploadedFile
{
$path = public_path("images/website_types/{$websiteType}/{$filename}");
if (! file_exists($path)) {
throw new RuntimeException("Website type image not found at path: {$path}");
}
return new UploadedFile($path, $filename, $mimeType, null, true);
}
} }

View File

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 422 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -0,0 +1,10 @@
<svg width="41" height="41" viewBox="0 0 41 41" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:se="http://svg-edit.googlecode.com" se:swatches="W3siaWQiOiJCMDBEREFFMy0zMjUwLTRERDgtOEU0RS0zMDYxREJGNUE3QTMiLCJuYW1lIjoiIzVCNUI1QiIsImhleCI6IiM1QjVCNUIifSx7ImlkIjoiRjI4QjU4NTYtNUM2OC00QkY4LTk3MDMtOTk5NDJCOTgzNTVBIiwibmFtZSI6IiMyRkQzQUMiLCJoZXgiOiIjMkZEM0FDIn1d">
<g><se:title>Layer 1</se:title><path d="m16.32,27.13c2.29,0 3.13,-0.37 3.73,-0.76c0.65,-0.42 1.17,-1.06 1.24,-1.97c0.06,-0.76 -0.29,-1.4 -0.81,-1.78c-0.7,-0.5 -2.05,-0.81 -3.21,-1.05c-1.13,-0.24 -2.14,-0.6 -3.03,-1.22c-1.06,-0.73 -1.61,-1.47 -1.96,-2.52c-0.45,-1.32 -0.36,-3.12 -0.11,-4.02c0.43,-1.54 1.35,-2.86 2.59,-3.84001c1.6,-1.26 3.58,-2.01999 6.46,-2.17999c0.3,-0.02 0.71,-0.04 1.21,-0.04c0,0 0.16,0 0.21,0h13.3c-3.68,-4.71 -9.41,-7.75 -15.85,-7.75c-11.1,0 -20.09,9 -20.09,20.09c0,2.47 0.45,4.84 1.27,7.03h15.06l-0.01,0.01z" fill="#2FD3AC" id="svg_7"/><path d="m23.11,12.59c-0.65,0 -1.64,0.03 -1.95,0.07c-1.3,0.17 -2.25,0.55 -2.82,1.31c-0.3199,0.43 -0.48,0.99 -0.29,1.66c0.31,1.07 2.1501,1.44 3.4001,1.7c1.49,0.31 2.5599,0.57 3.5899,1.08c1.06,0.53 2.03,1.41 2.51,2.43c0.46,0.98 0.6001,1.96 0.5201,3.08c-0.11,1.56 -0.4701,2.72 -0.9901,3.69c-0.49,0.91 -1.32,1.87 -2.03,2.44c-0.74,0.59 -1.81,1.38 -3.15,1.83c-1.35,0.45 -3.1399,0.63 -5.2999,0.63h-12.30005c3.68,4.68 9.39005,7.68 15.80005,7.68c11.1,0 20.0899,-9 20.0899,-20.09c0,-2.65 -0.5199,-5.18 -1.4499,-7.5h-15.6201l-0.01,-0.01z" fill="#5B5B5B" id="svg_8"/></g></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Some files were not shown because too many files have changed in this diff Show More