Compare commits
13 Commits
917f2dff09
...
feature/ev
| Author | SHA1 | Date | |
|---|---|---|---|
| 856b9a6706 | |||
| 3ddff7e0ee | |||
| 22de61c8c0 | |||
| 2d11263adc | |||
| c975b5d51d | |||
| c7afb70196 | |||
| 31955f862d | |||
| bb4495c6cc | |||
| 6e1f0d731c | |||
| 82c18e9e91 | |||
| dfad6fb4b0 | |||
| 3f9b773367 | |||
| 7996d53e12 |
@@ -1,18 +0,0 @@
|
|||||||
<?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';
|
|
||||||
}
|
|
||||||
@@ -19,7 +19,6 @@ 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',
|
||||||
@@ -214,14 +213,10 @@ 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) use ($availableInventory): void {
|
})->where(function (Builder $query): void {
|
||||||
$query
|
$query
|
||||||
->where(function (Builder $unlimitedQuery): void {
|
->where(function (Builder $unlimitedQuery): void {
|
||||||
$unlimitedQuery
|
$unlimitedQuery
|
||||||
@@ -241,18 +236,20 @@ class CatalogItem extends Model
|
|||||||
->whereNull('replaced_by_variant_id')
|
->whereNull('replaced_by_variant_id')
|
||||||
->whereHas(
|
->whereHas(
|
||||||
'inventory',
|
'inventory',
|
||||||
$availableInventory
|
fn (Builder $inventoryQuery): Builder => $inventoryQuery
|
||||||
|
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
->orWhere(function (Builder $directItemQuery) use ($availableInventory): void {
|
->orWhere(function (Builder $directItemQuery): void {
|
||||||
$directItemQuery
|
$directItemQuery
|
||||||
->whereDoesntHave('variants')
|
->whereDoesntHave('variants')
|
||||||
->where(function (Builder $inventoryQuery) use ($availableInventory): void {
|
->where(function (Builder $inventoryQuery): void {
|
||||||
$inventoryQuery
|
$inventoryQuery
|
||||||
->whereNull('catalog_items.inventory_id')
|
->whereNull('catalog_items.inventory_id')
|
||||||
->orWhereHas(
|
->orWhereHas(
|
||||||
'inventory',
|
'inventory',
|
||||||
$availableInventory
|
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
|
||||||
|
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,17 +2,11 @@
|
|||||||
|
|
||||||
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',
|
||||||
@@ -44,7 +38,6 @@ 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',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,295 +59,70 @@ 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
|
||||||
{
|
{
|
||||||
if (! array_key_exists('available_stock', $this->attributes)) {
|
return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_stock);
|
||||||
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
|
||||||
{
|
{
|
||||||
$this->mutate(
|
if ($amount < 1 || ($tracksInventory && $this->availableStock() < $amount)) {
|
||||||
InventoryMovementOperation::EntryStockReserved,
|
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.');
|
||||||
['entry_reserved_stock' => $amount],
|
}
|
||||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
$this->entry_reserved_stock += $amount;
|
||||||
if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) {
|
$this->save();
|
||||||
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function releaseEntry(int $amount): void
|
public function releaseEntry(int $amount): void
|
||||||
{
|
{
|
||||||
$this->mutate(
|
if ($amount < 1 || $this->entry_reserved_stock < $amount) {
|
||||||
InventoryMovementOperation::EntryStockReleased,
|
throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.');
|
||||||
['entry_reserved_stock' => -$amount],
|
}
|
||||||
function (self $inventory) use ($amount): void {
|
|
||||||
if ($amount < 1 || $inventory->entry_reserved_stock < $amount) {
|
$this->entry_reserved_stock -= $amount;
|
||||||
throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.');
|
$this->save();
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function reserve(int $amount, bool $tracksInventory): void
|
public function reserve(int $amount, bool $tracksInventory): void
|
||||||
{
|
{
|
||||||
$this->mutate(
|
if ($amount < 0) {
|
||||||
InventoryMovementOperation::StockReserved,
|
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
|
||||||
['reserved_stock' => $amount],
|
}
|
||||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
|
||||||
if ($amount < 0) {
|
if ($tracksInventory && $this->availableStock() < $amount) {
|
||||||
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
|
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||||
}
|
}
|
||||||
if ($tracksInventory && $inventory->availableStock() < $amount) {
|
|
||||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
$this->reserved_stock += $amount;
|
||||||
}
|
$this->save();
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function release(int $amount): void
|
public function release(int $amount): void
|
||||||
{
|
{
|
||||||
$this->mutate(
|
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||||
InventoryMovementOperation::StockReleased,
|
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
|
||||||
['reserved_stock' => -$amount],
|
}
|
||||||
function (self $inventory) use ($amount): void {
|
|
||||||
if ($amount < 0 || $inventory->reserved_stock < $amount) {
|
$this->reserved_stock -= $amount;
|
||||||
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
|
$this->save();
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function buy(int $amount, bool $tracksInventory): void
|
public function buy(int $amount, bool $tracksInventory): void
|
||||||
{
|
{
|
||||||
$counterDeltas = ['reserved_stock' => -$amount, 'sold_units' => $amount];
|
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||||
if (! array_key_exists('available_stock', $this->attributes) && $tracksInventory) {
|
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
|
||||||
$counterDeltas['real_stock'] = -$amount;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->mutate(
|
if ($tracksInventory && $this->real_stock - $this->entry_reserved_stock < $amount) {
|
||||||
InventoryMovementOperation::PurchaseCommitted,
|
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$operation = $delta > 0
|
if ($tracksInventory) {
|
||||||
? InventoryMovementOperation::StockIncreased
|
$this->real_stock -= $amount;
|
||||||
: 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->mutate(
|
$this->reserved_stock -= $amount;
|
||||||
InventoryMovementOperation::StockRefunded,
|
$this->sold_units += $amount;
|
||||||
$counterDeltas,
|
$this->save();
|
||||||
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,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
<?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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -64,7 +64,6 @@ 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)
|
||||||
@@ -154,7 +153,7 @@ class CatalogInventoryService
|
|||||||
|
|
||||||
if ($operation === 'commit'
|
if ($operation === 'commit'
|
||||||
&& $requirement['tracks_inventory']
|
&& $requirement['tracks_inventory']
|
||||||
&& $inventory->availableStock() < 0) {
|
&& $inventory->real_stock - $inventory->entry_reserved_stock < $requiredQuantity) {
|
||||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,12 +370,9 @@ class CatalogService
|
|||||||
|
|
||||||
private function createInventory(int $realStock): Inventory
|
private function createInventory(int $realStock): Inventory
|
||||||
{
|
{
|
||||||
$inventory = Inventory::query()->create([
|
return Inventory::query()->create([
|
||||||
'real_stock' => $realStock,
|
'real_stock' => $realStock,
|
||||||
]);
|
]);
|
||||||
$inventory->recordInitialization();
|
|
||||||
|
|
||||||
return $inventory;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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->availableStock() < 0)) {
|
|| ($line->tracks_inventory && $inventory->real_stock - $inventory->entry_reserved_stock < $line->quantity)) {
|
||||||
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.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -194,7 +194,6 @@ 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()
|
||||||
@@ -203,10 +202,7 @@ 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->transferCounters([
|
$sourceInventory->update(['reserved_stock' => 0, 'entry_reserved_stock' => 0]);
|
||||||
'reserved_stock' => -$sourceInventory->reserved_stock,
|
|
||||||
'entry_reserved_stock' => -$sourceInventory->entry_reserved_stock,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $replacementInventory;
|
return $replacementInventory;
|
||||||
}
|
}
|
||||||
@@ -240,14 +236,13 @@ class VariantReplacementService
|
|||||||
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
|
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
|
||||||
}
|
}
|
||||||
|
|
||||||
$transferredCounters = [
|
$destinationInventory->update([
|
||||||
'real_stock' => $sourceInventory->real_stock,
|
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
|
||||||
'reserved_stock' => $sourceInventory->reserved_stock,
|
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
|
||||||
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
|
'entry_reserved_stock' => $destinationInventory->entry_reserved_stock + $sourceInventory->entry_reserved_stock,
|
||||||
'sold_units' => $sourceInventory->sold_units,
|
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
|
||||||
'refunded_units' => $sourceInventory->refunded_units,
|
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
|
||||||
];
|
]);
|
||||||
$destinationInventory->transferCounters($transferredCounters);
|
|
||||||
if ($activeLines->isNotEmpty()) {
|
if ($activeLines->isNotEmpty()) {
|
||||||
StockReservationLine::query()
|
StockReservationLine::query()
|
||||||
->whereKey($activeLines->modelKeys())
|
->whereKey($activeLines->modelKeys())
|
||||||
@@ -255,10 +250,13 @@ 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->transferCounters(array_map(
|
$sourceInventory->update([
|
||||||
fn (int $value): int => -$value,
|
'real_stock' => 0,
|
||||||
$transferredCounters,
|
'reserved_stock' => 0,
|
||||||
));
|
'entry_reserved_stock' => 0,
|
||||||
|
'sold_units' => 0,
|
||||||
|
'refunded_units' => 0,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
|
|||||||
@@ -8,12 +8,14 @@ use Illuminate\Database\Eloquent\Builder;
|
|||||||
|
|
||||||
class PurchaseRefundSummaryService
|
class PurchaseRefundSummaryService
|
||||||
{
|
{
|
||||||
public function totalForTenant(Tenant $tenant): string
|
public function totalForTenant(Tenant $tenant, ?int $eventId = null): 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 $query): Builder => $query->where('event_id', $eventId))
|
||||||
)
|
)
|
||||||
->sum('amount');
|
->sum('amount');
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -70,12 +69,15 @@ class TenantTransactionResetService
|
|||||||
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
|
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
|
||||||
];
|
];
|
||||||
|
|
||||||
Inventory::query()
|
DB::table('inventories')
|
||||||
->whereKey($scope['inventory_ids'])
|
->whereIn('id', $scope['inventory_ids'])
|
||||||
->orderBy('id')
|
->update([
|
||||||
->lockForUpdate()
|
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
|
||||||
->get()
|
'reserved_stock' => 0,
|
||||||
->each(fn (Inventory $inventory) => $inventory->resetTransactionCounters());
|
'entry_reserved_stock' => 0,
|
||||||
|
'sold_units' => 0,
|
||||||
|
'refunded_units' => 0,
|
||||||
|
]);
|
||||||
|
|
||||||
return $summary;
|
return $summary;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -32,10 +32,10 @@ class SaleController extends Controller
|
|||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return SaleResource::collection(
|
return SaleResource::collection(
|
||||||
$this->saleService->sales($tenant, $request->validated())
|
$this->saleService->sales($tenant, $request->validated(), $request->user()->event_id)
|
||||||
)->additional([
|
)->additional([
|
||||||
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
|
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $request->user()->event_id),
|
||||||
'refunded_total' => $this->saleService->refundedTotal($tenant),
|
'refunded_total' => $this->saleService->refundedTotal($tenant, $request->user()->event_id),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,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));
|
return new SaleDetailResource($this->saleService->detail($tenant, $sale, $request->user()->event_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function tickets(Request $request, int $sale): AnonymousResourceCollection
|
public function tickets(Request $request, int $sale): AnonymousResourceCollection
|
||||||
@@ -51,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->saleService->tickets($tenant, $sale, $request->user()->event_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,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));
|
return new SaleResource($this->saleService->confirm($tenant, $sale, $request->user()->event_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
return new SaleResource($this->saleService->cancel($tenant, $sale, $request->user()->event_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function modifications(
|
public function modifications(
|
||||||
@@ -76,6 +76,7 @@ class SaleController extends Controller
|
|||||||
$this->saleService->modifications(
|
$this->saleService->modifications(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -86,7 +87,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->salePdfService->downloadSales(
|
return $this->salePdfService->downloadSales(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->salesForExport($tenant, $request->validated()),
|
$this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,7 +98,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->salePdfService->downloadModifications(
|
return $this->salePdfService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
$this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->saleExcelService->downloadSales(
|
return $this->saleExcelService->downloadSales(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->salesForExport($tenant, $request->validated()),
|
$this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -120,7 +121,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->saleExcelService->downloadModifications(
|
return $this->saleExcelService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
$this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -10,6 +9,7 @@ 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,19 +21,18 @@ class AdminAppSaleService
|
|||||||
protected PurchaseRefundSummaryService $refundSummaryService,
|
protected PurchaseRefundSummaryService $refundSummaryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function confirmedSalesTotal(Tenant $tenant): string
|
public function confirmedSalesTotal(Tenant $tenant, ?int $eventId = null): string
|
||||||
{
|
{
|
||||||
$total = Purchase::query()
|
$total = $this->purchasesQuery($tenant, $eventId)
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->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): string
|
public function refundedTotal(Tenant $tenant, ?int $eventId = null): string
|
||||||
{
|
{
|
||||||
return $this->refundSummaryService->totalForTenant($tenant);
|
return $this->refundSummaryService->totalForTenant($tenant, $eventId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,43 +46,42 @@ class AdminAppSaleService
|
|||||||
* } $filters
|
* } $filters
|
||||||
* @return LengthAwarePaginator<Purchase>
|
* @return LengthAwarePaginator<Purchase>
|
||||||
*/
|
*/
|
||||||
public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
public function sales(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return $this->salesQuery($tenant, $filters)
|
return $this->salesQuery($tenant, $filters, $eventId)
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
|
|
||||||
public function detail(Tenant $tenant, int $saleId): Purchase
|
public function detail(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||||
{
|
{
|
||||||
return Purchase::query()
|
return $this->purchasesQuery($tenant, $eventId)
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->with('items')
|
->with('items')
|
||||||
->findOrFail($saleId);
|
->findOrFail($saleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return Collection<int, Ticket> */
|
/** @return Collection<int, Ticket> */
|
||||||
public function tickets(Tenant $tenant, int $saleId): Collection
|
public function tickets(Tenant $tenant, int $saleId, ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->findForTenant($tenant, $saleId)
|
return $this->findForTenant($tenant, $saleId, $eventId)
|
||||||
->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): Purchase
|
public function confirm(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||||
{
|
{
|
||||||
$sale = $this->findForTenant($tenant, $saleId);
|
$sale = $this->findForTenant($tenant, $saleId, $eventId);
|
||||||
|
|
||||||
return $this->saleForResponse(
|
return $this->saleForResponse(
|
||||||
$this->checkoutService->confirmPaidPurchase($sale)
|
$this->checkoutService->confirmPaidPurchase($sale)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(Tenant $tenant, int $saleId): Purchase
|
public function cancel(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||||
{
|
{
|
||||||
$sale = $this->findForTenant($tenant, $saleId);
|
$sale = $this->findForTenant($tenant, $saleId, $eventId);
|
||||||
|
|
||||||
return $this->saleForResponse(
|
return $this->saleForResponse(
|
||||||
$this->checkoutService->cancelPurchaseFromAdmin($sale)
|
$this->checkoutService->cancelPurchaseFromAdmin($sale)
|
||||||
@@ -94,18 +92,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 = []): Collection
|
public function salesForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->salesQuery($tenant, $filters)->get();
|
return $this->salesQuery($tenant, $filters, $eventId)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<string, mixed> $filters
|
* @param array<string, mixed> $filters
|
||||||
* @return LengthAwarePaginator<ValueChange>
|
* @return LengthAwarePaginator<ValueChange>
|
||||||
*/
|
*/
|
||||||
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
public function modifications(Tenant $tenant, array $filters = [], ?int $eventId = null): LengthAwarePaginator
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant, $filters)
|
return $this->modificationsQuery($tenant, $filters, $eventId)
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
@@ -114,13 +112,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 = []): Collection
|
public function modificationsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant, $filters)->get();
|
return $this->modificationsQuery($tenant, $filters, $eventId)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $filters */
|
/** @param array<string, mixed> $filters */
|
||||||
protected function salesQuery(Tenant $tenant, array $filters): Builder
|
protected function salesQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
|
||||||
{
|
{
|
||||||
$sortColumns = [
|
$sortColumns = [
|
||||||
'id' => 'id',
|
'id' => 'id',
|
||||||
@@ -137,8 +135,7 @@ class AdminAppSaleService
|
|||||||
? $requestedDirection
|
? $requestedDirection
|
||||||
: 'desc';
|
: 'desc';
|
||||||
|
|
||||||
return Purchase::query()
|
return $this->purchasesQuery($tenant, $eventId)
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->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);
|
||||||
|
|
||||||
@@ -176,11 +173,18 @@ 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): Builder
|
protected function modificationsQuery(Tenant $tenant, array $filters, ?int $eventId = null): 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): Builder => $query->whereHasMorph(
|
||||||
|
'trackable',
|
||||||
|
[Purchase::class],
|
||||||
|
fn (Builder $sales): Builder => $sales
|
||||||
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->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);
|
||||||
|
|
||||||
@@ -221,11 +225,18 @@ class AdminAppSaleService
|
|||||||
->orderByDesc('id');
|
->orderByDesc('id');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function findForTenant(Tenant $tenant, int $saleId): Purchase
|
protected function findForTenant(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||||
|
{
|
||||||
|
return $this->purchasesQuery($tenant, $eventId)
|
||||||
|
->findOrFail($saleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return Builder<Purchase> */
|
||||||
|
protected function purchasesQuery(Tenant $tenant, ?int $eventId): Builder
|
||||||
{
|
{
|
||||||
return Purchase::query()
|
return Purchase::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
->findOrFail($saleId);
|
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function saleForResponse(Purchase $sale): Purchase
|
protected function saleForResponse(Purchase $sale): Purchase
|
||||||
|
|||||||
@@ -29,4 +29,8 @@ 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.
|
||||||
|
|
||||||
|
Cuando el usuario autenticado tiene `event_id`, el controlador lo pasa al servicio como alcance obligatorio para ventas, totales, historial y exportaciones. El alcance se combina con el tenant y no se obtiene de los filtros enviados por el cliente. Los administradores sin `event_id` conservan el alcance del tenant.
|
||||||
|
|
||||||
|
El detalle, los tickets de una venta, la confirmación y la cancelación buscan la compra dentro del mismo alcance. Una venta de otro evento o sin evento devuelve 404 para un administrador con `event_id`, antes de ejecutar cualquier acción en `CheckoutService`.
|
||||||
|
|
||||||
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.
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Address\Models;
|
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Ticketing\Event\Models\Event;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
class Address extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = ['label', 'address_text', 'latitude', 'longitude'];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'latitude' => 'decimal:7',
|
|
||||||
'longitude' => 'decimal:7',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{latitude: float, longitude: float}|null */
|
|
||||||
public function coordinates(): ?array
|
|
||||||
{
|
|
||||||
if ($this->latitude === null || $this->longitude === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return ['latitude' => (float) $this->latitude, 'longitude' => (float) $this->longitude];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasMany<Event, $this> */
|
|
||||||
public function events(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Event::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsToMany<Tenant, $this> */
|
|
||||||
public function tenants(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Tenant::class, 'tenant_addresses', 'address_id', 'tenant_codigo', 'id', 'codigo')
|
|
||||||
->using(TenantAddress::class)
|
|
||||||
->withPivot(['id', 'is_main'])
|
|
||||||
->withTimestamps();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Address\Models;
|
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
|
||||||
|
|
||||||
class TenantAddress extends Pivot
|
|
||||||
{
|
|
||||||
protected $table = 'tenant_addresses';
|
|
||||||
|
|
||||||
public $incrementing = true;
|
|
||||||
|
|
||||||
protected $fillable = ['tenant_codigo', 'address_id', 'is_main'];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return ['is_main' => 'boolean'];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Tenant, $this> */
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Address, $this> */
|
|
||||||
public function address(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Address::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Address\Resources;
|
|
||||||
|
|
||||||
use App\Domains\Core\Address\Models\Address;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/** @mixin Address */
|
|
||||||
class AddressResource extends JsonResource
|
|
||||||
{
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'label' => $this->label,
|
|
||||||
'address_text' => $this->address_text,
|
|
||||||
'latitude' => $this->latitude === null ? null : (float) $this->latitude,
|
|
||||||
'longitude' => $this->longitude === null ? null : (float) $this->longitude,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -20,6 +20,7 @@ class AdminAppAdministratorController extends Controller
|
|||||||
return AdministratorResource::collection($this->administratorService->list(
|
return AdministratorResource::collection($this->administratorService->list(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$request->string('search')->trim()->toString() ?: null,
|
$request->string('search')->trim()->toString() ?: null,
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +29,7 @@ class AdminAppAdministratorController extends Controller
|
|||||||
return AdministratorResource::make($this->administratorService->create(
|
return AdministratorResource::make($this->administratorService->create(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,6 +39,7 @@ class AdminAppAdministratorController extends Controller
|
|||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$administrator,
|
$administrator,
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class AdministratorResource extends JsonResource
|
|||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'rol_codigo' => $this->rol_codigo,
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'event_id' => $this->event_id,
|
||||||
'role' => $this->whenLoaded('role', fn () => [
|
'role' => $this->whenLoaded('role', fn () => [
|
||||||
'codigo' => $this->role?->codigo,
|
'codigo' => $this->role?->codigo,
|
||||||
'nombre' => $this->role?->nombre,
|
'nombre' => $this->role?->nombre,
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ class AdministratorService
|
|||||||
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
||||||
|
|
||||||
/** @return Collection<int, User> */
|
/** @return Collection<int, User> */
|
||||||
public function list(Tenant $tenant, ?string $search = null): Collection
|
public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->query($tenant)->with('role')
|
return $this->query($tenant, $eventId)->with('role')
|
||||||
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
||||||
$query->where('nombre_apellido', 'like', "%{$search}%")
|
$query->where('nombre_apellido', 'like', "%{$search}%")
|
||||||
->orWhere('dni', 'like', "%{$search}%")
|
->orWhere('dni', 'like', "%{$search}%")
|
||||||
@@ -31,14 +31,15 @@ class AdministratorService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function create(Tenant $tenant, array $data): User
|
public function create(Tenant $tenant, array $data, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $data): User {
|
return DB::transaction(function () use ($tenant, $data, $eventId): User {
|
||||||
$administrator = User::query()->create([
|
$administrator = User::query()->create([
|
||||||
...$this->attributes($data),
|
...$this->attributes($data),
|
||||||
'password' => Str::random(64),
|
'password' => Str::random(64),
|
||||||
'rol_codigo' => RoleCode::AdminApp->value,
|
'rol_codigo' => RoleCode::AdminApp->value,
|
||||||
'tenant_codigo' => $tenant->codigo,
|
'tenant_codigo' => $tenant->codigo,
|
||||||
|
'event_id' => $eventId,
|
||||||
]);
|
]);
|
||||||
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
||||||
$administrator->email,
|
$administrator->email,
|
||||||
@@ -50,10 +51,10 @@ class AdministratorService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function update(Tenant $tenant, int $administratorId, array $data): User
|
public function update(Tenant $tenant, int $administratorId, array $data, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $administratorId, $data): User {
|
return DB::transaction(function () use ($tenant, $administratorId, $data, $eventId): User {
|
||||||
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
$administrator = $this->query($tenant, $eventId)->lockForUpdate()->findOrFail($administratorId);
|
||||||
$administrator->update($this->attributes($data));
|
$administrator->update($this->attributes($data));
|
||||||
|
|
||||||
return $administrator->load('role');
|
return $administrator->load('role');
|
||||||
@@ -66,11 +67,11 @@ class AdministratorService
|
|||||||
// Serialize deletions for this tenant, including requests already authenticated
|
// Serialize deletions for this tenant, including requests already authenticated
|
||||||
// when another administrator removes their account.
|
// when another administrator removes their account.
|
||||||
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||||
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
$administrator = $this->query($tenant, $actor->event_id)->lockForUpdate()->findOrFail($administratorId);
|
||||||
if ($administrator->is($actor)) {
|
if ($administrator->is($actor)) {
|
||||||
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
||||||
}
|
}
|
||||||
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get();
|
$activeAdministrators = $this->query($tenant, $actor->event_id)->lockForUpdate()->get();
|
||||||
if ($activeAdministrators->count() <= 1) {
|
if ($activeAdministrators->count() <= 1) {
|
||||||
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
||||||
}
|
}
|
||||||
@@ -80,10 +81,11 @@ class AdministratorService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function query(Tenant $tenant): Builder
|
private function query(Tenant $tenant, ?int $eventId = null): Builder
|
||||||
{
|
{
|
||||||
return User::query()->where('tenant_codigo', $tenant->codigo)
|
return User::query()->where('tenant_codigo', $tenant->codigo)
|
||||||
->where('rol_codigo', RoleCode::AdminApp->value);
|
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||||
|
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data
|
/** @param array<string, mixed> $data
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el fronten
|
|||||||
|
|
||||||
## Verificación
|
## Verificación
|
||||||
|
|
||||||
|
Cuando el actor tiene `event_id`, los nuevos administradores heredan su evento y el listado, la búsqueda, la edición y la baja se limitan a ese evento dentro del tenant. La comprobación de administradores activos también usa ese alcance. El evento se toma del usuario autenticado, no del cuerpo de la solicitud; sin `event_id` se conserva el comportamiento por tenant.
|
||||||
|
|
||||||
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
||||||
|
|
||||||
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
||||||
|
|||||||
@@ -2,11 +2,12 @@
|
|||||||
|
|
||||||
namespace App\Domains\Core\Auth\Models;
|
namespace App\Domains\Core\Auth\Models;
|
||||||
|
|
||||||
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
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\Commerce\Catalog\Models\Category;
|
|
||||||
use App\Domains\Ticketing\Event\Models\EventDateChangeView;
|
|
||||||
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\EventDateChangeView;
|
||||||
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;
|
||||||
@@ -20,7 +21,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'])]
|
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo', 'event_id'])]
|
||||||
#[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
|
||||||
{
|
{
|
||||||
@@ -86,6 +87,12 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
/** @return BelongsToMany<Category, $this> */
|
/** @return BelongsToMany<Category, $this> */
|
||||||
public function scanCategories(): BelongsToMany
|
public function scanCategories(): BelongsToMany
|
||||||
{
|
{
|
||||||
@@ -103,6 +110,7 @@ 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',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ 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,
|
||||||
|
'event_id' => $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,
|
||||||
|
|||||||
@@ -33,14 +33,10 @@ class TenantBootstrapService
|
|||||||
$tenant,
|
$tenant,
|
||||||
[
|
[
|
||||||
'eventDateChanges',
|
'eventDateChanges',
|
||||||
'menues' => fn ($query) => $query
|
'menues' => fn ($query) => $query->whereHas(
|
||||||
->where(fn ($query) => $query
|
'roles',
|
||||||
->where('menues.code', 'help.contact')
|
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
||||||
->orWhere('tenants_menues.is_enabled', true))
|
),
|
||||||
->whereHas(
|
|
||||||
'roles',
|
|
||||||
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
|
||||||
),
|
|
||||||
$tenant->storefront_website_type_code === 'onticket_multi_event'
|
$tenant->storefront_website_type_code === 'onticket_multi_event'
|
||||||
? 'eventCategories' : 'categories' => fn ($query) => $query->orderBy('nombre'),
|
? 'eventCategories' : 'categories' => fn ($query) => $query->orderBy('nombre'),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ class Menu extends Model
|
|||||||
'codigo'
|
'codigo'
|
||||||
)
|
)
|
||||||
->using(TenantMenu::class)
|
->using(TenantMenu::class)
|
||||||
->withPivot('static_content', 'is_enabled')
|
->withPivot('static_content')
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,14 +16,12 @@ class TenantMenu extends Pivot
|
|||||||
'tenant_code',
|
'tenant_code',
|
||||||
'menu_code',
|
'menu_code',
|
||||||
'static_content',
|
'static_content',
|
||||||
'is_enabled',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
protected function casts(): array
|
protected function casts(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'static_content' => 'array',
|
'static_content' => 'array',
|
||||||
'is_enabled' => 'boolean',
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\PhoneNumber\Models;
|
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
|
||||||
|
|
||||||
class PhoneNumber extends Model
|
|
||||||
{
|
|
||||||
protected $fillable = ['label', 'number'];
|
|
||||||
|
|
||||||
public static function dialableNumber(string $number): string
|
|
||||||
{
|
|
||||||
return preg_replace('/[^0-9+]/', '', $number);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function telUrl(): string
|
|
||||||
{
|
|
||||||
return 'tel:'.self::dialableNumber($this->number);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenants(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Tenant::class, 'tenant_phone_numbers', 'phone_number_id', 'tenant_codigo', 'id', 'codigo')
|
|
||||||
->using(TenantPhoneNumber::class)
|
|
||||||
->withPivot(['id', 'is_main'])
|
|
||||||
->withTimestamps();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\PhoneNumber\Models;
|
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
|
||||||
|
|
||||||
class TenantPhoneNumber extends Pivot
|
|
||||||
{
|
|
||||||
protected $table = 'tenant_phone_numbers';
|
|
||||||
|
|
||||||
public $incrementing = true;
|
|
||||||
|
|
||||||
protected $fillable = ['tenant_codigo', 'phone_number_id', 'is_main'];
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return ['is_main' => 'boolean'];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function tenant(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function phoneNumber(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(PhoneNumber::class);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\PhoneNumber\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class PhoneNumberResource extends JsonResource
|
|
||||||
{
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'label' => $this->label,
|
|
||||||
'number' => $this->number,
|
|
||||||
'tel_url' => $this->resource->telUrl(),
|
|
||||||
'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -26,6 +26,7 @@ class AdminAppStaffController extends Controller
|
|||||||
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,
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ 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(),
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,12 +45,13 @@ class AdminAppStaffController extends Controller
|
|||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$staff,
|
$staff,
|
||||||
$request->validated(),
|
$request->validated(),
|
||||||
|
$request->user()->event_id,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
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->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $request->user()->event_id);
|
||||||
|
|
||||||
return response()->noContent();
|
return response()->noContent();
|
||||||
}
|
}
|
||||||
@@ -60,6 +63,7 @@ class AdminAppStaffController extends Controller
|
|||||||
$scanner = $this->staffService->find(
|
$scanner = $this->staffService->find(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail(),
|
||||||
$staff,
|
$staff,
|
||||||
|
$request->user()->event_id,
|
||||||
);
|
);
|
||||||
|
|
||||||
return ScanAttemptResource::collection(
|
return ScanAttemptResource::collection(
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class StaffResource extends JsonResource
|
|||||||
'dni' => $this->dni,
|
'dni' => $this->dni,
|
||||||
'email' => $this->email,
|
'email' => $this->email,
|
||||||
'rol_codigo' => $this->rol_codigo,
|
'rol_codigo' => $this->rol_codigo,
|
||||||
|
'event_id' => $this->event_id,
|
||||||
'role' => $this->whenLoaded('role', fn () => [
|
'role' => $this->whenLoaded('role', fn () => [
|
||||||
'codigo' => $this->role?->codigo,
|
'codigo' => $this->role?->codigo,
|
||||||
'nombre' => $this->role?->nombre,
|
'nombre' => $this->role?->nombre,
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
|
|
||||||
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 Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
use Illuminate\Database\Eloquent\Collection;
|
||||||
@@ -22,9 +22,9 @@ class StaffService
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
/** @return Collection<int, User> */
|
/** @return Collection<int, User> */
|
||||||
public function list(Tenant $tenant, ?string $search = null): Collection
|
public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
return $this->staffQuery($tenant)
|
return $this->staffQuery($tenant, $eventId)
|
||||||
->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 {
|
||||||
@@ -52,18 +52,19 @@ class StaffService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function create(Tenant $tenant, array $data): User
|
public function create(Tenant $tenant, array $data, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
||||||
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
||||||
|
|
||||||
return DB::transaction(function () use ($tenant, $data, $categoryIds): User {
|
return DB::transaction(function () use ($tenant, $data, $categoryIds, $eventId): 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,
|
||||||
]);
|
]);
|
||||||
$staff->scanCategories()->sync($categoryIds);
|
$staff->scanCategories()->sync($categoryIds);
|
||||||
$this->resetPasswordAttemptService->createForScannerEmail(
|
$this->resetPasswordAttemptService->createForScannerEmail(
|
||||||
@@ -76,9 +77,9 @@ class StaffService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
public function update(Tenant $tenant, int $staffId, array $data): User
|
public function update(Tenant $tenant, int $staffId, array $data, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
$staff = $this->find($tenant, $staffId);
|
$staff = $this->find($tenant, $staffId, $eventId);
|
||||||
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
||||||
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
||||||
|
|
||||||
@@ -92,9 +93,9 @@ class StaffService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(Tenant $tenant, int $staffId): void
|
public function delete(Tenant $tenant, int $staffId, ?int $eventId = null): void
|
||||||
{
|
{
|
||||||
$staff = $this->find($tenant, $staffId);
|
$staff = $this->find($tenant, $staffId, $eventId);
|
||||||
|
|
||||||
DB::transaction(function () use ($staff): void {
|
DB::transaction(function () use ($staff): void {
|
||||||
$staff->tokens()->delete();
|
$staff->tokens()->delete();
|
||||||
@@ -102,16 +103,17 @@ class StaffService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find(Tenant $tenant, int $staffId): User
|
public function find(Tenant $tenant, int $staffId, ?int $eventId = null): User
|
||||||
{
|
{
|
||||||
return $this->staffQuery($tenant)->findOrFail($staffId);
|
return $this->staffQuery($tenant, $eventId)->findOrFail($staffId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function staffQuery(Tenant $tenant): Builder
|
private function staffQuery(Tenant $tenant, ?int $eventId = null): 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): Builder => $query->where('event_id', $eventId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -18,3 +18,5 @@ 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.
|
||||||
|
|
||||||
|
Si el administrador autenticado tiene `event_id`, el alta de scanners hereda ese valor y las búsquedas, ediciones, bajas y consultas de intentos de escaneo se limitan a personal del mismo evento. El cliente no puede elegir ni cambiar el evento. Sin `event_id`, se mantiene el alcance por tenant.
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Requests\AdminApp\UpdateBrandRequest;
|
|
||||||
use App\Domains\Core\Tenant\Resources\AdminApp\BrandResource;
|
|
||||||
use App\Domains\Core\Tenant\Services\BrandService;
|
|
||||||
use App\Domains\Core\Tenant\Services\TenantInformationService;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class BrandController extends Controller
|
|
||||||
{
|
|
||||||
public function show(Request $request, TenantInformationService $tenantInformationService): BrandResource
|
|
||||||
{
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
|
|
||||||
return BrandResource::make($tenantInformationService->load($tenant));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(
|
|
||||||
UpdateBrandRequest $request,
|
|
||||||
BrandService $brandService,
|
|
||||||
TenantInformationService $tenantInformationService,
|
|
||||||
): BrandResource {
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
$brandService->update($tenant, $request->validated());
|
|
||||||
|
|
||||||
return BrandResource::make($tenantInformationService->load($tenant->fresh()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Requests\AdminApp\UpdateHelpMenuContentRequest;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class HelpMenuController extends Controller
|
|
||||||
{
|
|
||||||
private const MENU_CODES = [
|
|
||||||
'help.faq',
|
|
||||||
'help.terms-and-conditions',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function index(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
$menus = $tenant->menues()
|
|
||||||
->whereIn('menues.code', self::MENU_CODES)
|
|
||||||
->get()
|
|
||||||
->keyBy('code');
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => collect(self::MENU_CODES)->mapWithKeys(
|
|
||||||
fn (string $code): array => [$code => [
|
|
||||||
'code' => $code,
|
|
||||||
'is_enabled' => (bool) ($menus->get($code)?->pivot?->is_enabled ?? false),
|
|
||||||
'static_content' => $menus->get($code)?->pivot?->static_content
|
|
||||||
?? $this->emptyContent($code),
|
|
||||||
]]
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function toggle(
|
|
||||||
Request $request,
|
|
||||||
string $menuCode,
|
|
||||||
): JsonResponse {
|
|
||||||
abort_unless(in_array($menuCode, self::MENU_CODES, true), 404);
|
|
||||||
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
$tenantMenu = $tenant->menues()
|
|
||||||
->where('menues.code', $menuCode)
|
|
||||||
->firstOrFail();
|
|
||||||
$isEnabled = ! $tenantMenu->pivot->is_enabled;
|
|
||||||
|
|
||||||
$tenant->menues()->updateExistingPivot($menuCode, [
|
|
||||||
'is_enabled' => $isEnabled,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => [
|
|
||||||
'code' => $tenantMenu->code,
|
|
||||||
'is_enabled' => $isEnabled,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function updateContent(
|
|
||||||
UpdateHelpMenuContentRequest $request,
|
|
||||||
string $menuCode,
|
|
||||||
): JsonResponse {
|
|
||||||
abort_unless(in_array($menuCode, self::MENU_CODES, true), 404);
|
|
||||||
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
$tenantMenu = $tenant->menues()
|
|
||||||
->where('menues.code', $menuCode)
|
|
||||||
->firstOrFail();
|
|
||||||
$staticContent = $request->validated('static_content');
|
|
||||||
|
|
||||||
$tenant->menues()->updateExistingPivot($menuCode, [
|
|
||||||
'static_content' => $staticContent,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return response()->json([
|
|
||||||
'data' => [
|
|
||||||
'code' => $tenantMenu->code,
|
|
||||||
'is_enabled' => (bool) $tenantMenu->pivot->is_enabled,
|
|
||||||
'static_content' => $staticContent,
|
|
||||||
],
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function emptyContent(string $menuCode): array
|
|
||||||
{
|
|
||||||
return $menuCode === 'help.faq' ? [] : ['html' => ''];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,10 +6,6 @@ use App\Domains\Commerce\Catalog\Enums\GroupLayout;
|
|||||||
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
|
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
|
||||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Commerce\Catalog\Models\Category;
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
use App\Domains\Core\Address\Models\Address;
|
|
||||||
use App\Domains\Core\Address\Models\TenantAddress;
|
|
||||||
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
|
|
||||||
use App\Domains\Core\PhoneNumber\Models\TenantPhoneNumber;
|
|
||||||
use App\Domains\Core\Client\Models\Client;
|
use App\Domains\Core\Client\Models\Client;
|
||||||
use App\Domains\Core\Menu\Models\Menu;
|
use App\Domains\Core\Menu\Models\Menu;
|
||||||
use App\Domains\Core\Menu\Models\TenantMenu;
|
use App\Domains\Core\Menu\Models\TenantMenu;
|
||||||
@@ -26,7 +22,6 @@ use Illuminate\Database\Eloquent\Model;
|
|||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
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;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
|
||||||
use Illuminate\Support\Facades\Schema;
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
@@ -313,39 +308,6 @@ class Tenant extends Model
|
|||||||
return $this->hasMany(WebsiteExtra::class, 'website_code', 'codigo');
|
return $this->hasMany(WebsiteExtra::class, 'website_code', 'codigo');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsToMany<Address, $this> */
|
|
||||||
public function addresses(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(Address::class, 'tenant_addresses', 'tenant_codigo', 'address_id', 'codigo', 'id')
|
|
||||||
->using(TenantAddress::class)
|
|
||||||
->withPivot(['id', 'is_main'])
|
|
||||||
->withTimestamps()
|
|
||||||
->orderByPivot('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return HasOneThrough<Address, TenantAddress, $this> */
|
|
||||||
public function mainAddress(): HasOneThrough
|
|
||||||
{
|
|
||||||
return $this->hasOneThrough(Address::class, TenantAddress::class, 'tenant_codigo', 'id', 'codigo', 'address_id')
|
|
||||||
->where('tenant_addresses.is_main', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function phoneNumbers(): BelongsToMany
|
|
||||||
{
|
|
||||||
return $this->belongsToMany(PhoneNumber::class, 'tenant_phone_numbers', 'tenant_codigo', 'phone_number_id', 'codigo', 'id')
|
|
||||||
->using(TenantPhoneNumber::class)
|
|
||||||
->withPivot(['id', 'is_main'])
|
|
||||||
->withTimestamps()
|
|
||||||
->orderByPivot('is_main', 'desc')
|
|
||||||
->orderByPivot('id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function mainPhoneNumber(): HasOneThrough
|
|
||||||
{
|
|
||||||
return $this->hasOneThrough(PhoneNumber::class, TenantPhoneNumber::class, 'tenant_codigo', 'id', 'codigo', 'phone_number_id')
|
|
||||||
->where('tenant_phone_numbers.is_main', true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function menues(): BelongsToMany
|
public function menues(): BelongsToMany
|
||||||
{
|
{
|
||||||
return $this->belongsToMany(
|
return $this->belongsToMany(
|
||||||
@@ -357,7 +319,7 @@ class Tenant extends Model
|
|||||||
'code'
|
'code'
|
||||||
)
|
)
|
||||||
->using(TenantMenu::class)
|
->using(TenantMenu::class)
|
||||||
->withPivot('static_content', 'is_enabled')
|
->withPivot('static_content')
|
||||||
->withTimestamps();
|
->withTimestamps();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Requests\AdminApp;
|
|
||||||
|
|
||||||
use App\Shared\Rules\CroppedImageOrBase64Rule;
|
|
||||||
use App\Shared\Rules\ImageOrBase64Rule;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpdateBrandRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
$shopit = $this->user()->tenant()->firstOrFail()->storefront_website_type_code === 'shopit';
|
|
||||||
|
|
||||||
return [
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'primary_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
|
||||||
'footer_bg_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
|
||||||
'header_logo' => ['present', 'nullable', new ImageOrBase64Rule],
|
|
||||||
'footer_logo' => ['present', 'nullable', new ImageOrBase64Rule],
|
|
||||||
'banner_images' => [$shopit ? 'present' : 'prohibited', 'array', 'max:10'],
|
|
||||||
'banner_images.*' => ['required', new CroppedImageOrBase64Rule],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Requests\AdminApp;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpdateHelpMenuContentRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return match ($this->route('menuCode')) {
|
|
||||||
'help.faq' => [
|
|
||||||
'static_content' => ['required', 'array'],
|
|
||||||
'static_content.*.pregunta' => ['required', 'string'],
|
|
||||||
'static_content.*.respuesta' => ['required', 'string'],
|
|
||||||
'static_content.*.is_active' => ['required', 'boolean'],
|
|
||||||
],
|
|
||||||
'help.terms-and-conditions' => [
|
|
||||||
'static_content' => ['required', 'array'],
|
|
||||||
'static_content.html' => ['present', 'nullable', 'string'],
|
|
||||||
],
|
|
||||||
default => [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Resources\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
|
||||||
use App\Shared\Attachable\Models\Attachment;
|
|
||||||
use App\Shared\Attachable\Models\AttachmentCrop;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/** @mixin Tenant */
|
|
||||||
class BrandResource extends JsonResource
|
|
||||||
{
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'nombre' => $this->nombre,
|
|
||||||
'primary_color' => $this->primary_color,
|
|
||||||
'header_bg_color' => $this->header_bg_color,
|
|
||||||
'footer_color' => $this->footerColor(),
|
|
||||||
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
|
||||||
'header_logo_key' => $this->headerLogo?->key,
|
|
||||||
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
|
|
||||||
'footer_logo_key' => $this->footerLogo?->key,
|
|
||||||
'can_edit_carousel' => $this->canEditCarousel(),
|
|
||||||
'banner_images' => $this->bannerImages(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function footerColor(): string
|
|
||||||
{
|
|
||||||
$footerColor = strtolower(trim((string) $this->footer_bg_color));
|
|
||||||
$primaryColor = strtolower(trim((string) $this->primary_color));
|
|
||||||
|
|
||||||
if ($footerColor !== '' && $footerColor === $primaryColor) {
|
|
||||||
return 'primary';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($footerColor, ['#000', '#000000', 'black'], true)) {
|
|
||||||
return 'black';
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($footerColor === '' || in_array($footerColor, ['#fff', '#ffffff', 'white'], true)) {
|
|
||||||
return 'white';
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'primary';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<int, array<string, mixed>> */
|
|
||||||
private function bannerImages(): array
|
|
||||||
{
|
|
||||||
if (! $this->canEditCarousel()) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$extra = $this->websiteExtras->first(
|
|
||||||
fn ($extra) => $extra->is_enabled && $extra->websiteTypeExtra->codigo === 'carousel'
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($extra === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$images = $extra->resolvedConfig();
|
|
||||||
|
|
||||||
if (! is_array($images)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return collect($images)
|
|
||||||
->filter(fn ($image) => $image instanceof Attachment)
|
|
||||||
->map(function (Attachment $image): array {
|
|
||||||
$desktopCrop = $image->cropVariants
|
|
||||||
->firstWhere('variant', AttachmentCrop::DESKTOP);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'key' => $image->key,
|
|
||||||
'url' => ($desktopCrop?->croppedAttachment ?? $image)->getTemporaryUrl(1440),
|
|
||||||
'original_url' => $image->getTemporaryUrl(1440),
|
|
||||||
'crop' => $desktopCrop === null ? null : [
|
|
||||||
'crop_horizontal' => $desktopCrop->crop_horizontal,
|
|
||||||
'crop_vertical' => $desktopCrop->crop_vertical,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function canEditCarousel(): bool
|
|
||||||
{
|
|
||||||
if ($this->storefront_website_type_code !== 'shopit') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$extra = $this->websiteExtras->first(
|
|
||||||
fn ($extra) => $extra->websiteTypeExtra->codigo === 'carousel'
|
|
||||||
);
|
|
||||||
|
|
||||||
return $extra === null || $extra->is_enabled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Resources;
|
|
||||||
|
|
||||||
use App\Domains\Core\Address\Resources\AddressResource;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TenantAddressResource extends AddressResource
|
|
||||||
{
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [...parent::toArray($request), 'is_main' => (bool) $this->pivot->is_main];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,8 +3,6 @@
|
|||||||
namespace App\Domains\Core\Tenant\Resources;
|
namespace App\Domains\Core\Tenant\Resources;
|
||||||
|
|
||||||
use App\Domains\Commerce\Catalog\Models\Category;
|
use App\Domains\Commerce\Catalog\Models\Category;
|
||||||
use App\Domains\Core\Address\Resources\AddressResource;
|
|
||||||
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
|
|
||||||
use App\Domains\Core\Menu\Models\Menu;
|
use App\Domains\Core\Menu\Models\Menu;
|
||||||
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
|
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
use App\Domains\Core\Tenant\Models\Tenant;
|
||||||
@@ -43,11 +41,7 @@ class TenantResource extends JsonResource
|
|||||||
'site_title' => $this->site_title ?? 'ShopitFront',
|
'site_title' => $this->site_title ?? 'ShopitFront',
|
||||||
'asset_url' => config('filesystems.disks.s3.url'),
|
'asset_url' => config('filesystems.disks.s3.url'),
|
||||||
'address' => $this->address,
|
'address' => $this->address,
|
||||||
'addresses' => $this->whenLoaded('addresses', fn () => TenantAddressResource::collection($this->addresses)),
|
|
||||||
'main_address' => $this->whenLoaded('mainAddress', fn () => AddressResource::make($this->mainAddress)),
|
|
||||||
'phone' => $this->phone,
|
'phone' => $this->phone,
|
||||||
'phone_numbers' => $this->whenLoaded('phoneNumbers', fn () => PhoneNumberResource::collection($this->phoneNumbers)),
|
|
||||||
'main_phone_number' => $this->whenLoaded('mainPhoneNumber', fn () => PhoneNumberResource::make($this->mainPhoneNumber)),
|
|
||||||
'favicon' => $this->favicon?->getTemporaryUrl(1440),
|
'favicon' => $this->favicon?->getTemporaryUrl(1440),
|
||||||
'primary_color' => $this->primary_color,
|
'primary_color' => $this->primary_color,
|
||||||
'secondary_color' => $this->secondary_color,
|
'secondary_color' => $this->secondary_color,
|
||||||
@@ -133,7 +127,6 @@ class TenantResource extends JsonResource
|
|||||||
'subtitle' => $event->subtitle,
|
'subtitle' => $event->subtitle,
|
||||||
'description' => $event->description,
|
'description' => $event->description,
|
||||||
'location' => $event->location,
|
'location' => $event->location,
|
||||||
'address' => AddressResource::make($event->address),
|
|
||||||
'date_text' => $event->date_text,
|
'date_text' => $event->date_text,
|
||||||
'dates' => $event->dates
|
'dates' => $event->dates
|
||||||
->filter(fn (EventDate $date): bool => $date->rescheduled_to_event_date_id === null)
|
->filter(fn (EventDate $date): bool => $date->rescheduled_to_event_date_id === null)
|
||||||
|
|||||||
@@ -1,165 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Services;
|
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
|
||||||
use App\Shared\Attachable\Enums\AttachmentType;
|
|
||||||
use App\Shared\Attachable\Models\Attachment;
|
|
||||||
use App\Shared\Attachable\Services\AttachmentService;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
use Throwable;
|
|
||||||
|
|
||||||
class BrandService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AttachmentService $attachmentService,
|
|
||||||
private readonly WebsiteExtraService $websiteExtraService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
|
||||||
public function update(Tenant $tenant, array $data): void
|
|
||||||
{
|
|
||||||
$carousel = $tenant->storefront_website_type_code === 'shopit'
|
|
||||||
? $tenant->websiteExtras()->whereHas('websiteTypeExtra',
|
|
||||||
fn ($query) => $query->where('codigo', 'carousel'))->first()
|
|
||||||
: null;
|
|
||||||
$allowedBannerIds = is_array($carousel?->config) ? $carousel->config : [];
|
|
||||||
if ($carousel !== null && ! $carousel->is_enabled && ($data['banner_images'] ?? []) !== []) {
|
|
||||||
throw ValidationException::withMessages(['banner_images' => 'El carrusel está deshabilitado.']);
|
|
||||||
}
|
|
||||||
$seen = [];
|
|
||||||
|
|
||||||
foreach (['header_logo' => $tenant->header_logo_id, 'footer_logo' => $tenant->footer_logo_id] as $field => $id) {
|
|
||||||
$this->existingImage($data[$field], $id === null ? [] : [$id], $field);
|
|
||||||
}
|
|
||||||
foreach ($data['banner_images'] ?? [] as $index => $item) {
|
|
||||||
$image = is_array($item) ? $item['image'] : $item;
|
|
||||||
$attachment = $this->existingImage($image, $allowedBannerIds, "banner_images.{$index}");
|
|
||||||
if ($attachment !== null) {
|
|
||||||
if (isset($seen[$attachment->id])) {
|
|
||||||
throw ValidationException::withMessages(["banner_images.{$index}" => 'La imagen está repetida.']);
|
|
||||||
}
|
|
||||||
$seen[$attachment->id] = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$created = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
DB::transaction(function () use ($tenant, $data, $carousel, &$created): void {
|
|
||||||
$header = $this->logo($data['header_logo'], $tenant->header_logo_id, $tenant, $created);
|
|
||||||
$footer = $this->logo($data['footer_logo'], $tenant->footer_logo_id, $tenant, $created);
|
|
||||||
|
|
||||||
$tenant->update([
|
|
||||||
'nombre' => trim($data['nombre']),
|
|
||||||
'primary_color' => $data['primary_color'],
|
|
||||||
'footer_bg_color' => $data['footer_bg_color'],
|
|
||||||
'header_logo_id' => $header?->id,
|
|
||||||
'footer_logo_id' => $footer?->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if ($tenant->storefront_website_type_code !== 'shopit'
|
|
||||||
|| ($carousel !== null && ! $carousel->is_enabled)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$definition = $this->websiteExtraService->definitionForTenant($tenant, 'carousel');
|
|
||||||
$ids = [];
|
|
||||||
$recrops = [];
|
|
||||||
foreach ($data['banner_images'] as $item) {
|
|
||||||
$image = is_array($item) ? $item['image'] : $item;
|
|
||||||
$attachment = $this->existingImage($image, $carousel?->config ?? [], 'banner_images');
|
|
||||||
$crops = $this->crops($item);
|
|
||||||
|
|
||||||
if ($attachment === null) {
|
|
||||||
$attachment = $crops === null
|
|
||||||
? $this->attachmentService->store($image, "tenants/{$tenant->codigo}/extras/carousel")
|
|
||||||
: $this->attachmentService->storeCroppedImageVariants(
|
|
||||||
$image, "tenants/{$tenant->codigo}/extras/carousel", $crops
|
|
||||||
);
|
|
||||||
$created[] = $attachment;
|
|
||||||
} elseif ($crops !== null) {
|
|
||||||
$recrops[] = [$attachment, $crops];
|
|
||||||
}
|
|
||||||
$ids[] = $attachment->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$tenant->websiteExtras()->updateOrCreate(
|
|
||||||
['website_type_extra_id' => $definition->id],
|
|
||||||
['config' => $ids]
|
|
||||||
);
|
|
||||||
|
|
||||||
foreach ($recrops as [$attachment, $crops]) {
|
|
||||||
$this->attachmentService->updateImageCropVariants($attachment, $crops);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} catch (Throwable $error) {
|
|
||||||
foreach ($created as $attachment) {
|
|
||||||
$paths = [$attachment->path];
|
|
||||||
foreach ($attachment->cropVariants as $crop) {
|
|
||||||
if ($crop->croppedAttachment !== null) {
|
|
||||||
$paths[] = $crop->croppedAttachment->path;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Storage::disk('s3')->delete($paths);
|
|
||||||
}
|
|
||||||
throw $error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<int, int> $allowedIds */
|
|
||||||
private function existingImage(mixed $value, array $allowedIds, string $field): ?Attachment
|
|
||||||
{
|
|
||||||
if (! is_string($value) || ! Str::isUuid($value)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$attachment = Attachment::query()->where('key', $value)->first();
|
|
||||||
if ($attachment === null || $attachment->type !== AttachmentType::Image
|
|
||||||
|| ! in_array($attachment->id, $allowedIds, true)) {
|
|
||||||
throw ValidationException::withMessages([$field => 'La imagen no pertenece a esta marca.']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $attachment;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<int, Attachment> $created */
|
|
||||||
private function logo(mixed $value, ?int $currentId, Tenant $tenant, array &$created): ?Attachment
|
|
||||||
{
|
|
||||||
if ($value === null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$existing = $this->existingImage($value, $currentId === null ? [] : [$currentId], 'logo');
|
|
||||||
if ($existing !== null) {
|
|
||||||
return $existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
$attachment = $this->attachmentService->store($value, "tenants/{$tenant->codigo}/logos");
|
|
||||||
$created[] = $attachment;
|
|
||||||
|
|
||||||
return $attachment;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, array<string, mixed>>|null */
|
|
||||||
private function crops(mixed $item): ?array
|
|
||||||
{
|
|
||||||
if (! is_array($item)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! isset($item['crop_horizontal'], $item['crop_vertical'])) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$crop = [
|
|
||||||
'crop_horizontal' => $item['crop_horizontal'],
|
|
||||||
'crop_vertical' => $item['crop_vertical'],
|
|
||||||
];
|
|
||||||
|
|
||||||
return ['desktop' => $crop, 'mobile' => $crop];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Core\Tenant\Services;
|
|
||||||
|
|
||||||
use App\Domains\Core\Address\Models\Address;
|
|
||||||
use App\Domains\Core\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class TenantAddressService
|
|
||||||
{
|
|
||||||
public function setMainAddress(Tenant $tenant, Address $address): void
|
|
||||||
{
|
|
||||||
DB::transaction(function () use ($tenant, $address): void {
|
|
||||||
// Serialize changes for this tenant, including when it has no principal address yet.
|
|
||||||
Tenant::query()->where('codigo', $tenant->codigo)->lockForUpdate()->firstOrFail();
|
|
||||||
$tenant->addresses()->where('addresses.id', $address->id)->firstOrFail();
|
|
||||||
|
|
||||||
DB::table('tenant_addresses')->where('tenant_codigo', $tenant->codigo)
|
|
||||||
->where('is_main', true)->update(['is_main' => false, 'updated_at' => now()]);
|
|
||||||
$tenant->addresses()->updateExistingPivot($address->id, ['is_main' => true]);
|
|
||||||
});
|
|
||||||
|
|
||||||
$tenant->unsetRelation('addresses');
|
|
||||||
$tenant->unsetRelation('mainAddress');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -18,14 +18,9 @@ class TenantInformationService
|
|||||||
'footerBackgroundImage',
|
'footerBackgroundImage',
|
||||||
'storefrontWebsiteType',
|
'storefrontWebsiteType',
|
||||||
'socialMedia',
|
'socialMedia',
|
||||||
'addresses',
|
|
||||||
'mainAddress',
|
|
||||||
'phoneNumbers',
|
|
||||||
'mainPhoneNumber',
|
|
||||||
'websiteExtras.websiteTypeExtra',
|
'websiteExtras.websiteTypeExtra',
|
||||||
'activeEvent.dates',
|
'activeEvent.dates',
|
||||||
'activeEvent.socialMedia',
|
'activeEvent.socialMedia',
|
||||||
'activeEvent.address',
|
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
|
|||||||
- `StorefrontWebsiteTypeExtra`: definición de un extra y su configuración admitida.
|
- `StorefrontWebsiteTypeExtra`: definición de un extra y su configuración admitida.
|
||||||
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
|
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
|
||||||
- `SocialMedia`: catálogo de redes sociales asociables.
|
- `SocialMedia`: catálogo de redes sociales asociables.
|
||||||
- `phoneNumbers` y `mainPhoneNumber`: teléfonos públicos del tenant, definidos en el dominio `Core/PhoneNumber` y vinculados por `tenant_phone_numbers`. Solo un vínculo por tenant puede ser principal.
|
|
||||||
|
|
||||||
## Servicios
|
## Servicios
|
||||||
|
|
||||||
@@ -32,5 +31,3 @@ El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant
|
|||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral.
|
Usa `Attachable` para logos y archivos. Es referenciado por casi todos los dominios para aislamiento. El `codigo` es clave de ruta y clave foránea heredada; no debe sustituirse por `id` sin una migración integral.
|
||||||
|
|
||||||
Los resources exponen `phone_numbers` (incluye `is_main`) y `main_phone_number`, con `id`, `label`, `number` y `tel_url`. Contacto muestra la colección y el footer usa el principal. La migración copia primero `tenants.phone` como principal y luego el teléfono de `help.contact`, si es distinto; si no existe teléfono del tenant, el del menú pasa a ser principal. Los duplicados por formato se comparan dentro de cada tenant sin inferir país ni código de área. Los campos anteriores se conservan como datos legacy, pero la tienda consume las relaciones nuevas. No hay endpoints de edición de teléfonos ni cambios del adminapp en esta etapa.
|
|
||||||
|
|||||||
@@ -1,18 +1,11 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Core\Tenant\Controllers\AdminApp\BrandController;
|
|
||||||
use App\Domains\Core\Tenant\Controllers\AdminApp\HelpMenuController;
|
|
||||||
use App\Domains\Core\Tenant\Controllers\AdminApp\WebsiteExtraController;
|
use App\Domains\Core\Tenant\Controllers\AdminApp\WebsiteExtraController;
|
||||||
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'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('brand', [BrandController::class, 'show']);
|
|
||||||
Route::put('brand', [BrandController::class, 'update']);
|
|
||||||
Route::get('help-menus', [HelpMenuController::class, 'index']);
|
|
||||||
Route::patch('help-menus/{menuCode}/toggle', [HelpMenuController::class, 'toggle']);
|
|
||||||
Route::put('help-menus/{menuCode}/content', [HelpMenuController::class, 'updateContent']);
|
|
||||||
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
|
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
|
||||||
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
|
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
|
||||||
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
|
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -394,22 +393,14 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
|
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
|
||||||
|
|
||||||
$available = $inventory?->available_stock
|
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0 || ($inventory->entry_reserved_stock ?? 0) > 0) {
|
||||||
?? ($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.");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (property_exists($inventory, 'available_stock')) {
|
DB::table('inventories')->where('id', $inventory->id)->update([
|
||||||
Inventory::query()->findOrFail($inventory->id)->commitDirectSale(1);
|
'real_stock' => $inventory->real_stock - 1,
|
||||||
} else {
|
'sold_units' => $inventory->sold_units + 1,
|
||||||
// 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) {
|
||||||
|
|||||||
@@ -5,28 +5,28 @@ use App\Domains\Ticketing\Desfile\Controllers\EntryReservationController;
|
|||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index'])
|
Route::get('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'index'])
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.index');
|
->name('adminapp.desfile.entry-reservations.index');
|
||||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf'])
|
Route::get('v1/adminapp/tenant/desfile/entry-reservations/pdf', [EntryReservationController::class, 'downloadPdf'])
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.pdf');
|
->name('adminapp.desfile.entry-reservations.pdf');
|
||||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel'])
|
Route::get('v1/adminapp/tenant/desfile/entry-reservations/excel', [EntryReservationController::class, 'downloadExcel'])
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.excel');
|
->name('adminapp.desfile.entry-reservations.excel');
|
||||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf'])
|
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf'])
|
||||||
->whereNumber('reservation')
|
->whereNumber('reservation')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.ticket.pdf');
|
->name('adminapp.desfile.entry-reservations.ticket.pdf');
|
||||||
Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store'])
|
Route::post('v1/adminapp/tenant/desfile/entry-reservations', [EntryReservationController::class, 'store'])
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.store');
|
->name('adminapp.desfile.entry-reservations.store');
|
||||||
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy'])
|
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy'])
|
||||||
->whereNumber('reservation')
|
->whereNumber('reservation')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.reservas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.reservas'])
|
||||||
->name('adminapp.desfile.entry-reservations.destroy');
|
->name('adminapp.desfile.entry-reservations.destroy');
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/tenant/desfile')
|
Route::prefix('v1/adminapp/tenant/desfile')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:onticket.adminapp.desfile.entradas'])
|
->middleware(['auth:sanctum', 'adminapp.tenant', 'tenant.menu:adminapp.desfile.entradas'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('entries', [EntryController::class, 'show'])
|
Route::get('entries', [EntryController::class, 'show'])
|
||||||
->name('adminapp.desfile.entries.show');
|
->name('adminapp.desfile.entries.show');
|
||||||
|
|||||||
@@ -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\Address\Models\Address;
|
|
||||||
use App\Domains\Core\Client\Models\Client;
|
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;
|
||||||
@@ -16,14 +15,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', 'address_id', 'location', 'exact_location', 'date_text', 'published_at', 'attachment_id'])]
|
#[Fillable(['client_id', '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 ['client_id' => 'integer', 'address_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array'];
|
return ['client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Tenant, $this> */
|
/** @return BelongsTo<Tenant, $this> */
|
||||||
@@ -38,12 +37,6 @@ class Event extends Model
|
|||||||
return $this->belongsTo(Client::class);
|
return $this->belongsTo(Client::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<Address, $this> */
|
|
||||||
public function address(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Address::class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function effectiveClient(): Client
|
public function effectiveClient(): Client
|
||||||
{
|
{
|
||||||
return $this->client ?? $this->tenant->client;
|
return $this->client ?? $this->tenant->client;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
namespace App\Domains\Ticketing\Event\Resources;
|
namespace App\Domains\Ticketing\Event\Resources;
|
||||||
|
|
||||||
use App\Domains\Core\Address\Resources\AddressResource;
|
|
||||||
use App\Domains\Ticketing\Event\Models\Event;
|
use App\Domains\Ticketing\Event\Models\Event;
|
||||||
use App\Domains\Ticketing\Event\Services\EventDateGroupingService;
|
use App\Domains\Ticketing\Event\Services\EventDateGroupingService;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
@@ -22,7 +21,6 @@ class EventResource extends JsonResource
|
|||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'subtitle' => $this->subtitle,
|
'subtitle' => $this->subtitle,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
'address' => AddressResource::make($this->address),
|
|
||||||
'location' => $this->location,
|
'location' => $this->location,
|
||||||
'exact_location' => $this->exact_location,
|
'exact_location' => $this->exact_location,
|
||||||
'date_text' => $this->date_text,
|
'date_text' => $this->date_text,
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Domains\Ticketing\Event\Resources;
|
|||||||
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
|
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
|
||||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||||
use App\Domains\Core\Address\Resources\AddressResource;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ class PublicEventResource extends JsonResource
|
|||||||
'title' => $this->title,
|
'title' => $this->title,
|
||||||
'subtitle' => $this->subtitle,
|
'subtitle' => $this->subtitle,
|
||||||
'description' => $this->description,
|
'description' => $this->description,
|
||||||
'address' => AddressResource::make($this->address),
|
|
||||||
'location' => $this->location,
|
'location' => $this->location,
|
||||||
'exact_location' => $this->exact_location,
|
'exact_location' => $this->exact_location,
|
||||||
'date_text' => $this->date_text,
|
'date_text' => $this->date_text,
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticketing\Event\Services;
|
|
||||||
|
|
||||||
use App\Domains\Core\Address\Models\Address;
|
|
||||||
use App\Domains\Ticketing\Event\Models\Event;
|
|
||||||
|
|
||||||
class EventAddressService
|
|
||||||
{
|
|
||||||
/** Called inside the event update transaction, after validating the legacy location fields. */
|
|
||||||
public function syncLocation(Event $event, bool $coordinatesProvided = false): void
|
|
||||||
{
|
|
||||||
$address = $event->address;
|
|
||||||
$coordinates = $coordinatesProvided || $address === null ? $event->exact_location : $address->coordinates();
|
|
||||||
$values = [
|
|
||||||
'address_text' => $event->location,
|
|
||||||
'latitude' => $coordinates['latitude'] ?? null,
|
|
||||||
'longitude' => $coordinates['longitude'] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
if ($address !== null && ! (clone $address)->fill($values)->isDirty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Editing one event must not change the location of another event or a tenant.
|
|
||||||
if ($address === null || $address->tenants()->exists()
|
|
||||||
|| $address->events()->where('events.id', '!=', $event->id)->exists()) {
|
|
||||||
$address = Address::create(['label' => $address?->label ?? 'Ubicación del evento', ...$values]);
|
|
||||||
$event->address()->associate($address);
|
|
||||||
$event->save();
|
|
||||||
} else {
|
|
||||||
$address->update($values);
|
|
||||||
}
|
|
||||||
|
|
||||||
$event->setRelation('address', $address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,18 +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\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;
|
||||||
@@ -32,12 +32,11 @@ class EventService
|
|||||||
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
|
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
|
||||||
private readonly VariantReplacementService $variantReplacementService,
|
private readonly VariantReplacementService $variantReplacementService,
|
||||||
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
|
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
|
||||||
private readonly EventAddressService $eventAddressService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function forTenant(Tenant $tenant): Event
|
public function forTenant(Tenant $tenant): Event
|
||||||
{
|
{
|
||||||
return $tenant->activeEvent->load(['dates.validityTime', 'socialMedia', 'address']);
|
return $tenant->activeEvent->load(['dates.validityTime', 'socialMedia']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $data */
|
/** @param array<string, mixed> $data */
|
||||||
@@ -50,7 +49,6 @@ class EventService
|
|||||||
'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]),
|
||||||
]);
|
]);
|
||||||
$this->eventAddressService->syncLocation($event, array_key_exists('exact_location', $data));
|
|
||||||
$tenant->update([
|
$tenant->update([
|
||||||
...array_intersect_key($data, array_flip([
|
...array_intersect_key($data, array_flip([
|
||||||
'allow_ticket_refund',
|
'allow_ticket_refund',
|
||||||
@@ -66,7 +64,7 @@ class EventService
|
|||||||
$this->syncLegacyContact($event, $data['contact']);
|
$this->syncLegacyContact($event, $data['contact']);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $event->load(['dates.validityTime', 'socialMedia', 'address']);
|
return $event->load(['dates.validityTime', 'socialMedia']);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ class PublicEventService
|
|||||||
$query->where(function ($query) use ($term): void {
|
$query->where(function ($query) use ($term): void {
|
||||||
$query->where('title', 'like', '%'.$term.'%')
|
$query->where('title', 'like', '%'.$term.'%')
|
||||||
->orWhere('subtitle', 'like', '%'.$term.'%')
|
->orWhere('subtitle', 'like', '%'.$term.'%')
|
||||||
->orWhereHas('address', fn ($query) => $query->where('address_text', 'like', '%'.$term.'%'))
|
->orWhere('location', 'like', '%'.$term.'%');
|
||||||
->orWhere(fn ($query) => $query->whereNull('address_id')->where('location', 'like', '%'.$term.'%'));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return $query->with(['attachment', 'address'])->orderByDesc('published_at')->orderByDesc('id')->paginate(15, ['*'], 'page', $page);
|
return $query->with('attachment')->orderByDesc('published_at')->orderByDesc('id')->paginate(15, ['*'], 'page', $page);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function find(Tenant $tenant, Event $event): Event
|
public function find(Tenant $tenant, Event $event): Event
|
||||||
@@ -39,7 +38,6 @@ class PublicEventService
|
|||||||
|
|
||||||
return $event->load([
|
return $event->load([
|
||||||
'attachment',
|
'attachment',
|
||||||
'address',
|
|
||||||
'dates' => fn ($query) => $query->whereNull('rescheduled_to_event_date_id')->whereNull('suspended_at'),
|
'dates' => fn ($query) => $query->whereNull('rescheduled_to_event_date_id')->whereNull('suspended_at'),
|
||||||
'socialMedia',
|
'socialMedia',
|
||||||
'catalogItems.attachments',
|
'catalogItems.attachments',
|
||||||
|
|||||||
@@ -13,32 +13,6 @@ mediante `event_id`. El catálogo `social_media` define las plataformas;
|
|||||||
`exact_location` guarda opcionalmente un objeto JSON con `latitude` (-90 a 90)
|
`exact_location` guarda opcionalmente un objeto JSON con `latitude` (-90 a 90)
|
||||||
y `longitude` (-180 a 180). `location` sigue siendo la dirección legible.
|
y `longitude` (-180 a 180). `location` sigue siendo la dirección legible.
|
||||||
|
|
||||||
`events.address_id` es una referencia opcional a `addresses.id`: `Event::address()`
|
|
||||||
obtiene su dirección y `Address::events()` permite reutilizar una dirección en varios
|
|
||||||
eventos. Al borrar una dirección, el vínculo queda en `NULL` y se conserva el evento.
|
|
||||||
|
|
||||||
La migración `2026_09_30_000200_add_address_id_to_events` agrega el vínculo.
|
|
||||||
`2026_09_30_000300_copy_event_locations_to_addresses` copia `location` a
|
|
||||||
`address_text` y las coordenadas de `exact_location` a `latitude` y `longitude`, con
|
|
||||||
el label `Ubicación del evento`. Solo copia eventos sin `address_id` y con texto de
|
|
||||||
ubicación; no modifica los campos ni los timestamps originales, ni vincula la
|
|
||||||
dirección al tenant. Si faltan texto y coordenadas, el vínculo queda en `NULL`.
|
|
||||||
Los datos inválidos, incluidas coordenadas sin texto de dirección, detienen la
|
|
||||||
copia y revierten las inserciones para evitar una migración parcial. Las migraciones
|
|
||||||
deben ejecutarse explícitamente por quien administra la base.
|
|
||||||
|
|
||||||
Las respuestas públicas, administrativas y el evento del bootstrap incluyen
|
|
||||||
`address` con `id`, `label`, `address_text`, `latitude` y `longitude`. Las coordenadas
|
|
||||||
se serializan como números o `null`. Los campos anteriores de la respuesta conservan
|
|
||||||
sus valores originales; el frontend lee la ubicación y coordenadas desde `address`.
|
|
||||||
El guardado administrativo de
|
|
||||||
`location` y `exact_location` también actualiza `addresses`; si una dirección está
|
|
||||||
compartida con otro evento o con un tenant, se crea una copia para el evento editado.
|
|
||||||
El tenant devuelve `addresses` con `is_main` y `main_address`. El storefront usa
|
|
||||||
todas las direcciones en contacto, la principal en el footer y la propia del evento
|
|
||||||
en sus tarjetas, cabecera y mapa. Solo se agregan al mapa las direcciones con ambas
|
|
||||||
coordenadas.
|
|
||||||
|
|
||||||
La migración `2026_09_18_000500_restore_events` crea un evento por cada tenant que
|
La migración `2026_09_18_000500_restore_events` crea un evento por cada tenant que
|
||||||
tenía datos de evento o fechas, y migra las fechas y redes correspondientes.
|
tenía datos de evento o fechas, y migra las fechas y redes correspondientes.
|
||||||
`2026_09_18_000600_associate_active_event_social_media` copia al evento activo las
|
`2026_09_18_000600_associate_active_event_social_media` copia al evento activo las
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ class AccommodationController extends Controller
|
|||||||
$this->accommodationService->upsertMany(
|
$this->accommodationService->upsertMany(
|
||||||
$tenant,
|
$tenant,
|
||||||
$request->validated('variants'),
|
$request->validated('variants'),
|
||||||
$request->validated('stock_adjustment_id'),
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ 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)
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class FoodController extends Controller
|
|||||||
$this->foodService->upsertMany(
|
$this->foodService->upsertMany(
|
||||||
$tenant,
|
$tenant,
|
||||||
$request->validated('variants'),
|
$request->validated('variants'),
|
||||||
$request->validated('stock_adjustment_id'),
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,7 +39,6 @@ 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'),
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ 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)
|
||||||
|
|||||||
@@ -16,10 +16,9 @@ class UpdateHistoricalFoodStockRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
'variants.*' => ['required', 'array:id,stock'],
|
||||||
'variants.*' => ['required', 'array:id,stock_difference'],
|
|
||||||
'variants.*.id' => ['required', 'integer', 'distinct'],
|
'variants.*.id' => ['required', 'integer', 'distinct'],
|
||||||
'variants.*.stock_difference' => ['required', 'integer'],
|
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
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
|
||||||
{
|
{
|
||||||
@@ -17,42 +16,12 @@ class UpsertAccommodationVariantsRequest extends FormRequest
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
'variants.*' => ['required', 'array:id,title,description,stock,price'],
|
||||||
'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' => ['sometimes', 'integer', 'min:0'],
|
'variants.*.stock' => ['required', '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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ class UpsertEntriesRequest extends FormRequest
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'entries' => ['required', 'array', 'min:1', 'max:100'],
|
'entries' => ['required', 'array', 'min:1', 'max:100'],
|
||||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'],
|
||||||
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,stock_difference,price'],
|
|
||||||
'entries.*.id' => [
|
'entries.*.id' => [
|
||||||
'sometimes',
|
'sometimes',
|
||||||
'nullable',
|
'nullable',
|
||||||
@@ -47,8 +46,7 @@ class UpsertEntriesRequest extends FormRequest
|
|||||||
fn ($query) => $query->where('tenant_code', $tenantCode)
|
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
'entries.*.stock' => ['sometimes', 'integer', 'min:0'],
|
'entries.*.stock' => ['required', '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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -63,20 +61,6 @@ 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)) {
|
||||||
|
|||||||
@@ -20,8 +20,7 @@ class UpsertFoodVariantsRequest extends FormRequest
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'],
|
||||||
'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',
|
||||||
@@ -33,8 +32,7 @@ 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' => ['sometimes', 'integer', 'min:0'],
|
'variants.*.stock' => ['required', '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'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -51,20 +49,6 @@ 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'] ?? ''))),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ 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
|
||||||
{
|
{
|
||||||
@@ -40,49 +39,12 @@ 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'],
|
||||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
'items.*.variants.*' => ['required', 'array:id,color,size,stock,price'],
|
||||||
'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' => ['sometimes', 'integer', 'min:0'],
|
'items.*.variants.*.stock' => ['required', '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.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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->availableStock(),
|
'stock' => $variant->inventory->real_stock,
|
||||||
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
];
|
];
|
||||||
})->values(),
|
})->values(),
|
||||||
|
|||||||
@@ -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->availableStock(),
|
'stock' => $variant->inventory->real_stock,
|
||||||
'price' => $this->precio,
|
'price' => $this->precio,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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->availableStock(),
|
'stock' => $variant->inventory->real_stock,
|
||||||
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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->availableStock(),
|
'stock' => $variant->inventory->real_stock,
|
||||||
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||||
];
|
];
|
||||||
})->values(),
|
})->values(),
|
||||||
|
|||||||
@@ -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, ?string $stockAdjustmentId = null): CatalogItem
|
public function upsertMany(Tenant $tenant, array $variants): CatalogItem
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem {
|
return DB::transaction(function () use ($tenant, $variants): 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, $stockAdjustmentId);
|
$this->updateVariant($attribute, $variant, $itemAttribute, $data, $index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,
|
||||||
...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
|
'stock' => (int) $variant['stock'],
|
||||||
])->all();
|
])->all();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +212,6 @@ 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'],
|
||||||
@@ -231,13 +230,20 @@ 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
|
||||||
@@ -257,10 +263,7 @@ class AccommodationService
|
|||||||
'descripcion' => $data['description'],
|
'descripcion' => $data['description'],
|
||||||
'precio' => $data['price'],
|
'precio' => $data['price'],
|
||||||
]);
|
]);
|
||||||
$inventory->adjustAvailableStock(
|
$inventory->update(['real_stock' => $data['stock']]);
|
||||||
(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']],
|
||||||
|
|||||||
@@ -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, ?string $stockAdjustmentId = null): Collection
|
public function upsertMany(Tenant $tenant, array $entries): Collection
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $entries, $stockAdjustmentId): Collection {
|
return DB::transaction(function () use ($tenant, $entries): 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, $stockAdjustmentId, &$reservedSlugs): CatalogItem {
|
return collect($entries)->map(function (array $entry, int $index) use ($tenant, $category, &$reservedSlugs): CatalogItem {
|
||||||
if (isset($entry['id'])) {
|
if (isset($entry['id'])) {
|
||||||
return $this->update($tenant, $category, $entry, $index, $stockAdjustmentId);
|
return $this->update($tenant, $category, $entry, $index);
|
||||||
}
|
}
|
||||||
|
|
||||||
$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, ?string $stockAdjustmentId): CatalogItem
|
private function update(Tenant $tenant, Category $category, array $entry, int $index): CatalogItem
|
||||||
{
|
{
|
||||||
$catalogItem = CatalogItem::query()
|
$catalogItem = CatalogItem::query()
|
||||||
->whereKey($entry['id'])
|
->whereKey($entry['id'])
|
||||||
@@ -121,6 +121,14 @@ 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()
|
||||||
@@ -141,10 +149,7 @@ 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->adjustAvailableStock(
|
$inventory->update(['real_stock' => $entry['stock']]);
|
||||||
(int) $entry['stock_difference'],
|
|
||||||
idempotencyKey: $stockAdjustmentId,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $catalogItem->load([
|
return $catalogItem->load([
|
||||||
'variants.inventory',
|
'variants.inventory',
|
||||||
|
|||||||
@@ -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, ?string $stockAdjustmentId = null): CatalogItem
|
public function upsertMany(Tenant $tenant, array $variants): CatalogItem
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem {
|
return DB::transaction(function () use ($tenant, $variants): 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, $stockAdjustmentId);
|
$this->updateVariant($variant, $itemAttributes, $data, $index);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,11 +113,11 @@ class FoodService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array<int, array{id: int, stock_difference: int}> $variants
|
* @param array<int, array{id: int, stock: int}> $variants
|
||||||
*/
|
*/
|
||||||
public function updateHistoricalStock(Tenant $tenant, array $variants, ?string $stockAdjustmentId = null): CatalogItem
|
public function updateHistoricalStock(Tenant $tenant, array $variants): CatalogItem
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $variants, $stockAdjustmentId): CatalogItem {
|
return DB::transaction(function () use ($tenant, $variants): CatalogItem {
|
||||||
$food = CatalogItem::query()
|
$food = CatalogItem::query()
|
||||||
->forTenantCatalog($tenant)
|
->forTenantCatalog($tenant)
|
||||||
->where('slug', 'comida')
|
->where('slug', 'comida')
|
||||||
@@ -142,11 +142,17 @@ class FoodService
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$stock = (int) $data['stock'];
|
||||||
$inventory = $this->inventoryForHistoricalStockUpdate($variant);
|
$inventory = $this->inventoryForHistoricalStockUpdate($variant);
|
||||||
$inventory->adjustAvailableStock(
|
if ($stock < $inventory->reserved_stock) {
|
||||||
(int) $data['stock_difference'],
|
throw ValidationException::withMessages([
|
||||||
idempotencyKey: $stockAdjustmentId,
|
"variants.{$index}.stock" => [
|
||||||
);
|
'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;
|
||||||
@@ -175,7 +181,6 @@ 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;
|
||||||
@@ -296,7 +301,7 @@ class FoodService
|
|||||||
'schedule' => $schedule->value,
|
'schedule' => $schedule->value,
|
||||||
'service' => $service->value,
|
'service' => $service->value,
|
||||||
'description' => (string) ($variant['description'] ?? ''),
|
'description' => (string) ($variant['description'] ?? ''),
|
||||||
...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
|
'stock' => (int) $variant['stock'],
|
||||||
];
|
];
|
||||||
})->all();
|
})->all();
|
||||||
}
|
}
|
||||||
@@ -379,7 +384,6 @@ 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,
|
||||||
@@ -396,23 +400,27 @@ 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->adjustAvailableStock(
|
$inventory->update(['real_stock' => $data['stock']]);
|
||||||
(int) $data['stock_difference'],
|
|
||||||
idempotencyKey: $stockAdjustmentId,
|
|
||||||
);
|
|
||||||
$this->syncDefinitions($variant, $itemAttributes, $data);
|
$this->syncDefinitions($variant, $itemAttributes, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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, ?string $stockAdjustmentId = null): Collection
|
public function upsertMany(Tenant $tenant, array $items): Collection
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $items, $stockAdjustmentId): Collection {
|
return DB::transaction(function () use ($tenant, $items): 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,7 +57,6 @@ class MerchandiseService
|
|||||||
$tenant,
|
$tenant,
|
||||||
$attributes,
|
$attributes,
|
||||||
$category,
|
$category,
|
||||||
$stockAdjustmentId,
|
|
||||||
&$reservedSlugs,
|
&$reservedSlugs,
|
||||||
): CatalogItem {
|
): CatalogItem {
|
||||||
$item = isset($data['id'])
|
$item = isset($data['id'])
|
||||||
@@ -102,7 +101,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, $stockAdjustmentId);
|
$this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,7 +244,7 @@ class MerchandiseService
|
|||||||
...$variant,
|
...$variant,
|
||||||
'color' => $color->value,
|
'color' => $color->value,
|
||||||
'size' => $size->value,
|
'size' => $size->value,
|
||||||
...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
|
'stock' => (int) $variant['stock'],
|
||||||
];
|
];
|
||||||
})->all();
|
})->all();
|
||||||
}
|
}
|
||||||
@@ -343,7 +342,6 @@ 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'],
|
||||||
@@ -361,18 +359,22 @@ 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->adjustAvailableStock(
|
$inventory->update(['real_stock' => $data['stock']]);
|
||||||
(int) $data['stock_difference'],
|
|
||||||
idempotencyKey: $stockAdjustmentId,
|
|
||||||
);
|
|
||||||
$this->syncDefinitions($variant, $itemAttributes, $data);
|
$this->syncDefinitions($variant, $itemAttributes, $data);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,48 +19,48 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
->middleware("tenant.menu:{$menuCode}");
|
->middleware("tenant.menu:{$menuCode}");
|
||||||
};
|
};
|
||||||
|
|
||||||
$visibilityRoutes('entries', 'entries', 'onticket.adminapp.fiesta-futbol-infantil.entradas');
|
$visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
|
||||||
$visibilityRoutes('foods', 'foods', 'onticket.adminapp.fiesta-futbol-infantil.comida');
|
$visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
|
||||||
$visibilityRoutes('accommodations', 'accommodations', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos');
|
$visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
|
||||||
$visibilityRoutes('merchandise', 'merchandise', 'onticket.adminapp.fiesta-futbol-infantil.merchandising');
|
$visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
|
||||||
|
|
||||||
Route::get('entries', [EntryController::class, 'index'])
|
Route::get('entries', [EntryController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
->name('adminapp.fiesta-futbol-infantil.entries.index');
|
->name('adminapp.fiesta-futbol-infantil.entries.index');
|
||||||
Route::post('entries', [EntryController::class, 'store'])
|
Route::post('entries', [EntryController::class, 'store'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
->name('adminapp.fiesta-futbol-infantil.entries.store');
|
->name('adminapp.fiesta-futbol-infantil.entries.store');
|
||||||
Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
|
Route::delete('entries/{entry}', [EntryController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.entradas')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.entradas')
|
||||||
->name('adminapp.fiesta-futbol-infantil.entries.destroy');
|
->name('adminapp.fiesta-futbol-infantil.entries.destroy');
|
||||||
Route::get('foods', [FoodController::class, 'index'])
|
Route::get('foods', [FoodController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.index');
|
->name('adminapp.fiesta-futbol-infantil.foods.index');
|
||||||
Route::post('foods', [FoodController::class, 'store'])
|
Route::post('foods', [FoodController::class, 'store'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.store');
|
->name('adminapp.fiesta-futbol-infantil.foods.store');
|
||||||
Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
|
Route::patch('foods/history-stock', [FoodController::class, 'updateHistoricalStock'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
|
->name('adminapp.fiesta-futbol-infantil.foods.history-stock.update');
|
||||||
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
|
Route::delete('foods/{food}', [FoodController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.comida')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.comida')
|
||||||
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
->name('adminapp.fiesta-futbol-infantil.foods.destroy');
|
||||||
Route::get('accommodations', [AccommodationController::class, 'index'])
|
Route::get('accommodations', [AccommodationController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
->name('adminapp.fiesta-futbol-infantil.accommodations.index');
|
->name('adminapp.fiesta-futbol-infantil.accommodations.index');
|
||||||
Route::post('accommodations', [AccommodationController::class, 'store'])
|
Route::post('accommodations', [AccommodationController::class, 'store'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
->name('adminapp.fiesta-futbol-infantil.accommodations.store');
|
->name('adminapp.fiesta-futbol-infantil.accommodations.store');
|
||||||
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
|
Route::delete('accommodations/{accommodation}', [AccommodationController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.alojamientos')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.alojamientos')
|
||||||
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
|
->name('adminapp.fiesta-futbol-infantil.accommodations.destroy');
|
||||||
Route::get('merchandise', [MerchandiseController::class, 'index'])
|
Route::get('merchandise', [MerchandiseController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
->name('adminapp.fiesta-futbol-infantil.merchandise.index');
|
->name('adminapp.fiesta-futbol-infantil.merchandise.index');
|
||||||
Route::post('merchandise', [MerchandiseController::class, 'store'])
|
Route::post('merchandise', [MerchandiseController::class, 'store'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
->name('adminapp.fiesta-futbol-infantil.merchandise.store');
|
->name('adminapp.fiesta-futbol-infantil.merchandise.store');
|
||||||
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
|
Route::delete('merchandise/{merchandise}', [MerchandiseController::class, 'destroy'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.fiesta-futbol-infantil.merchandising')
|
->middleware('tenant.menu:adminapp.fiesta-futbol-infantil.merchandising')
|
||||||
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
|
->name('adminapp.fiesta-futbol-infantil.merchandise.destroy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class TicketController extends Controller
|
|||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new AdminAppTicketCollection(
|
return new AdminAppTicketCollection(
|
||||||
$this->ticketService->search($tenant, $request->validated())
|
$this->ticketService->search($tenant, $request->validated(), $request->user()->event_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ class TicketController extends Controller
|
|||||||
{
|
{
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket));
|
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket, $request->user()->event_id));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource
|
public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource
|
||||||
@@ -45,7 +45,7 @@ class TicketController extends Controller
|
|||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
$tenant = $request->user()->tenant()->firstOrFail();
|
||||||
|
|
||||||
return new AdminAppTicketRefundCalculationResource(
|
return new AdminAppTicketRefundCalculationResource(
|
||||||
$this->ticketService->calculateRefund($tenant, $ticket)
|
$this->ticketService->calculateRefund($tenant, $ticket, $request->user()->event_id)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
return $this->ticketPdfService->download(
|
return $this->ticketPdfService->download(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
$this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -80,7 +80,7 @@ class TicketController extends Controller
|
|||||||
|
|
||||||
return $this->ticketExcelService->download(
|
return $this->ticketExcelService->download(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
$this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,9 +41,9 @@ class AdminAppTicketService
|
|||||||
/**
|
/**
|
||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
*/
|
*/
|
||||||
public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult
|
public function search(Tenant $tenant, array $filters = [], ?int $eventId = null): AdminAppTicketResult
|
||||||
{
|
{
|
||||||
$query = $this->baseQuery($tenant, $filters);
|
$query = $this->baseQuery($tenant, $filters, $eventId);
|
||||||
$countQuery = clone $query;
|
$countQuery = clone $query;
|
||||||
|
|
||||||
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
||||||
@@ -77,7 +77,7 @@ class AdminAppTicketService
|
|||||||
tickets: $tickets,
|
tickets: $tickets,
|
||||||
scannedTickets: $scannedTickets,
|
scannedTickets: $scannedTickets,
|
||||||
totalTickets: $totalTickets,
|
totalTickets: $totalTickets,
|
||||||
refundedTotal: $this->refundSummaryService->totalForTenant($tenant),
|
refundedTotal: $this->refundSummaryService->totalForTenant($tenant, $eventId),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +85,9 @@ class AdminAppTicketService
|
|||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
|
||||||
* @return Collection<int, Ticket>
|
* @return Collection<int, Ticket>
|
||||||
*/
|
*/
|
||||||
public function ticketsForExport(Tenant $tenant, array $filters = []): Collection
|
public function ticketsForExport(Tenant $tenant, array $filters = [], ?int $eventId = null): Collection
|
||||||
{
|
{
|
||||||
$query = $this->baseQuery($tenant, $filters);
|
$query = $this->baseQuery($tenant, $filters, $eventId);
|
||||||
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
||||||
$tickets = $query
|
$tickets = $query
|
||||||
->with(self::RELATIONS)
|
->with(self::RELATIONS)
|
||||||
@@ -97,11 +97,10 @@ class AdminAppTicketService
|
|||||||
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
|
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function cancel(Tenant $tenant, int $ticketId): Ticket
|
public function cancel(Tenant $tenant, int $ticketId, ?int $eventId = null): Ticket
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($tenant, $ticketId): Ticket {
|
return DB::transaction(function () use ($tenant, $ticketId, $eventId): Ticket {
|
||||||
$ticket = Ticket::query()
|
$ticket = $this->ticketsQuery($tenant, $eventId)
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($ticketId);
|
->findOrFail($ticketId);
|
||||||
|
|
||||||
@@ -124,10 +123,9 @@ class AdminAppTicketService
|
|||||||
* partial: string|null,
|
* partial: string|null,
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public function calculateRefund(Tenant $tenant, int $ticketId): array
|
public function calculateRefund(Tenant $tenant, int $ticketId, ?int $eventId = null): array
|
||||||
{
|
{
|
||||||
$ticket = Ticket::query()
|
$ticket = $this->ticketsQuery($tenant, $eventId)
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->findOrFail($ticketId);
|
->findOrFail($ticketId);
|
||||||
|
|
||||||
if (! $ticket->can_refund()) {
|
if (! $ticket->can_refund()) {
|
||||||
@@ -175,14 +173,13 @@ 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 = $this->ticketsQuery($tenant, $createdBy?->event_id)
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->lockForUpdate()
|
->lockForUpdate()
|
||||||
->findOrFail($ticketId);
|
->findOrFail($ticketId);
|
||||||
|
|
||||||
|
$this->ensureRefundIsAllowed($tenant, $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([
|
||||||
@@ -228,13 +225,13 @@ class AdminAppTicketService
|
|||||||
'amount' => number_format($refundAmount, 2, '.', ''),
|
'amount' => number_format($refundAmount, 2, '.', ''),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$this->restoreInventory($ticket, $purchaseItem, $createdBy);
|
$this->restoreInventory($ticket, $purchaseItem);
|
||||||
|
|
||||||
return $ticket->refresh()->load(self::RELATIONS);
|
return $ticket->refresh()->load(self::RELATIONS);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem, ?User $createdBy): void
|
private function restoreInventory(Ticket $ticket, PurchaseItem $purchaseItem): void
|
||||||
{
|
{
|
||||||
$catalogItem = $ticket->sourceCatalogItem;
|
$catalogItem = $ticket->sourceCatalogItem;
|
||||||
if ($catalogItem === null) {
|
if ($catalogItem === null) {
|
||||||
@@ -272,7 +269,11 @@ class AdminAppTicketService
|
|||||||
throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']);
|
throw ValidationException::withMessages(['ticket' => 'No se encontró el inventario del ticket.']);
|
||||||
}
|
}
|
||||||
|
|
||||||
$inventory->refundStock(1, $createdBy);
|
if ($catalogItem->inventory_policy === InventoryPolicy::Tracked) {
|
||||||
|
$inventory->real_stock++;
|
||||||
|
}
|
||||||
|
$inventory->refunded_units++;
|
||||||
|
$inventory->save();
|
||||||
}
|
}
|
||||||
|
|
||||||
private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float
|
private function refundedAmountForPurchaseItem(PurchaseItem $purchaseItem): float
|
||||||
@@ -310,12 +311,11 @@ class AdminAppTicketService
|
|||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
||||||
* @return Builder<Ticket>
|
* @return Builder<Ticket>
|
||||||
*/
|
*/
|
||||||
private function baseQuery(Tenant $tenant, array $filters): Builder
|
private function baseQuery(Tenant $tenant, array $filters, ?int $eventId = null): Builder
|
||||||
{
|
{
|
||||||
$search = trim((string) ($filters['q'] ?? ''));
|
$search = trim((string) ($filters['q'] ?? ''));
|
||||||
|
|
||||||
$query = Ticket::query()
|
$query = $this->ticketsQuery($tenant, $eventId)
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->when($search !== '', function (Builder $query) use ($search): void {
|
->when($search !== '', function (Builder $query) use ($search): void {
|
||||||
$this->applySearchFilter($query, $search);
|
$this->applySearchFilter($query, $search);
|
||||||
})
|
})
|
||||||
@@ -355,6 +355,14 @@ class AdminAppTicketService
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @return Builder<Ticket> */
|
||||||
|
private function ticketsQuery(Tenant $tenant, ?int $eventId): Builder
|
||||||
|
{
|
||||||
|
return Ticket::query()
|
||||||
|
->where('tenant_code', $tenant->codigo)
|
||||||
|
->when($eventId !== null, fn (Builder $query): Builder => $query->where('tickets.event_id', $eventId));
|
||||||
|
}
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
/** @param Builder<Ticket> $query */
|
||||||
private function applySearchFilter(Builder $query, string $search): void
|
private function applySearchFilter(Builder $query, string $search): void
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -65,9 +64,14 @@ class BackfillRefundedUnitsService
|
|||||||
}, 'refunds.id', 'id');
|
}, 'refunds.id', 'id');
|
||||||
|
|
||||||
foreach ($counts as $inventoryId => $count) {
|
foreach ($counts as $inventoryId => $count) {
|
||||||
$inventory = Inventory::query()->lockForUpdate()->find($inventoryId)
|
$updates = ['refunded_units' => DB::raw('refunded_units + '.$count['refunded'])];
|
||||||
?? throw new RuntimeException("No se encontró el inventario {$inventoryId} para reponerlo.");
|
if (($count['stock'] ?? 0) > 0) {
|
||||||
$inventory->refundStock($count['refunded']);
|
$updates['real_stock'] = DB::raw('real_stock + '.$count['stock']);
|
||||||
|
}
|
||||||
|
|
||||||
|
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'));
|
||||||
@@ -78,8 +82,7 @@ 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' => 0,
|
'real_stock_added' => array_sum(array_column($counts, 'stock')),
|
||||||
'available_stock_added' => $refundedUnitsAdded,
|
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -82,13 +82,17 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
|
|||||||
- `POST /tickets/pdf`.
|
- `POST /tickets/pdf`.
|
||||||
|
|
||||||
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
|
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
|
||||||
`onticket.adminapp.tickets`:
|
`adminapp.tickets`:
|
||||||
|
|
||||||
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
|
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
|
||||||
`scanned_tickets` y `total_tickets` para el tenant autenticado.
|
`scanned_tickets` y `total_tickets` para el tenant autenticado.
|
||||||
|
|
||||||
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
|
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
|
||||||
|
|
||||||
|
Si el administrador autenticado tiene `event_id`, las consultas de Tickets y sus exportaciones se limitan a `tickets.event_id` dentro del tenant. Los contadores usan el mismo alcance y el total reembolsado se limita a las compras del evento. Sin `event_id`, se conserva el alcance por tenant.
|
||||||
|
|
||||||
|
La cancelación, el cálculo de reembolso y el reembolso buscan el ticket dentro de ese alcance antes de validar o ejecutar la operación. Un ticket de otro evento o sin evento devuelve 404 para un administrador con evento asignado. El alcance se obtiene del usuario autenticado, no de los parámetros del cliente.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación.
|
Depende de `Purchase`, `Catalog`, `Tenant` y `Auth`. La generación debe ser idempotente ante reintentos del evento. `TicketNotAvailableException` y `TicketGenerationException` separan indisponibilidad de errores de generación.
|
||||||
|
|||||||
@@ -7,24 +7,24 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('tickets', [TicketController::class, 'index'])
|
Route::get('tickets', [TicketController::class, 'index'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.index');
|
->name('adminapp.tickets.index');
|
||||||
Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel'])
|
Route::post('tickets/{ticket}/cancel', [TicketController::class, 'cancel'])
|
||||||
->whereNumber('ticket')
|
->whereNumber('ticket')
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.cancel');
|
->name('adminapp.tickets.cancel');
|
||||||
Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund'])
|
Route::get('tickets/{ticket}/refund', [TicketController::class, 'calculateRefund'])
|
||||||
->whereNumber('ticket')
|
->whereNumber('ticket')
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.calculate-refund');
|
->name('adminapp.tickets.calculate-refund');
|
||||||
Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund'])
|
Route::post('tickets/{ticket}/refund', [TicketController::class, 'refund'])
|
||||||
->whereNumber('ticket')
|
->whereNumber('ticket')
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.refund');
|
->name('adminapp.tickets.refund');
|
||||||
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
|
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.pdf');
|
->name('adminapp.tickets.pdf');
|
||||||
Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
|
Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
|
||||||
->middleware('tenant.menu:onticket.adminapp.tickets')
|
->middleware('tenant.menu:adminapp.tickets')
|
||||||
->name('adminapp.tickets.excel');
|
->name('adminapp.tickets.excel');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,9 +13,6 @@ class EnsureTenantHasMenu
|
|||||||
$tenant = $request->user()?->tenant()->first();
|
$tenant = $request->user()?->tenant()->first();
|
||||||
$hasMenu = $tenant?->menues()
|
$hasMenu = $tenant?->menues()
|
||||||
->where('menues.code', $menuCode)
|
->where('menues.code', $menuCode)
|
||||||
->where(fn ($query) => $query
|
|
||||||
->where('menues.code', 'help.contact')
|
|
||||||
->orWhere('tenants_menues.is_enabled', true))
|
|
||||||
->exists() ?? false;
|
->exists() ?? false;
|
||||||
|
|
||||||
abort_unless($hasMenu, 404);
|
abort_unless($hasMenu, 404);
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ Route::prefix('v1/adminapp/forms')
|
|||||||
Route::get(
|
Route::get(
|
||||||
'desfile/entry-reservation',
|
'desfile/entry-reservation',
|
||||||
DesfileEntryReservationFormController::class
|
DesfileEntryReservationFormController::class
|
||||||
)->middleware('tenant.menu:onticket.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)
|
Route::get('tickets-filter', TicketFilterFormController::class)
|
||||||
->middleware('tenant.menu:onticket.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',
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ return new class extends Migration
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
$sourcePath = public_path('images/website_types/onticket/'.self::FILENAME);
|
$sourcePath = public_path('images/website_types/'.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}");
|
||||||
|
|||||||
@@ -1,89 +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 $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}."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,33 +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::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');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,236 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
private const ONTICKET_MENUS = [
|
|
||||||
'adminapp.event' => ['onticket.adminapp.event', 'Eventos', '/admin/event'],
|
|
||||||
'adminapp.inicio' => ['onticket.adminapp.inicio', 'Inicio', '/admin/inicio'],
|
|
||||||
'adminapp.catalog' => ['onticket.adminapp.catalog', 'Catálogo', '/admin/catalog'],
|
|
||||||
'adminapp.combos' => ['onticket.adminapp.combos', 'Combos', '/admin/combos'],
|
|
||||||
'adminapp.categories' => ['onticket.adminapp.categories', 'Categorías', '/admin/categories'],
|
|
||||||
'adminapp.ventas' => ['onticket.adminapp.ventas', 'Ventas', '/admin/ventas'],
|
|
||||||
'adminapp.staff' => ['onticket.adminapp.staff', 'Usuarios', '/admin/staff'],
|
|
||||||
'adminapp.tickets' => ['onticket.adminapp.tickets', 'Tickets', '/admin/tickets'],
|
|
||||||
'adminapp.fiesta-futbol-infantil.entradas' => [
|
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.entradas',
|
|
||||||
'Entradas',
|
|
||||||
'/admin/entradas',
|
|
||||||
],
|
|
||||||
'adminapp.fiesta-futbol-infantil.alojamientos' => [
|
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.alojamientos',
|
|
||||||
'Alojamientos',
|
|
||||||
'/admin/alojamientos',
|
|
||||||
],
|
|
||||||
'adminapp.fiesta-futbol-infantil.merchandising' => [
|
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.merchandising',
|
|
||||||
'Merchandising',
|
|
||||||
'/admin/merchandising',
|
|
||||||
],
|
|
||||||
'adminapp.fiesta-futbol-infantil.comida' => [
|
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.comida',
|
|
||||||
'Comida',
|
|
||||||
'/admin/comidas',
|
|
||||||
],
|
|
||||||
'adminapp.desfile.entradas' => [
|
|
||||||
'onticket.adminapp.desfile.entradas',
|
|
||||||
'Entradas',
|
|
||||||
'/admin/desfile/entradas',
|
|
||||||
],
|
|
||||||
'adminapp.desfile.reservas' => [
|
|
||||||
'onticket.adminapp.desfile.reservas',
|
|
||||||
'Reserva de Tickets',
|
|
||||||
'/admin/desfile/reservas',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
private const SHOPIT_MENUS = [
|
|
||||||
'shopit.adminapp.marca' => ['Marca', '/admin/marca'],
|
|
||||||
'shopit.adminapp.contacto' => ['Contacto', '/admin/contacto'],
|
|
||||||
'shopit.adminapp.productos' => ['Productos', '/admin/productos'],
|
|
||||||
'shopit.adminapp.categorias' => ['Categorías', '/admin/categorias'],
|
|
||||||
'shopit.adminapp.transacciones' => ['Transacciones', '/admin/transacciones'],
|
|
||||||
'shopit.adminapp.reportes' => ['Reportes', '/admin/reportes'],
|
|
||||||
'shopit.adminapp.usuarios_roles' => ['Usuarios y roles', '/admin/usuarios-roles'],
|
|
||||||
];
|
|
||||||
|
|
||||||
private const LEGACY_SHOPIT_ADMIN_MENUS = [
|
|
||||||
'adminapp.event',
|
|
||||||
'adminapp.inicio',
|
|
||||||
'adminapp.catalog',
|
|
||||||
'adminapp.combos',
|
|
||||||
'adminapp.categories',
|
|
||||||
'adminapp.ventas',
|
|
||||||
'adminapp.staff',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
DB::transaction(function (): void {
|
|
||||||
if (! DB::table('menues')->where('code', 'main.adminapp')->exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::ONTICKET_MENUS as $legacyCode => [$code, $label, $route]) {
|
|
||||||
$this->replaceMenuCode($legacyCode, $code, $label, $route);
|
|
||||||
$this->assignMenuToAdminRoles($code);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (self::SHOPIT_MENUS as $code => [$label, $route]) {
|
|
||||||
$this->upsertMenu($code, $label, $route);
|
|
||||||
$this->assignMenuToAdminRoles($code);
|
|
||||||
}
|
|
||||||
|
|
||||||
$shopItTenantCodes = DB::table('tenants')
|
|
||||||
->where('admin_website_type_code', 'shopit')
|
|
||||||
->pluck('codigo');
|
|
||||||
|
|
||||||
if ($shopItTenantCodes->isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$adminMenuCodes = DB::table('menues')
|
|
||||||
->where('parent_menu_code', 'main.adminapp')
|
|
||||||
->pluck('code');
|
|
||||||
|
|
||||||
DB::table('tenants_menues')
|
|
||||||
->whereIn('tenant_code', $shopItTenantCodes)
|
|
||||||
->whereIn('menu_code', $adminMenuCodes)
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
foreach ($shopItTenantCodes as $tenantCode) {
|
|
||||||
$this->assignMenuToTenant($tenantCode, 'main.adminapp');
|
|
||||||
|
|
||||||
foreach (array_keys(self::SHOPIT_MENUS) as $menuCode) {
|
|
||||||
$this->assignMenuToTenant($tenantCode, $menuCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
DB::transaction(function (): void {
|
|
||||||
if (! DB::table('menues')->where('code', 'main.adminapp')->exists()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$shopItTenantCodes = DB::table('tenants')
|
|
||||||
->where('admin_website_type_code', 'shopit')
|
|
||||||
->pluck('codigo');
|
|
||||||
|
|
||||||
DB::table('tenants_menues')
|
|
||||||
->whereIn('tenant_code', $shopItTenantCodes)
|
|
||||||
->whereIn('menu_code', array_keys(self::SHOPIT_MENUS))
|
|
||||||
->delete();
|
|
||||||
DB::table('roles_menues')
|
|
||||||
->whereIn('menu_codigo', array_keys(self::SHOPIT_MENUS))
|
|
||||||
->delete();
|
|
||||||
DB::table('menues')
|
|
||||||
->whereIn('code', array_keys(self::SHOPIT_MENUS))
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
foreach (self::ONTICKET_MENUS as $legacyCode => [$code, $label, $route]) {
|
|
||||||
$this->replaceMenuCode($code, $legacyCode, $label, $route);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($shopItTenantCodes as $tenantCode) {
|
|
||||||
foreach (self::LEGACY_SHOPIT_ADMIN_MENUS as $menuCode) {
|
|
||||||
$this->assignMenuToTenant($tenantCode, $menuCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function replaceMenuCode(
|
|
||||||
string $sourceCode,
|
|
||||||
string $targetCode,
|
|
||||||
string $label,
|
|
||||||
string $route,
|
|
||||||
): void {
|
|
||||||
$source = DB::table('menues')->where('code', $sourceCode)->first();
|
|
||||||
|
|
||||||
$this->upsertMenu($targetCode, $label, $route, $source);
|
|
||||||
|
|
||||||
if ($source === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
DB::table('tenants_menues')
|
|
||||||
->where('menu_code', $sourceCode)
|
|
||||||
->get()
|
|
||||||
->each(function (object $assignment) use ($targetCode): void {
|
|
||||||
DB::table('tenants_menues')->updateOrInsert(
|
|
||||||
[
|
|
||||||
'tenant_code' => $assignment->tenant_code,
|
|
||||||
'menu_code' => $targetCode,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'static_content' => $assignment->static_content,
|
|
||||||
'created_at' => $assignment->created_at ?? now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
DB::table('roles_menues')
|
|
||||||
->where('menu_codigo', $sourceCode)
|
|
||||||
->pluck('rol_codigo')
|
|
||||||
->each(fn (string $roleCode) => DB::table('roles_menues')->updateOrInsert([
|
|
||||||
'rol_codigo' => $roleCode,
|
|
||||||
'menu_codigo' => $targetCode,
|
|
||||||
]));
|
|
||||||
|
|
||||||
DB::table('tenants_menues')->where('menu_code', $sourceCode)->delete();
|
|
||||||
DB::table('roles_menues')->where('menu_codigo', $sourceCode)->delete();
|
|
||||||
DB::table('menues')->where('code', $sourceCode)->delete();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function upsertMenu(
|
|
||||||
string $code,
|
|
||||||
string $label,
|
|
||||||
string $route,
|
|
||||||
?object $source = null,
|
|
||||||
): void {
|
|
||||||
DB::table('menues')->updateOrInsert(
|
|
||||||
['code' => $code],
|
|
||||||
[
|
|
||||||
'label' => $source->label ?? $label,
|
|
||||||
'parent_menu_code' => 'main.adminapp',
|
|
||||||
'content_type' => $source->content_type ?? 'dynamic',
|
|
||||||
'static_content_schema' => $source->static_content_schema ?? null,
|
|
||||||
'route' => $source->route ?? $route,
|
|
||||||
'created_at' => $source->created_at ?? now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assignMenuToAdminRoles(string $menuCode): void
|
|
||||||
{
|
|
||||||
DB::table('roles')
|
|
||||||
->whereIn('codigo', ['admin', 'adminapp'])
|
|
||||||
->pluck('codigo')
|
|
||||||
->each(fn (string $roleCode) => DB::table('roles_menues')->updateOrInsert([
|
|
||||||
'rol_codigo' => $roleCode,
|
|
||||||
'menu_codigo' => $menuCode,
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function assignMenuToTenant(string $tenantCode, string $menuCode): void
|
|
||||||
{
|
|
||||||
DB::table('tenants_menues')->updateOrInsert(
|
|
||||||
[
|
|
||||||
'tenant_code' => $tenantCode,
|
|
||||||
'menu_code' => $menuCode,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'static_content' => null,
|
|
||||||
'created_at' => now(),
|
|
||||||
'updated_at' => now(),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,22 +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('tenants_menues', function (Blueprint $table): void {
|
|
||||||
$table->boolean('is_enabled')->default(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('tenants_menues', function (Blueprint $table): void {
|
|
||||||
$table->dropColumn('is_enabled');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use Illuminate\Database\Migrations\Migration;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
return new class extends Migration
|
|
||||||
{
|
|
||||||
public function up(): void
|
|
||||||
{
|
|
||||||
DB::table('menues')
|
|
||||||
->where('code', 'help.terms-and-conditions')
|
|
||||||
->update([
|
|
||||||
'content_type' => 'static',
|
|
||||||
'static_content_schema' => json_encode([
|
|
||||||
'html' => 'present|nullable|string',
|
|
||||||
], JSON_THROW_ON_ERROR),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
DB::table('menues')
|
|
||||||
->where('code', 'help.terms-and-conditions')
|
|
||||||
->update([
|
|
||||||
'content_type' => 'dynamic',
|
|
||||||
'static_content_schema' => null,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,57 +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
|
|
||||||
{
|
|
||||||
$driver = DB::connection()->getDriverName();
|
|
||||||
|
|
||||||
if (! in_array($driver, ['mysql', 'mariadb', 'sqlite', 'pgsql', 'sqlsrv'], true)) {
|
|
||||||
throw new RuntimeException("Unsupported address database driver: {$driver}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Schema::create('addresses', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('label');
|
|
||||||
$table->text('address_text');
|
|
||||||
$table->decimal('latitude', 10, 7)->nullable();
|
|
||||||
$table->decimal('longitude', 10, 7)->nullable();
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
|
|
||||||
Schema::create('tenant_addresses', function (Blueprint $table) use ($driver): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('tenant_codigo');
|
|
||||||
$table->foreignId('address_id')->constrained('addresses')->cascadeOnDelete();
|
|
||||||
$table->boolean('is_main')->default(false);
|
|
||||||
$table->timestamps();
|
|
||||||
|
|
||||||
$table->foreign('tenant_codigo')->references('codigo')->on('tenants')->cascadeOnDelete();
|
|
||||||
$table->unique(['tenant_codigo', 'address_id']);
|
|
||||||
|
|
||||||
if (in_array($driver, ['mysql', 'mariadb'], true)) {
|
|
||||||
// NULL allows many secondary addresses, while a tenant code can occur only once.
|
|
||||||
$table->string('main_tenant_codigo')->nullable()
|
|
||||||
->virtualAs('CASE WHEN is_main = 1 THEN tenant_codigo ELSE NULL END');
|
|
||||||
$table->unique('main_tenant_codigo', 'tenant_addresses_one_main_per_tenant');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (! in_array($driver, ['mysql', 'mariadb'], true)) {
|
|
||||||
$condition = $driver === 'pgsql' ? 'is_main = true' : 'is_main = 1';
|
|
||||||
DB::statement('CREATE UNIQUE INDEX tenant_addresses_one_main_per_tenant '
|
|
||||||
."ON tenant_addresses (tenant_codigo) WHERE {$condition}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('tenant_addresses');
|
|
||||||
Schema::dropIfExists('addresses');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,108 +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_menues')
|
|
||||||
->where('menu_code', 'help.contact')
|
|
||||||
->whereNotNull('static_content')
|
|
||||||
->orderBy('id')
|
|
||||||
->chunkById(100, function ($menus): void {
|
|
||||||
foreach ($menus as $menu) {
|
|
||||||
$content = json_decode($menu->static_content, true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
if ($content === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($content) || ! is_array($content['locations'] ?? [])) {
|
|
||||||
throw new RuntimeException("Invalid contact locations for tenant {$menu->tenant_code}");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($content['locations'] ?? [] as $location) {
|
|
||||||
if (! is_array($location) || ! is_array($location['addresses'] ?? [])) {
|
|
||||||
throw new RuntimeException("Invalid contact addresses for tenant {$menu->tenant_code}");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($location['addresses'] ?? [] as $address) {
|
|
||||||
$this->copyAddress($menu->tenant_code, $location, $address);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copyAddress(string $tenantCode, array $location, mixed $address): void
|
|
||||||
{
|
|
||||||
if (! is_array($address) || ! is_string($address['address'] ?? null)
|
|
||||||
|| trim($address['address']) === '') {
|
|
||||||
throw new RuntimeException("Invalid contact address text for tenant {$tenantCode}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$coordinates = $address['coordinates'] ?? null;
|
|
||||||
|
|
||||||
if ($coordinates !== null && (! is_array($coordinates) || count($coordinates) !== 2
|
|
||||||
|| ! is_numeric($coordinates[0] ?? null) || ! is_numeric($coordinates[1] ?? null)
|
|
||||||
|| ! is_finite((float) $coordinates[0]) || ! is_finite((float) $coordinates[1])
|
|
||||||
|| abs((float) $coordinates[0]) > 90 || abs((float) $coordinates[1]) > 180)) {
|
|
||||||
throw new RuntimeException("Invalid contact coordinates for tenant {$tenantCode}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$label = $address['label'] ?? $location['label'] ?? 'Dirección';
|
|
||||||
|
|
||||||
if (! is_string($label) || trim($label) === '' || mb_strlen($label) > 255) {
|
|
||||||
throw new RuntimeException("Invalid contact address label for tenant {$tenantCode}");
|
|
||||||
}
|
|
||||||
|
|
||||||
$values = [
|
|
||||||
'label' => $label,
|
|
||||||
'address_text' => $address['address'],
|
|
||||||
'latitude' => $coordinates === null ? null : number_format((float) $coordinates[0], 7, '.', ''),
|
|
||||||
'longitude' => $coordinates === null ? null : number_format((float) $coordinates[1], 7, '.', ''),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Reuse only within this tenant; equal legacy addresses in different tenants stay independent.
|
|
||||||
$existing = DB::table('addresses')
|
|
||||||
->join('tenant_addresses', 'tenant_addresses.address_id', '=', 'addresses.id')
|
|
||||||
->where('tenant_addresses.tenant_codigo', $tenantCode)
|
|
||||||
->where(array_combine(
|
|
||||||
array_map(fn (string $field): string => 'addresses.'.$field, array_keys($values)),
|
|
||||||
array_values($values),
|
|
||||||
))
|
|
||||||
->first(['addresses.id', 'tenant_addresses.id as link_id']);
|
|
||||||
|
|
||||||
if ($existing !== null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = now();
|
|
||||||
$isMain = ! DB::table('tenant_addresses')
|
|
||||||
->where('tenant_codigo', $tenantCode)
|
|
||||||
->where('is_main', true)
|
|
||||||
->exists();
|
|
||||||
$addressId = DB::table('addresses')->insertGetId([
|
|
||||||
...$values,
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
|
|
||||||
DB::table('tenant_addresses')->insert([
|
|
||||||
'tenant_codigo' => $tenantCode,
|
|
||||||
'address_id' => $addressId,
|
|
||||||
'is_main' => $isMain,
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
// Keep copies: they may already have been edited or reused. Legacy menus were never changed.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,22 +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('address_id')->nullable()->constrained('addresses')->nullOnDelete();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::table('events', function (Blueprint $table): void {
|
|
||||||
$table->dropConstrainedForeignId('address_id');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,57 +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('events')->whereNull('address_id')->orderBy('id')->lockForUpdate()
|
|
||||||
->chunkById(100, function ($events): void {
|
|
||||||
foreach ($events as $event) {
|
|
||||||
$coordinates = $event->exact_location === null
|
|
||||||
? null
|
|
||||||
: json_decode($event->exact_location, true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
|
|
||||||
if ($coordinates !== null && (! is_array($coordinates)
|
|
||||||
|| ! is_numeric($coordinates['latitude'] ?? null)
|
|
||||||
|| ! is_numeric($coordinates['longitude'] ?? null)
|
|
||||||
|| ! is_finite((float) $coordinates['latitude'])
|
|
||||||
|| ! is_finite((float) $coordinates['longitude'])
|
|
||||||
|| abs((float) $coordinates['latitude']) > 90
|
|
||||||
|| abs((float) $coordinates['longitude']) > 180)) {
|
|
||||||
throw new RuntimeException("Invalid location coordinates for event {$event->id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($event->location === null || trim($event->location) === '') {
|
|
||||||
if ($coordinates !== null) {
|
|
||||||
throw new RuntimeException("Missing address text for event {$event->id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$now = now();
|
|
||||||
$addressId = DB::table('addresses')->insertGetId([
|
|
||||||
'label' => 'Ubicación del evento',
|
|
||||||
'address_text' => $event->location,
|
|
||||||
'latitude' => $coordinates === null ? null : $coordinates['latitude'],
|
|
||||||
'longitude' => $coordinates === null ? null : $coordinates['longitude'],
|
|
||||||
'created_at' => $now,
|
|
||||||
'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Update only the new link, preserving the original fields and timestamps.
|
|
||||||
DB::table('events')->where('id', $event->id)->update(['address_id' => $addressId]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
// Copies may already have been edited or shared; keep them and the legacy data.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,48 +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
|
|
||||||
{
|
|
||||||
$driver = DB::connection()->getDriverName();
|
|
||||||
if (! in_array($driver, ['mysql', 'mariadb', 'sqlite', 'pgsql', 'sqlsrv'], true)) {
|
|
||||||
throw new RuntimeException("Unsupported phone number database driver: {$driver}");
|
|
||||||
}
|
|
||||||
|
|
||||||
Schema::create('phone_numbers', function (Blueprint $table): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('label');
|
|
||||||
$table->string('number');
|
|
||||||
$table->timestamps();
|
|
||||||
});
|
|
||||||
Schema::create('tenant_phone_numbers', function (Blueprint $table) use ($driver): void {
|
|
||||||
$table->id();
|
|
||||||
$table->string('tenant_codigo');
|
|
||||||
$table->foreignId('phone_number_id')->constrained('phone_numbers')->cascadeOnDelete();
|
|
||||||
$table->boolean('is_main')->default(false);
|
|
||||||
$table->timestamps();
|
|
||||||
$table->foreign('tenant_codigo')->references('codigo')->on('tenants')->cascadeOnDelete();
|
|
||||||
$table->unique(['tenant_codigo', 'phone_number_id']);
|
|
||||||
if (in_array($driver, ['mysql', 'mariadb'], true)) {
|
|
||||||
$table->string('main_tenant_codigo')->nullable()
|
|
||||||
->virtualAs('CASE WHEN is_main = 1 THEN tenant_codigo ELSE NULL END');
|
|
||||||
$table->unique('main_tenant_codigo', 'tenant_phones_one_main');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (! in_array($driver, ['mysql', 'mariadb'], true)) {
|
|
||||||
$condition = $driver === 'pgsql' ? 'is_main = true' : 'is_main = 1';
|
|
||||||
DB::statement("CREATE UNIQUE INDEX tenant_phones_one_main ON tenant_phone_numbers (tenant_codigo) WHERE {$condition}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
Schema::dropIfExists('tenant_phone_numbers');
|
|
||||||
Schema::dropIfExists('phone_numbers');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,65 +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')->orderBy('id')->chunkById(100, function ($tenants): void {
|
|
||||||
foreach ($tenants as $tenant) {
|
|
||||||
$this->copyPhone($tenant->codigo, $tenant->phone, true);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
DB::table('tenants_menues')->where('menu_code', 'help.contact')
|
|
||||||
->whereNotNull('static_content')->orderBy('id')->chunkById(100, function ($menus): void {
|
|
||||||
foreach ($menus as $menu) {
|
|
||||||
$content = json_decode($menu->static_content, true, flags: JSON_THROW_ON_ERROR);
|
|
||||||
if ($content === null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (! is_array($content)) {
|
|
||||||
throw new RuntimeException("Invalid contact content for tenant {$menu->tenant_code}");
|
|
||||||
}
|
|
||||||
$this->copyPhone($menu->tenant_code, $content['phone'] ?? null, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function copyPhone(string $tenantCode, mixed $number, bool $fromTenant): void
|
|
||||||
{
|
|
||||||
if ($number === null || (is_string($number) && trim($number) === '')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (! is_string($number) || mb_strlen($number) > 255 || ! preg_match('/[0-9]/', $number)) {
|
|
||||||
throw new RuntimeException("Invalid phone number for tenant {$tenantCode}");
|
|
||||||
}
|
|
||||||
$numbers = DB::table('phone_numbers')
|
|
||||||
->join('tenant_phone_numbers', 'phone_numbers.id', '=', 'tenant_phone_numbers.phone_number_id')
|
|
||||||
->where('tenant_codigo', $tenantCode)->get(['phone_numbers.number']);
|
|
||||||
$dialable = preg_replace('/[^0-9]/', '', $number);
|
|
||||||
foreach ($numbers as $existing) {
|
|
||||||
if (preg_replace('/[^0-9]/', '', $existing->number) === $dialable) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
$isMain = ! DB::table('tenant_phone_numbers')->where('tenant_codigo', $tenantCode)->where('is_main', true)->exists();
|
|
||||||
$now = now();
|
|
||||||
$id = DB::table('phone_numbers')->insertGetId([
|
|
||||||
'label' => $fromTenant ? 'Principal' : 'Contacto',
|
|
||||||
'number' => trim($number), 'created_at' => $now, 'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
DB::table('tenant_phone_numbers')->insert([
|
|
||||||
'tenant_codigo' => $tenantCode, 'phone_number_id' => $id, 'is_main' => $isMain,
|
|
||||||
'created_at' => $now, 'updated_at' => $now,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function down(): void
|
|
||||||
{
|
|
||||||
// Preserve copies that may have been edited. The legacy sources were not changed.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?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 {
|
||||||
|
$table->foreignId('event_id')
|
||||||
|
->nullable()
|
||||||
|
->after('tenant_codigo')
|
||||||
|
->constrained('events')
|
||||||
|
->cascadeOnUpdate()
|
||||||
|
->restrictOnDelete();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::table('users', function (Blueprint $table): void {
|
||||||
|
$table->dropForeign(['event_id']);
|
||||||
|
$table->dropColumn('event_id');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -14,7 +14,6 @@ use App\Shared\Enums\FieldType;
|
|||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
use Illuminate\Http\UploadedFile;
|
use Illuminate\Http\UploadedFile;
|
||||||
use Illuminate\Support\Carbon;
|
use Illuminate\Support\Carbon;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -52,19 +51,14 @@ class FiestaTradicionArrufoSeeder extends Seeder
|
|||||||
['tenant_code' => $tenant->codigo, 'title' => self::TITLE],
|
['tenant_code' => $tenant->codigo, 'title' => self::TITLE],
|
||||||
['published_at' => now()],
|
['published_at' => now()],
|
||||||
);
|
);
|
||||||
$eventData = [
|
$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ó.'
|
||||||
."\n\n".'Organiza: Biblioteca Popular Miguel Ángel Sosa.',
|
."\n\n".'Organiza: Biblioteca Popular Miguel Ángel Sosa.',
|
||||||
'location' => 'Predio de Doma del Club Unión Deportiva Arrufó',
|
'location' => 'Predio de Doma del Club Unión Deportiva Arrufó',
|
||||||
];
|
]);
|
||||||
|
|
||||||
if (Schema::hasColumn('events', 'client_id')) {
|
|
||||||
$eventData['client_id'] = $client->id;
|
|
||||||
}
|
|
||||||
|
|
||||||
$event->update($eventData);
|
|
||||||
|
|
||||||
$eventImage = $event->attachment;
|
$eventImage = $event->attachment;
|
||||||
if ($eventImage === null || ! Storage::disk('s3')->exists($eventImage->path)) {
|
if ($eventImage === null || ! Storage::disk('s3')->exists($eventImage->path)) {
|
||||||
|
|||||||
@@ -53,131 +53,89 @@ class MenuSeeder extends Seeder
|
|||||||
'route' => '/scanner/detail/:id',
|
'route' => '/scanner/detail/:id',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.event',
|
'code' => 'adminapp.event',
|
||||||
'label' => 'Eventos',
|
'label' => 'Eventos',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/event',
|
'route' => '/admin/event',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.inicio',
|
'code' => 'adminapp.inicio',
|
||||||
'label' => 'Inicio',
|
'label' => 'Inicio',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/inicio',
|
'route' => '/admin/inicio',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.catalog',
|
'code' => 'adminapp.catalog',
|
||||||
'label' => 'Catálogo',
|
'label' => 'Catálogo',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/catalog',
|
'route' => '/admin/catalog',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.combos',
|
'code' => 'adminapp.combos',
|
||||||
'label' => 'Combos',
|
'label' => 'Combos',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/combos',
|
'route' => '/admin/combos',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.categories',
|
'code' => 'adminapp.categories',
|
||||||
'label' => 'Categorías',
|
'label' => 'Categorías',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/categories',
|
'route' => '/admin/categories',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.ventas',
|
'code' => 'adminapp.ventas',
|
||||||
'label' => 'Ventas',
|
'label' => 'Ventas',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/ventas',
|
'route' => '/admin/ventas',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.staff',
|
'code' => 'adminapp.staff',
|
||||||
'label' => 'Usuarios',
|
'label' => 'Usuarios',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/staff',
|
'route' => '/admin/staff',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.tickets',
|
'code' => 'adminapp.tickets',
|
||||||
'label' => 'Tickets',
|
'label' => 'Tickets',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/tickets',
|
'route' => '/admin/tickets',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.entradas',
|
'code' => 'adminapp.fiesta-futbol-infantil.entradas',
|
||||||
'label' => 'Entradas',
|
'label' => 'Entradas',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/entradas',
|
'route' => '/admin/entradas',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.alojamientos',
|
'code' => 'adminapp.fiesta-futbol-infantil.alojamientos',
|
||||||
'label' => 'Alojamientos',
|
'label' => 'Alojamientos',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/alojamientos',
|
'route' => '/admin/alojamientos',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.merchandising',
|
'code' => 'adminapp.fiesta-futbol-infantil.merchandising',
|
||||||
'label' => 'Merchandising',
|
'label' => 'Merchandising',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/merchandising',
|
'route' => '/admin/merchandising',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.fiesta-futbol-infantil.comida',
|
'code' => 'adminapp.fiesta-futbol-infantil.comida',
|
||||||
'label' => 'Comida',
|
'label' => 'Comida',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/comidas',
|
'route' => '/admin/comidas',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.desfile.entradas',
|
'code' => 'adminapp.desfile.entradas',
|
||||||
'label' => 'Entradas',
|
'label' => 'Entradas',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/desfile/entradas',
|
'route' => '/admin/desfile/entradas',
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'code' => 'onticket.adminapp.desfile.reservas',
|
'code' => 'adminapp.desfile.reservas',
|
||||||
'label' => 'Reserva de Tickets',
|
'label' => 'Reserva de Tickets',
|
||||||
'parent_menu_code' => 'main.adminapp',
|
'parent_menu_code' => 'main.adminapp',
|
||||||
'route' => '/admin/desfile/reservas',
|
'route' => '/admin/desfile/reservas',
|
||||||
],
|
],
|
||||||
[
|
|
||||||
'code' => 'shopit.adminapp.marca',
|
|
||||||
'label' => 'Marca',
|
|
||||||
'parent_menu_code' => 'main.adminapp',
|
|
||||||
'route' => '/admin/marca',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'code' => 'shopit.adminapp.contacto',
|
|
||||||
'label' => 'Contacto',
|
|
||||||
'parent_menu_code' => 'main.adminapp',
|
|
||||||
'route' => '/admin/contacto',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'code' => 'shopit.adminapp.productos',
|
|
||||||
'label' => 'Productos',
|
|
||||||
'parent_menu_code' => 'main.adminapp',
|
|
||||||
'route' => '/admin/productos',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'code' => 'shopit.adminapp.categorias',
|
|
||||||
'label' => 'Categorías',
|
|
||||||
'parent_menu_code' => 'main.adminapp',
|
|
||||||
'route' => '/admin/categorias',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'code' => 'shopit.adminapp.transacciones',
|
|
||||||
'label' => 'Transacciones',
|
|
||||||
'parent_menu_code' => 'main.adminapp',
|
|
||||||
'route' => '/admin/transacciones',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'code' => 'shopit.adminapp.reportes',
|
|
||||||
'label' => 'Reportes',
|
|
||||||
'parent_menu_code' => 'main.adminapp',
|
|
||||||
'route' => '/admin/reportes',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'code' => 'shopit.adminapp.usuarios_roles',
|
|
||||||
'label' => 'Usuarios y roles',
|
|
||||||
'parent_menu_code' => 'main.adminapp',
|
|
||||||
'route' => '/admin/usuarios-roles',
|
|
||||||
],
|
|
||||||
[
|
[
|
||||||
'code' => 'account',
|
'code' => 'account',
|
||||||
'label' => 'Mi cuenta',
|
'label' => 'Mi cuenta',
|
||||||
@@ -261,10 +219,7 @@ class MenuSeeder extends Seeder
|
|||||||
'code' => 'help.terms-and-conditions',
|
'code' => 'help.terms-and-conditions',
|
||||||
'label' => 'Términos y condiciones',
|
'label' => 'Términos y condiciones',
|
||||||
'parent_menu_code' => 'help',
|
'parent_menu_code' => 'help',
|
||||||
'content_type' => Menu::CONTENT_TYPE_STATIC,
|
'content_type' => Menu::CONTENT_TYPE_DYNAMIC,
|
||||||
'static_content_schema' => [
|
|
||||||
'html' => 'present|nullable|string',
|
|
||||||
],
|
|
||||||
'route' => '/ayuda/terminos-y-condiciones',
|
'route' => '/ayuda/terminos-y-condiciones',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -300,10 +255,6 @@ class MenuSeeder extends Seeder
|
|||||||
])
|
])
|
||||||
->delete();
|
->delete();
|
||||||
|
|
||||||
Menu::query()
|
|
||||||
->where('code', 'like', 'adminapp.%')
|
|
||||||
->delete();
|
|
||||||
|
|
||||||
$allRoleMenuCodes = Menu::query()->pluck('code');
|
$allRoleMenuCodes = Menu::query()->pluck('code');
|
||||||
$adminAppMenuCodes = Menu::query()
|
$adminAppMenuCodes = Menu::query()
|
||||||
->where('code', 'main.adminapp')
|
->where('code', 'main.adminapp')
|
||||||
@@ -348,32 +299,24 @@ class MenuSeeder extends Seeder
|
|||||||
'mutual_smep',
|
'mutual_smep',
|
||||||
];
|
];
|
||||||
$ticketAdminMenuCodes = [
|
$ticketAdminMenuCodes = [
|
||||||
'onticket.adminapp.tickets',
|
'adminapp.tickets',
|
||||||
];
|
];
|
||||||
$fiestaCategoryMenuCodes = [
|
$fiestaCategoryMenuCodes = [
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.entradas',
|
'adminapp.fiesta-futbol-infantil.entradas',
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.alojamientos',
|
'adminapp.fiesta-futbol-infantil.alojamientos',
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.merchandising',
|
'adminapp.fiesta-futbol-infantil.merchandising',
|
||||||
'onticket.adminapp.fiesta-futbol-infantil.comida',
|
'adminapp.fiesta-futbol-infantil.comida',
|
||||||
];
|
];
|
||||||
$fiestaExcludedAdminMenuCodes = [
|
$fiestaExcludedAdminMenuCodes = [
|
||||||
'onticket.adminapp.inicio',
|
'adminapp.inicio',
|
||||||
'onticket.adminapp.catalog',
|
'adminapp.catalog',
|
||||||
'onticket.adminapp.categories',
|
'adminapp.categories',
|
||||||
'onticket.adminapp.combos',
|
'adminapp.combos',
|
||||||
];
|
];
|
||||||
$desfileMenuCodes = [
|
$desfileMenuCodes = [
|
||||||
'onticket.adminapp.desfile.entradas',
|
'adminapp.desfile.entradas',
|
||||||
'onticket.adminapp.desfile.reservas',
|
'adminapp.desfile.reservas',
|
||||||
];
|
];
|
||||||
$shopItAdminMenuCodes = Menu::query()
|
|
||||||
->where('code', 'like', 'shopit.adminapp.%')
|
|
||||||
->pluck('code')
|
|
||||||
->all();
|
|
||||||
$onTicketAdminMenuCodes = Menu::query()
|
|
||||||
->where('code', 'like', 'onticket.adminapp.%')
|
|
||||||
->pluck('code')
|
|
||||||
->all();
|
|
||||||
$onTicketMenuCodes = [
|
$onTicketMenuCodes = [
|
||||||
'event.index',
|
'event.index',
|
||||||
'event.category',
|
'event.category',
|
||||||
@@ -463,13 +406,6 @@ class MenuSeeder extends Seeder
|
|||||||
foreach ($tenants as $tenant) {
|
foreach ($tenants as $tenant) {
|
||||||
$menuCodes = $allMenus;
|
$menuCodes = $allMenus;
|
||||||
|
|
||||||
$menuCodes = array_diff(
|
|
||||||
$menuCodes,
|
|
||||||
$tenant->admin_website_type_code === 'shopit'
|
|
||||||
? $onTicketAdminMenuCodes
|
|
||||||
: $shopItAdminMenuCodes,
|
|
||||||
);
|
|
||||||
|
|
||||||
if ($tenant->codigo === 'sonder') {
|
if ($tenant->codigo === 'sonder') {
|
||||||
// Sonder NO tiene tickets
|
// Sonder NO tiene tickets
|
||||||
$menuCodes = array_diff($menuCodes, ['account.tickets']);
|
$menuCodes = array_diff($menuCodes, ['account.tickets']);
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->websiteTypeService->updateOrCreate(
|
$shopIt = $this->websiteTypeService->updateOrCreate(
|
||||||
['codigo' => 'shopit'],
|
['codigo' => 'shopit'],
|
||||||
[
|
[
|
||||||
'nombre' => 'ShopIt',
|
'nombre' => 'ShopIt',
|
||||||
@@ -48,10 +48,9 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
'scanner_domain' => 'scanner.localhost',
|
'scanner_domain' => 'scanner.localhost',
|
||||||
'site_title' => 'ShopIt',
|
'site_title' => 'ShopIt',
|
||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'primary_color' => '#2FD3AC',
|
'site_logo' => $this->onTicketLogo(),
|
||||||
'site_logo' => $this->shopItLogo(),
|
'footer_logo' => $this->onTicketFooterLogo(),
|
||||||
'footer_logo' => $this->shopItFooterLogo(),
|
'favicon' => $this->onTicketFavicon(),
|
||||||
'favicon' => $this->shopItFavicon(),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -84,7 +83,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
...self::PRESENTATION,
|
...self::PRESENTATION,
|
||||||
'site_logo' => $this->onTicketLogo(),
|
'site_logo' => $this->onTicketLogo(),
|
||||||
'footer_logo' => $this->onTicketFooterLogo(),
|
'footer_logo' => $this->onTicketFooterLogo(),
|
||||||
'favicon' => $this->onTicketFavicon(),
|
'favicon' => $shopIt->favicon()->firstOrFail()->key,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -205,7 +204,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
|
|
||||||
private function onTicketLogo(): UploadedFile
|
private function onTicketLogo(): UploadedFile
|
||||||
{
|
{
|
||||||
$path = public_path('images/website_types/onticket/onticket_logo.png');
|
$path = public_path('images/website_types/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}");
|
||||||
@@ -222,7 +221,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
|
|
||||||
private function onTicketFooterLogo(): UploadedFile
|
private function onTicketFooterLogo(): UploadedFile
|
||||||
{
|
{
|
||||||
$path = public_path('images/website_types/onticket/onticket_footer_logo.png');
|
$path = public_path('images/website_types/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}");
|
||||||
@@ -239,7 +238,7 @@ class WebsiteTypeSeeder extends Seeder
|
|||||||
|
|
||||||
private function onTicketFavicon(): UploadedFile
|
private function onTicketFavicon(): UploadedFile
|
||||||
{
|
{
|
||||||
$path = public_path('images/website_types/onticket/onticket_favicon.svg');
|
$path = public_path('images/website_types/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}");
|
||||||
@@ -253,30 +252,4 @@ 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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 422 B After Width: | Height: | Size: 422 B |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 3.0 KiB |
@@ -1,10 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 1.5 KiB |