Compare commits

..

4 Commits

114 changed files with 422 additions and 4346 deletions

View File

@@ -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';
}

View File

@@ -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')
); );
}); });
}); });

View File

@@ -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,
]);
}
});
} }
} }

View File

@@ -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');
}
}

View File

@@ -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.');
} }
} }

View File

@@ -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;
} }
/** /**

View File

@@ -225,7 +225,7 @@ class StockReservationService
$inventory = $inventories->get($line->inventory_id) $inventory = $inventories->get($line->inventory_id)
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.'); ?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
if ($inventory->reserved_stock < $line->quantity if ($inventory->reserved_stock < $line->quantity
|| ($line->tracks_inventory && $inventory->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.');
} }
} }

View File

@@ -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> */

View File

@@ -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;
}); });

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View File

@@ -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,
];
}
}

View File

@@ -11,9 +11,6 @@ class AdminAppContextService
{ {
$tenant = $user->tenant() $tenant = $user->tenant()
->with([ ->with([
'socialMedia',
'addresses',
'phoneNumbers',
'menues' => fn ($query) => $query 'menues' => fn ($query) => $query
->whereHas( ->whereHas(
'roles', 'roles',

View File

@@ -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'),
] ]

View File

@@ -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();
} }

View File

@@ -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',
]; ];
} }

View File

@@ -1,61 +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 $attributes = ['country_code' => '+54', 'mobile_prefix' => '9'];
protected $fillable = ['label', 'country_code', 'mobile_prefix', 'area_code', 'local_number'];
public function getNumberAttribute(?string $legacyNumber): string
{
if ($this->local_number === null) {
return $legacyNumber ?? '';
}
$areaCode = $this->mobile_prefix && $this->area_code
? '('.$this->area_code.')'
: $this->area_code;
return implode(' ', array_filter([
$this->country_code,
$this->mobile_prefix,
$areaCode,
$this->local_number,
], fn ($part): bool => $part !== null && $part !== ''));
}
public static function dialableNumber(string $number): string
{
return preg_replace('/[^0-9+]/', '', $number);
}
public function telUrl(): string
{
if ($this->country_code === null || $this->local_number === null) {
return 'tel:'.self::dialableNumber($this->number);
}
$areaCode = $this->area_code ?? '';
if ($this->country_code === '+54') {
$areaCode = ltrim($areaCode, '0');
}
return 'tel:'.self::dialableNumber(
$this->country_code.($this->mobile_prefix ?? '').$areaCode.$this->local_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();
}
}

View File

@@ -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);
}
}

View File

@@ -1,24 +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,
'country_code' => $this->country_code,
'mobile_prefix' => $this->mobile_prefix,
'area_code' => $this->area_code,
'local_number' => $this->local_number,
'number' => $this->number,
'tel_url' => $this->resource->telUrl(),
'is_main' => $this->whenPivotLoaded('tenant_phone_numbers', fn () => (bool) $this->pivot->is_main),
];
}
}

View File

@@ -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()));
}
}

View File

@@ -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' => ''];
}
}

View File

@@ -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();
} }
} }

View File

@@ -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],
];
}
}

View File

@@ -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 => [],
};
}
}

View File

@@ -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;
}
}

View File

@@ -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];
}
}

View File

@@ -3,9 +3,7 @@
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\Menu\Models\Menu; use App\Domains\Core\Menu\Models\Menu;
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
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;
use App\Domains\Ticketing\Event\Models\Event; use App\Domains\Ticketing\Event\Models\Event;
@@ -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)
@@ -233,7 +226,7 @@ class TenantResource extends JsonResource
'route' => $menu->route, 'route' => $menu->route,
]; ];
if ($menu->content_type === Menu::CONTENT_TYPE_STATIC && $menu->pivot?->static_content !== null) { if ($menu->pivot?->static_content !== null) {
$formatted['static_content'] = $menu->pivot->static_content; $formatted['static_content'] = $menu->pivot->static_content;
} }

View File

@@ -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];
}
}

View File

@@ -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');
}
}

View File

@@ -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',
]; ];
/** /**

View File

@@ -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.

View File

@@ -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']);

View File

@@ -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) {

View File

@@ -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');

View File

@@ -3,7 +3,6 @@
namespace App\Domains\Ticketing\Event\Models; namespace App\Domains\Ticketing\Event\Models;
use App\Domains\Commerce\Catalog\Models\CatalogItem; use App\Domains\Commerce\Catalog\Models\CatalogItem;
use App\Domains\Core\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;

View File

@@ -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,

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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']);
}); });
} }

View File

@@ -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',

View File

@@ -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

View File

@@ -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'),
) )
); );
} }

View File

@@ -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)

View File

@@ -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'),
) )
); );
} }

View File

@@ -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)

View File

@@ -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'],
]; ];
} }
} }

View File

@@ -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.',
);
}
}
},
];
}
} }

View File

@@ -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)) {

View File

@@ -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'] ?? ''))),

View File

@@ -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.',
);
}
}
}
},
];
}
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -39,9 +39,9 @@ class AccommodationService
/** /**
* @param array<int, array<string, mixed>> $variants * @param array<int, array<string, mixed>> $variants
*/ */
public function upsertMany(Tenant $tenant, array $variants, ?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']],

View File

@@ -43,18 +43,18 @@ class EntryService
* @param array<int, array<string, mixed>> $entries * @param array<int, array<string, mixed>> $entries
* @return Collection<int, CatalogItem> * @return Collection<int, CatalogItem>
*/ */
public function upsertMany(Tenant $tenant, array $entries, ?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',

View File

@@ -11,9 +11,9 @@ use App\Domains\Commerce\Catalog\Models\Inventory;
use App\Domains\Commerce\Catalog\Models\ItemAttribute; use App\Domains\Commerce\Catalog\Models\ItemAttribute;
use App\Domains\Commerce\Catalog\Models\Variant; use App\Domains\Commerce\Catalog\Models\Variant;
use App\Domains\Commerce\Catalog\Services\CatalogService; use App\Domains\Commerce\Catalog\Services\CatalogService;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Ticketing\Event\Enums\EventDateStatus; use App\Domains\Ticketing\Event\Enums\EventDateStatus;
use App\Domains\Ticketing\Event\Models\EventDate; use App\Domains\Ticketing\Event\Models\EventDate;
use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -50,9 +50,9 @@ class FoodService
/** /**
* @param array<int, array<string, mixed>> $variants * @param array<int, array<string, mixed>> $variants
*/ */
public function upsertMany(Tenant $tenant, array $variants, ?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);
} }

View File

@@ -43,9 +43,9 @@ class MerchandiseService
* @param array<int, array<string, mixed>> $items * @param array<int, array<string, mixed>> $items
* @return Collection<int, CatalogItem> * @return Collection<int, CatalogItem>
*/ */
public function upsertMany(Tenant $tenant, array $items, ?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);
} }

View File

@@ -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');
}); });

View File

@@ -228,13 +228,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 +272,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

View File

@@ -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,
]; ];
}); });

View File

@@ -82,7 +82,7 @@ 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.

View File

@@ -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');
}); });

View File

@@ -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);

View File

@@ -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',

View File

@@ -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}");

View File

@@ -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}."
);
}
}
};

View File

@@ -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');
}
};

View File

@@ -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);
}
};

View File

@@ -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(),
],
);
}
};

View File

@@ -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');
});
}
};

View File

@@ -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,
]);
}
};

View File

@@ -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');
}
};

View File

@@ -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.
}
};

View File

@@ -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');
});
}
};

View File

@@ -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.
}
};

View File

@@ -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');
}
};

View File

@@ -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.
}
};

View File

@@ -1,39 +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.contact')->update([
'content_type' => 'dynamic',
'static_content_schema' => null,
]);
// Preserve legacy pivot content for rollback; dynamic menus do not expose it.
}
public function down(): void
{
DB::table('menues')->where('code', 'help.contact')->update([
'content_type' => 'static',
'static_content_schema' => json_encode([
'whatsapp' => 'required|array',
'whatsapp.whatsapp_url' => 'required|url',
'whatsapp.whatsapp_label' => 'required|string',
'phone' => 'required|string',
'locations' => 'required|array|min:1',
'locations.*.label' => 'required|string',
'locations.*.addresses' => 'required|array|min:1',
'locations.*.addresses.*' => 'required|array:label,address,coordinates',
'locations.*.addresses.*.label' => 'required|string',
'locations.*.addresses.*.address' => 'required|string',
'locations.*.addresses.*.coordinates' => 'required|array|size:2',
'locations.*.addresses.*.coordinates.0' => 'required|numeric|between:-90,90',
'locations.*.addresses.*.coordinates.1' => 'required|numeric|between:-180,180',
'map_locations' => 'prohibited',
], JSON_THROW_ON_ERROR),
]);
}
};

View File

@@ -1,94 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('phone_numbers', function (Blueprint $table): void {
$table->string('country_code', 8)->nullable()->default('+54');
$table->string('mobile_prefix', 4)->nullable()->default('9');
$table->string('area_code', 8)->nullable();
$table->string('local_number')->nullable();
});
DB::table('phone_numbers')->orderBy('id')->chunkById(100, function ($numbers): void {
foreach ($numbers as $phone) {
DB::table('phone_numbers')->where('id', $phone->id)
->update($this->components($phone->number));
}
});
Schema::table('phone_numbers', function (Blueprint $table): void {
$table->dropColumn('number');
});
}
public function down(): void
{
Schema::table('phone_numbers', function (Blueprint $table): void {
$table->string('number')->nullable();
});
DB::table('phone_numbers')->orderBy('id')->chunkById(100, function ($numbers): void {
foreach ($numbers as $phone) {
$parts = array_filter([
$phone->country_code,
$phone->mobile_prefix,
$phone->area_code,
$phone->local_number,
], fn ($part): bool => $part !== null && $part !== '');
DB::table('phone_numbers')->where('id', $phone->id)
->update(['number' => implode(' ', $parts)]);
}
});
Schema::table('phone_numbers', function (Blueprint $table): void {
$table->dropColumn(['country_code', 'mobile_prefix', 'area_code', 'local_number']);
});
}
/** @return array{country_code: ?string, mobile_prefix: ?string, area_code: ?string, local_number: string} */
private function components(string $number): array
{
$number = trim($number);
if (preg_match('/^(\+?\d{1,3})\s+(?:(9)\s+)?\(?(\d{2,5})\)?[\s-]+([\d\s-]+)$/', $number, $match)) {
return [
'country_code' => '+'.ltrim($match[1], '+'),
'mobile_prefix' => ($match[2] ?? '') ?: null,
'area_code' => $match[3],
'local_number' => preg_replace('/\D/', '', $match[4]),
];
}
if (preg_match('/^\(?(0\d{2,5})\)?[\s-]+([\d\s-]+)$/', $number, $match)) {
return [
'country_code' => null,
'mobile_prefix' => null,
'area_code' => $match[1],
'local_number' => preg_replace('/\D/', '', $match[2]),
];
}
if (preg_match('/^\+54(9?)(\d+)$/', $number, $match)) {
return [
'country_code' => '+54',
'mobile_prefix' => $match[1] === '9' ? '9' : null,
'area_code' => null,
'local_number' => $match[2],
];
}
return [
'country_code' => null,
'mobile_prefix' => null,
'area_code' => null,
'local_number' => $number,
];
}
};

View File

@@ -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)) {

View File

@@ -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',
@@ -224,8 +182,23 @@ class MenuSeeder extends Seeder
'code' => 'help.contact', 'code' => 'help.contact',
'label' => 'Contacto', 'label' => 'Contacto',
'parent_menu_code' => 'help', 'parent_menu_code' => 'help',
'content_type' => Menu::CONTENT_TYPE_DYNAMIC, 'content_type' => Menu::CONTENT_TYPE_STATIC,
'static_content_schema' => null, 'static_content_schema' => [
'whatsapp' => 'required|array',
'whatsapp.whatsapp_url' => 'required|url',
'whatsapp.whatsapp_label' => 'required|string',
'phone' => 'required|string',
'locations' => 'required|array|min:1',
'locations.*.label' => 'required|string',
'locations.*.addresses' => 'required|array|min:1',
'locations.*.addresses.*' => 'required|array:label,address,coordinates',
'locations.*.addresses.*.label' => 'required|string',
'locations.*.addresses.*.address' => 'required|string',
'locations.*.addresses.*.coordinates' => 'required|array|size:2',
'locations.*.addresses.*.coordinates.0' => 'required|numeric|between:-90,90',
'locations.*.addresses.*.coordinates.1' => 'required|numeric|between:-180,180',
'map_locations' => 'prohibited',
],
'route' => '/ayuda/contacto', 'route' => '/ayuda/contacto',
], ],
[ [
@@ -246,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',
], ],
]; ];
@@ -285,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')
@@ -333,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',
@@ -384,6 +342,30 @@ class MenuSeeder extends Seeder
'is_active' => false, 'is_active' => false,
], ],
]; ];
$contactContent = [
'whatsapp' => [
'whatsapp_url' => 'https://wa.me/543412602222',
'whatsapp_label' => 'Chatea con nosotros',
],
'phone' => '+54 9 (0341) 6658247',
'locations' => [
'rosario' => [
'label' => 'Rosario',
'addresses' => [
[
'label' => 'Gigante de Arroyito',
'address' => 'Av. Génova 640, Rosario',
'coordinates' => [-32.913997, -60.674567],
],
[
'label' => 'Telepagos',
'address' => 'Rioja 1150, piso 12, dpto. 3, Rosario',
'coordinates' => [-32.946820, -60.639320],
],
],
],
],
];
$mutualSmepFrequentlyAskedQuestions = [ $mutualSmepFrequentlyAskedQuestions = [
[ [
'pregunta' => '¿Hay algún límite de compra?', 'pregunta' => '¿Hay algún límite de compra?',
@@ -401,17 +383,29 @@ class MenuSeeder extends Seeder
'is_active' => false, 'is_active' => false,
], ],
]; ];
$mutualSmepContactContent = [
'whatsapp' => [
'whatsapp_url' => 'https://wa.me/5493412474530',
'whatsapp_label' => 'Chateá con Mutual SMEP',
],
'phone' => '+54 9 341 247-4530',
'locations' => [
'rosario' => [
'label' => 'Rosario',
'addresses' => [
[
'label' => 'Mutual SMEP',
'address' => 'San Lorenzo 1543, Rosario, Santa Fe',
'coordinates' => [-32.9431184, -60.6437991],
],
],
],
],
];
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']);
@@ -453,6 +447,11 @@ class MenuSeeder extends Seeder
? $mutualSmepFrequentlyAskedQuestions ? $mutualSmepFrequentlyAskedQuestions
: $frequentlyAskedQuestions, : $frequentlyAskedQuestions,
]); ]);
$tenant->menues()->updateExistingPivot('help.contact', [
'static_content' => $tenant->codigo === 'mutual_smep'
? $mutualSmepContactContent
: $contactContent,
]);
} }
} }
} }

View File

@@ -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);
}
} }

View File

Before

Width:  |  Height:  |  Size: 422 B

After

Width:  |  Height:  |  Size: 422 B

View File

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@@ -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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

View File

@@ -1,163 +0,0 @@
<?php
namespace Tests\Feature\Address;
use App\Domains\Core\Address\Models\Address;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Resources\TenantResource;
use App\Domains\Ticketing\Event\Models\Event;
use App\Domains\Ticketing\Event\Resources\EventResource;
use App\Domains\Ticketing\Event\Resources\PublicEventResource;
use App\Domains\Ticketing\Event\Services\EventAddressService;
use App\Domains\Ticketing\Event\Services\PublicEventService;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class AddressPresentationTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
});
Schema::create('events', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code')->nullable();
$table->string('title')->nullable();
$table->string('subtitle')->nullable();
$table->timestamp('published_at')->nullable();
$table->foreignId('attachment_id')->nullable();
$table->string('location')->nullable();
$table->json('exact_location')->nullable();
$table->timestamps();
});
(require database_path('migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php'))->up();
(require database_path('migrations/2026_09_30_000200_add_address_id_to_events.php'))->up();
}
public function test_tenant_response_exposes_all_addresses_main_address_and_a_separate_event_address(): void
{
DB::table('tenants')->insert(['codigo' => 'acme']);
$tenant = Tenant::where('codigo', 'acme')->firstOrFail();
$tenant->forceFill((new Tenant)->getAttributes());
$principal = Address::create(['label' => 'Principal', 'address_text' => 'Sede 123', 'latitude' => 0, 'longitude' => 0]);
$secondary = Address::create(['label' => 'Sucursal', 'address_text' => 'Otra calle 456']);
$venue = Address::create(['label' => 'Predio', 'address_text' => 'Evento 789', 'latitude' => -32.9431184, 'longitude' => -60.6437991]);
$tenant->addresses()->attach($principal->id, ['is_main' => true]);
$tenant->addresses()->attach($secondary->id);
$tenant->load(['addresses', 'mainAddress']);
foreach (['headerLogo', 'footerLogo', 'favicon', 'headerBackgroundImage', 'footerBackgroundImage', 'storefrontWebsiteType'] as $relation) {
$tenant->setRelation($relation, null);
}
$event = $this->presentedEvent($venue, $tenant);
$tenant->setRelation('activeEvent', $event);
$data = (new TenantResource($tenant))->response()->getData(true)['data'];
$this->assertCount(2, $data['addresses']);
$this->assertTrue($data['addresses'][0]['is_main']);
$this->assertFalse($data['addresses'][1]['is_main']);
$this->assertSame(0, $data['main_address']['latitude']);
$this->assertNull($data['addresses'][1]['latitude']);
$this->assertSame($venue->id, $data['event']['address']['id']);
$this->assertSame(-32.9431184, $data['event']['address']['latitude']);
$this->assertSame('Evento 789', $data['event']['address']['address_text']);
$this->assertSame('Anterior', $data['event']['location']);
$this->assertArrayNotHasKey('is_main', $data['event']['address']);
}
public function test_public_and_admin_event_responses_use_the_same_canonical_address(): void
{
$address = Address::create(['label' => 'Predio', 'address_text' => 'Nueva 123', 'latitude' => 0, 'longitude' => -60.6437991]);
$event = $this->presentedEvent($address, new Tenant);
foreach ([EventResource::class, PublicEventResource::class] as $resource) {
$data = (new $resource($event))->response()->getData(true)['data'];
$this->assertSame('Nueva 123', $data['address']['address_text']);
$this->assertSame(0, $data['address']['latitude']);
$this->assertSame(-60.6437991, $data['address']['longitude']);
$this->assertSame('Anterior', $data['location']);
$this->assertSame(20, $data['exact_location']['longitude']);
}
$event->setRelation('address', null);
$data = (new PublicEventResource($event))->response()->getData(true)['data'];
$this->assertNull($data['address']);
$this->assertSame('Anterior', $data['location']);
$address->update(['latitude' => null, 'longitude' => null]);
$event->setRelation('address', $address);
$data = (new PublicEventResource($event))->response()->getData(true)['data'];
$this->assertNull($data['address']['latitude']);
$this->assertSame(20, $data['exact_location']['longitude']);
}
public function test_editing_event_location_preserves_canonical_coordinates_unless_explicitly_changed(): void
{
$address = Address::create(['label' => 'Predio', 'address_text' => 'Anterior', 'latitude' => 0, 'longitude' => -60]);
$event = Event::create(['address_id' => $address->id, 'location' => 'Nueva', 'exact_location' => ['latitude' => 10, 'longitude' => 20]]);
DB::transaction(fn () => app(EventAddressService::class)->syncLocation($event));
$this->assertSame('Nueva', $address->fresh()->address_text);
$this->assertSame('0.0000000', $address->fresh()->latitude);
$this->assertDatabaseCount('addresses', 1);
$event->exact_location = null;
DB::transaction(fn () => app(EventAddressService::class)->syncLocation($event, true));
$this->assertNull($address->fresh()->latitude);
$this->assertNull($address->fresh()->longitude);
}
public function test_editing_a_shared_event_address_does_not_change_tenant_or_other_event_addresses(): void
{
DB::table('tenants')->insert(['codigo' => 'acme']);
$tenant = Tenant::where('codigo', 'acme')->firstOrFail();
$address = Address::create(['label' => 'Compartida', 'address_text' => 'Original', 'latitude' => -32, 'longitude' => -60]);
$tenant->addresses()->attach($address->id, ['is_main' => true]);
$event = Event::create(['address_id' => $address->id, 'location' => 'Nueva ubicación']);
$other = Event::create(['address_id' => $address->id, 'location' => 'Original']);
DB::transaction(fn () => app(EventAddressService::class)->syncLocation($event));
$this->assertSame('Original', $address->fresh()->address_text);
$this->assertSame($address->id, $other->fresh()->address_id);
$this->assertSame($address->id, $tenant->mainAddress->id);
$this->assertNotSame($address->id, $event->fresh()->address_id);
$this->assertSame('Nueva ubicación', $event->fresh()->address->address_text);
$this->assertSame('-32.0000000', $event->fresh()->address->latitude);
}
public function test_public_event_search_uses_its_address_and_loads_it_for_the_response(): void
{
DB::table('tenants')->insert(['codigo' => 'acme']);
$tenant = Tenant::where('codigo', 'acme')->firstOrFail();
$address = Address::create(['label' => 'Predio', 'address_text' => 'Dirección nueva']);
$event = Event::create([
'tenant_code' => 'acme', 'title' => 'Festival', 'location' => 'Dirección anterior',
'address_id' => $address->id, 'published_at' => now()->subMinute(),
]);
$service = app(PublicEventService::class);
$page = $service->list($tenant, 'nueva', 1);
$this->assertSame($event->id, $page->items()[0]->id);
$this->assertTrue($page->items()[0]->relationLoaded('address'));
$this->assertSame(0, $service->list($tenant, 'anterior', 1)->total());
}
public function test_editing_an_unlinked_event_creates_its_address(): void
{
$event = Event::create(['location' => 'Predio 123', 'exact_location' => ['latitude' => -32, 'longitude' => -60]]);
DB::transaction(fn () => app(EventAddressService::class)->syncLocation($event));
$this->assertSame('Predio 123', $event->fresh()->address->address_text);
$this->assertSame('-60.0000000', $event->fresh()->address->longitude);
$this->assertDatabaseCount('tenant_addresses', 0);
}
private function presentedEvent(?Address $address, Tenant $tenant): Event
{
$event = new Event(['location' => 'Anterior', 'exact_location' => ['latitude' => 10, 'longitude' => 20]]);
$event->setRelation('address', $address)->setRelation('tenant', $tenant)
->setRelation('socialMedia', collect())->setRelation('dates', collect())->setRelation('attachment', null);
return $event;
}
}

View File

@@ -2,13 +2,10 @@
namespace Tests\Feature\Auth; namespace Tests\Feature\Auth;
use App\Domains\Core\Address\Models\Address;
use App\Domains\Core\Auth\Models\User; use App\Domains\Core\Auth\Models\User;
use App\Domains\Core\Authorization\Enums\RoleCode; use App\Domains\Core\Authorization\Enums\RoleCode;
use App\Domains\Core\Authorization\Models\Role; use App\Domains\Core\Authorization\Models\Role;
use App\Domains\Core\Menu\Models\Menu; use App\Domains\Core\Menu\Models\Menu;
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
use App\Domains\Core\Tenant\Models\SocialMedia;
use App\Domains\Core\Tenant\Models\Tenant; use App\Domains\Core\Tenant\Models\Tenant;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\Sanctum;
@@ -49,21 +46,6 @@ class AdminAppMeControllerTest extends TestCase
$tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]); $tenant->menues()->sync([$catalog->code, $staff->code, $storefront->code]);
$otherTenant->menues()->sync([$staff->code]); $otherTenant->menues()->sync([$staff->code]);
$instagram = SocialMedia::query()->create([
'code' => 'instagram', 'name' => 'Instagram', 'icon' => 'fa-brands fa-instagram',
]);
$tenant->socialMedia()->attach($instagram->code, ['url' => 'https://instagram.com/acme', 'orden' => 0]);
$address = Address::query()->create(['label' => 'Principal', 'address_text' => 'San Martín 123']);
$tenant->addresses()->attach($address->id, ['is_main' => true]);
$phone = PhoneNumber::query()->create([
'label' => 'Principal',
'country_code' => '+54',
'mobile_prefix' => '9',
'area_code' => '0341',
'local_number' => '6658247',
]);
$tenant->phoneNumbers()->attach($phone->id, ['is_main' => true]);
$user = User::factory()->create([ $user = User::factory()->create([
'nombre_apellido' => 'Admin Acme', 'nombre_apellido' => 'Admin Acme',
'email' => 'admin@acme.test', 'email' => 'admin@acme.test',
@@ -77,11 +59,6 @@ class AdminAppMeControllerTest extends TestCase
->assertJsonPath('data.user.id', $user->id) ->assertJsonPath('data.user.id', $user->id)
->assertJsonPath('data.user.email', 'admin@acme.test') ->assertJsonPath('data.user.email', 'admin@acme.test')
->assertJsonPath('data.tenant.codigo', 'acme') ->assertJsonPath('data.tenant.codigo', 'acme')
->assertJsonPath('data.tenant.social_media.0.url', 'https://instagram.com/acme')
->assertJsonPath('data.tenant.addresses.0.address_text', 'San Martín 123')
->assertJsonPath('data.tenant.phone_numbers.0.number', '+54 9 (0341) 6658247')
->assertJsonPath('data.tenant.phone_numbers.0.area_code', '0341')
->assertJsonPath('data.tenant.phone_numbers.0.local_number', '6658247')
->assertJsonCount(1, 'data.tenant.menues') ->assertJsonCount(1, 'data.tenant.menues')
->assertJsonPath('data.tenant.menues.0.code', 'admin.catalog') ->assertJsonPath('data.tenant.menues.0.code', 'admin.catalog')
->assertJsonPath('data.tenant.menues.0.label', 'Catálogo') ->assertJsonPath('data.tenant.menues.0.label', 'Catálogo')

View File

@@ -43,7 +43,7 @@ class ScannerMeControllerTest extends TestCase
'route' => '/scanner/scan', 'route' => '/scanner/scan',
]); ]);
$foreign = Menu::query()->create([ $foreign = Menu::query()->create([
'code' => 'onticket.adminapp.inicio', 'code' => 'adminapp.inicio',
'label' => 'Administración', 'label' => 'Administración',
'route' => '/admin/inicio', 'route' => '/admin/inicio',
]); ]);

View File

@@ -186,7 +186,7 @@ class EntryControllerTest extends TestCase
'admin_website_type_code' => 'onticket', 'admin_website_type_code' => 'onticket',
]); ]);
$menu = Menu::query()->firstOrCreate( $menu = Menu::query()->firstOrCreate(
['code' => 'onticket.adminapp.desfile.entradas'], ['code' => 'adminapp.desfile.entradas'],
['label' => 'Entradas', 'route' => '/admin/desfile/entradas'], ['label' => 'Entradas', 'route' => '/admin/desfile/entradas'],
); );
$tenant->menues()->attach($menu->code); $tenant->menues()->attach($menu->code);

View File

@@ -97,8 +97,6 @@ class AccommodationControllerTest extends TestCase
$updated = $this->variantPayload('Casa Rodante Premium', 'Con electricidad', 15, 50000); $updated = $this->variantPayload('Casa Rodante Premium', 'Con electricidad', 15, 50000);
$updated['id'] = $variantId; $updated['id'] = $variantId;
unset($updated['stock']);
$updated['stock_difference'] = -5;
$this->postJson('/api/v1/adminapp/tenant/accommodations', [ $this->postJson('/api/v1/adminapp/tenant/accommodations', [
'variants' => [ 'variants' => [
@@ -236,7 +234,7 @@ class AccommodationControllerTest extends TestCase
if ($withMenu) { if ($withMenu) {
$menu = Menu::query()->firstOrCreate( $menu = Menu::query()->firstOrCreate(
['code' => 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'], ['code' => 'adminapp.fiesta-futbol-infantil.alojamientos'],
['label' => 'Alojamientos', 'route' => '/admin/alojamientos'], ['label' => 'Alojamientos', 'route' => '/admin/alojamientos'],
); );
$tenant->menues()->syncWithoutDetaching([$menu->code]); $tenant->menues()->syncWithoutDetaching([$menu->code]);

View File

@@ -43,10 +43,10 @@ class EntryControllerTest extends TestCase
{ {
$tenant = $this->createFiestaTenant(); $tenant = $this->createFiestaTenant();
$categories = [ $categories = [
'entries' => ['Entradas', 'onticket.adminapp.fiesta-futbol-infantil.entradas'], 'entries' => ['Entradas', 'adminapp.fiesta-futbol-infantil.entradas'],
'foods' => ['Comidas', 'onticket.adminapp.fiesta-futbol-infantil.comida'], 'foods' => ['Comidas', 'adminapp.fiesta-futbol-infantil.comida'],
'accommodations' => ['Alojamientos', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos'], 'accommodations' => ['Alojamientos', 'adminapp.fiesta-futbol-infantil.alojamientos'],
'merchandise' => ['Merchandising', 'onticket.adminapp.fiesta-futbol-infantil.merchandising'], 'merchandise' => ['Merchandising', 'adminapp.fiesta-futbol-infantil.merchandising'],
]; ];
foreach ($categories as [$label, $menuCode]) { foreach ($categories as [$label, $menuCode]) {
@@ -194,7 +194,7 @@ class EntryControllerTest extends TestCase
'title' => 'Abono actualizado', 'title' => 'Abono actualizado',
'description' => 'Ahora incluye ambas fechas', 'description' => 'Ahora incluye ambas fechas',
'event_date_ids' => [$firstDate->id, $secondDate->id], 'event_date_ids' => [$firstDate->id, $secondDate->id],
'stock_difference' => 10, 'stock' => 20,
'price' => 250, 'price' => 250,
], ],
[ [
@@ -268,7 +268,7 @@ class EntryControllerTest extends TestCase
'id' => $entryId, 'id' => $entryId,
'title' => 'Abono editado', 'title' => 'Abono editado',
'event_date_ids' => [$date->id, $otherDate->id], 'event_date_ids' => [$date->id, $otherDate->id],
'stock_difference' => 10, 'stock' => 30,
'price' => 200, 'price' => 200,
]], ]],
])->assertOk() ])->assertOk()
@@ -329,7 +329,7 @@ class EntryControllerTest extends TestCase
'title' => 'Entrada inválida', 'title' => 'Entrada inválida',
'description' => null, 'description' => null,
'event_date_ids' => [$foreignDate->id], 'event_date_ids' => [$foreignDate->id],
'stock_difference' => 0, 'stock' => 10,
'price' => 100, 'price' => 100,
]], ]],
]) ])
@@ -371,7 +371,7 @@ class EntryControllerTest extends TestCase
'title' => 'Entrada inválida', 'title' => 'Entrada inválida',
'description' => null, 'description' => null,
'event_date_ids' => [$eventDate->id], 'event_date_ids' => [$eventDate->id],
'stock_difference' => 0, 'stock' => 10,
'price' => 100, 'price' => 100,
]], ]],
]) ])
@@ -405,7 +405,7 @@ class EntryControllerTest extends TestCase
{ {
$tenant = $this->createTenant('fiesta_futbol_infantil'); $tenant = $this->createTenant('fiesta_futbol_infantil');
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.fiesta-futbol-infantil.entradas', 'code' => 'adminapp.fiesta-futbol-infantil.entradas',
'label' => 'Entradas', 'label' => 'Entradas',
'route' => '/admin/entradas', 'route' => '/admin/entradas',
]); ]);

View File

@@ -124,8 +124,6 @@ class FoodControllerTest extends TestCase
$updated = $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 80, 8500); $updated = $this->variantPayload($secondDate->id, 'Cena', 'Vianda', 80, 8500);
$updated['id'] = $variantId; $updated['id'] = $variantId;
unset($updated['stock']);
$updated['stock_difference'] = -20;
$updated['description'] = 'Cena para llevar'; $updated['description'] = 'Cena para llevar';
$this->postJson('/api/v1/adminapp/tenant/foods', [ $this->postJson('/api/v1/adminapp/tenant/foods', [
@@ -242,7 +240,7 @@ class FoodControllerTest extends TestCase
Inventory::query()->whereKey($replacementInventoryId)->delete(); Inventory::query()->whereKey($replacementInventoryId)->delete();
$updated = $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ $updated = $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [
'variants' => [['id' => $historicalVariantId, 'stock_difference' => -55]], 'variants' => [['id' => $historicalVariantId, 'stock' => 45]],
]) ])
->assertOk() ->assertOk()
->assertJsonPath('data.history.0.variants.0.id', $historicalVariantId) ->assertJsonPath('data.history.0.variants.0.id', $historicalVariantId)
@@ -260,7 +258,7 @@ class FoodControllerTest extends TestCase
]); ]);
$this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [ $this->patchJson('/api/v1/adminapp/tenant/foods/history-stock', [
'variants' => [['id' => $activeVariantId, 'stock_difference' => -60]], 'variants' => [['id' => $activeVariantId, 'stock' => 20]],
]) ])
->assertUnprocessable() ->assertUnprocessable()
->assertJsonValidationErrors(['variants.0.id']); ->assertJsonValidationErrors(['variants.0.id']);
@@ -367,7 +365,7 @@ class FoodControllerTest extends TestCase
'time_end' => '23:59', 'time_end' => '23:59',
]); ]);
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.fiesta-futbol-infantil.comida', 'code' => 'adminapp.fiesta-futbol-infantil.comida',
'label' => 'Comida', 'label' => 'Comida',
'route' => '/admin/comidas', 'route' => '/admin/comidas',
]); ]);

View File

@@ -112,8 +112,6 @@ class MerchandiseControllerTest extends TestCase
$updatedVariant = $this->variantPayload('Blanco', 'M', 80, 12500); $updatedVariant = $this->variantPayload('Blanco', 'M', 80, 12500);
$updatedVariant['id'] = $variantId; $updatedVariant['id'] = $variantId;
unset($updatedVariant['stock']);
$updatedVariant['stock_difference'] = -20;
$updatedItem = $this->itemPayload('Camiseta oficial', 2, [ $updatedItem = $this->itemPayload('Camiseta oficial', 2, [
$updatedVariant, $updatedVariant,
$this->variantPayload('Verde', 'L', 60, 15000), $this->variantPayload('Verde', 'L', 60, 15000),
@@ -236,8 +234,6 @@ class MerchandiseControllerTest extends TestCase
$secondItemVariantId = $created->json('data.1.variants.0.id'); $secondItemVariantId = $created->json('data.1.variants.0.id');
$foreignVariant = $this->variantPayload('Azul Marino', 'XL', 20, 30000); $foreignVariant = $this->variantPayload('Azul Marino', 'XL', 20, 30000);
$foreignVariant['id'] = $secondItemVariantId; $foreignVariant['id'] = $secondItemVariantId;
unset($foreignVariant['stock']);
$foreignVariant['stock_difference'] = 0;
$firstItem = $this->itemPayload('Camiseta', 3, [$foreignVariant]); $firstItem = $this->itemPayload('Camiseta', 3, [$foreignVariant]);
$firstItem['id'] = $firstItemId; $firstItem['id'] = $firstItemId;
@@ -293,7 +289,7 @@ class MerchandiseControllerTest extends TestCase
['value' => 'XL', 'label' => 'XL', 'sort_order' => 4], ['value' => 'XL', 'label' => 'XL', 'sort_order' => 4],
]); ]);
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.fiesta-futbol-infantil.merchandising', 'code' => 'adminapp.fiesta-futbol-infantil.merchandising',
'label' => 'Merchandising', 'label' => 'Merchandising',
'route' => '/admin/merchandising', 'route' => '/admin/merchandising',
]); ]);

View File

@@ -135,7 +135,7 @@ class AdminAppDesfileEntryReservationFormControllerTest extends TestCase
private function grantReservationsMenu(Tenant $tenant): void private function grantReservationsMenu(Tenant $tenant): void
{ {
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.desfile.reservas', 'code' => 'adminapp.desfile.reservas',
'label' => 'Reserva de Tickets', 'label' => 'Reserva de Tickets',
'route' => '/admin/desfile/reservas', 'route' => '/admin/desfile/reservas',
]); ]);

View File

@@ -349,7 +349,7 @@ class AdminAppTicketFilterFormControllerTest extends TestCase
private function grantTicketsMenu(Tenant $tenant): void private function grantTicketsMenu(Tenant $tenant): void
{ {
$menu = Menu::query()->create([ $menu = Menu::query()->create([
'code' => 'onticket.adminapp.tickets', 'code' => 'adminapp.tickets',
'label' => 'Tickets', 'label' => 'Tickets',
'route' => '/admin/tickets', 'route' => '/admin/tickets',
]); ]);

View File

@@ -1,229 +0,0 @@
<?php
namespace Tests\Feature\Migrations;
use App\Domains\Core\Address\Models\Address;
use App\Domains\Core\Address\Models\TenantAddress;
use App\Domains\Core\Tenant\Models\Tenant;
use App\Domains\Core\Tenant\Services\TenantAddressService;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\QueryException;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Tests\TestCase;
class ContactAddressesTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
$table->string('address')->nullable();
$table->timestamps();
});
Schema::create('tenants_menues', function (Blueprint $table): void {
$table->id();
$table->string('tenant_code');
$table->string('menu_code');
$table->json('static_content')->nullable();
$table->boolean('is_enabled')->default(true);
});
DB::table('tenants')->insert([
['codigo' => 'alpha', 'address' => 'Dirección anterior'],
['codigo' => 'beta', 'address' => null],
['codigo' => 'empty', 'address' => null],
]);
$this->schemaMigration()->up();
}
public function test_it_copies_all_contact_locations_without_changing_legacy_data_and_can_run_again(): void
{
$sharedAddress = [
'label' => 'Sede principal',
'address' => 'San Lorenzo 1543, Rosario',
'coordinates' => [-32.9431184, -60.6437991],
];
$this->contact('alpha', [
'rosario' => ['label' => 'Rosario', 'addresses' => [$sharedAddress]],
'santa_fe' => ['label' => 'Santa Fe', 'addresses' => [
['label' => 'Sucursal', 'address' => 'San Martín 100', 'coordinates' => [0, 0]],
['label' => 'Sin mapa', 'address' => 'San Martín 200'],
]],
], enabled: false);
$this->contact('beta', ['rosario' => ['addresses' => [$sharedAddress]]]);
$this->contact('empty', []);
DB::table('tenants_menues')->insert([
'tenant_code' => 'empty',
'menu_code' => 'help.faq',
'static_content' => json_encode(['locations' => [['addresses' => [$sharedAddress]]]]),
]);
$oldMenus = DB::table('tenants_menues')->orderBy('id')->get()->toArray();
$migration = $this->dataMigration();
$migration->up();
$migration->up();
$this->assertDatabaseCount('addresses', 4);
$this->assertDatabaseCount('tenant_addresses', 4);
$this->assertDatabaseHas('addresses', [
'label' => 'Sede principal',
'address_text' => 'San Lorenzo 1543, Rosario',
'latitude' => -32.9431184,
'longitude' => -60.6437991,
]);
$this->assertDatabaseHas('addresses', ['label' => 'Sucursal', 'latitude' => 0, 'longitude' => 0]);
$this->assertDatabaseHas('addresses', ['label' => 'Sin mapa', 'latitude' => null, 'longitude' => null]);
$this->assertEquals($oldMenus, DB::table('tenants_menues')->orderBy('id')->get()->toArray());
$this->assertDatabaseHas('tenants', ['codigo' => 'alpha', 'address' => 'Dirección anterior']);
$alpha = Tenant::query()->where('codigo', 'alpha')->firstOrFail();
$beta = Tenant::query()->where('codigo', 'beta')->firstOrFail();
$this->assertCount(3, $alpha->addresses);
$this->assertTrue($alpha->addresses->first()->pivot->is_main);
$this->assertInstanceOf(TenantAddress::class, $alpha->addresses->first()->pivot);
$this->assertSame('Sede principal', $alpha->mainAddress->label);
$this->assertSame('-32.9431184', $alpha->mainAddress->latitude);
$this->assertNotSame($alpha->mainAddress->id, $beta->mainAddress->id);
$this->assertSame('alpha', $alpha->mainAddress->tenants->sole()->codigo);
$this->assertNull(Tenant::query()->where('codigo', 'empty')->firstOrFail()->mainAddress);
$this->assertSame(2, DB::table('tenant_addresses')->where('is_main', true)->count());
$migration->down();
$this->assertDatabaseCount('addresses', 4);
$this->assertEquals($oldMenus, DB::table('tenants_menues')->orderBy('id')->get()->toArray());
}
public function test_a_tenant_can_have_many_secondary_addresses_but_only_one_main(): void
{
$tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail();
$addresses = $this->addresses(3);
$tenant->addresses()->attach($addresses[0], ['is_main' => true]);
$tenant->addresses()->attach([$addresses[1], $addresses[2]], ['is_main' => false]);
$this->assertCount(3, $tenant->addresses);
$this->expectException(QueryException::class);
$tenant->addresses()->updateExistingPivot($addresses[1], ['is_main' => true]);
}
public function test_it_prevents_duplicate_links(): void
{
$tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail();
$address = $this->addresses(1)[0];
$tenant->addresses()->attach($address);
$this->expectException(QueryException::class);
$tenant->addresses()->attach($address);
}
public function test_main_address_is_specific_to_each_tenant_and_can_be_switched(): void
{
$alpha = Tenant::query()->where('codigo', 'alpha')->firstOrFail();
$beta = Tenant::query()->where('codigo', 'beta')->firstOrFail();
$addresses = $this->addresses(2);
$alpha->addresses()->attach($addresses[0], ['is_main' => true]);
$alpha->addresses()->attach($addresses[1]);
$beta->addresses()->attach($addresses[0], ['is_main' => true]);
$this->assertSame($addresses[0], $alpha->mainAddress->id);
app(TenantAddressService::class)->setMainAddress($alpha, Address::findOrFail($addresses[1]));
$this->assertSame($addresses[1], $alpha->mainAddress->id);
$this->assertSame($addresses[0], $beta->mainAddress->id);
$this->assertSame(2, DB::table('tenant_addresses')->where('is_main', true)->count());
$loaded = Tenant::query()->with('mainAddress')->whereIn('codigo', ['alpha', 'beta'])->get()->keyBy('codigo');
$this->assertSame($addresses[1], $loaded['alpha']->mainAddress->id);
$this->assertSame($addresses[0], $loaded['beta']->mainAddress->id);
}
public function test_switching_to_an_unlinked_address_preserves_the_previous_main(): void
{
$tenant = Tenant::query()->where('codigo', 'alpha')->firstOrFail();
$addresses = $this->addresses(2);
$tenant->addresses()->attach($addresses[0], ['is_main' => true]);
try {
app(TenantAddressService::class)->setMainAddress($tenant, Address::findOrFail($addresses[1]));
$this->fail('An unlinked address must be rejected.');
} catch (ModelNotFoundException) {
$this->assertSame($addresses[0], $tenant->fresh()->mainAddress->id);
}
}
public function test_invalid_coordinates_roll_back_all_copies_and_preserve_source_data(): void
{
$this->contact('alpha', [['addresses' => [
['label' => 'Válida', 'address' => 'Primera 100', 'coordinates' => [-32, -60]],
['label' => 'Inválida', 'address' => 'Segunda 200', 'coordinates' => [-91, -60]],
]]]);
$oldContent = DB::table('tenants_menues')->value('static_content');
try {
$this->dataMigration()->up();
$this->fail('Invalid latitude must not be silently imported.');
} catch (\RuntimeException $exception) {
$this->assertStringContainsString('alpha', $exception->getMessage());
$this->assertDatabaseCount('addresses', 0);
$this->assertDatabaseCount('tenant_addresses', 0);
$this->assertSame($oldContent, DB::table('tenants_menues')->value('static_content'));
}
}
public function test_deleting_a_tenant_removes_links_but_keeps_shared_addresses(): void
{
$address = $this->addresses(1)[0];
foreach (['alpha', 'beta'] as $code) {
Tenant::query()->where('codigo', $code)->firstOrFail()->addresses()->attach($address, ['is_main' => true]);
}
Tenant::query()->where('codigo', 'alpha')->delete();
$this->assertDatabaseCount('tenant_addresses', 1);
$this->assertDatabaseCount('addresses', 1);
Address::findOrFail($address)->delete();
$this->assertDatabaseCount('tenant_addresses', 0);
}
public function test_schema_can_be_rolled_back_without_removing_legacy_tables(): void
{
$this->schemaMigration()->down();
$this->assertFalse(Schema::hasTable('addresses'));
$this->assertFalse(Schema::hasTable('tenant_addresses'));
$this->assertTrue(Schema::hasTable('tenants_menues'));
$this->assertTrue(Schema::hasColumn('tenants', 'address'));
}
private function contact(string $code, array $locations, bool $enabled = true): void
{
DB::table('tenants_menues')->insert([
'tenant_code' => $code,
'menu_code' => 'help.contact',
'static_content' => json_encode(['phone' => '+54 123', 'locations' => $locations], JSON_THROW_ON_ERROR),
'is_enabled' => $enabled,
]);
}
private function addresses(int $count): array
{
$ids = [];
for ($i = 0; $i < $count; $i++) {
$ids[] = Address::create(['label' => "Sucursal {$i}", 'address_text' => "Calle {$i}"])->id;
}
return $ids;
}
private function schemaMigration(): Migration
{
return require database_path('migrations/2026_09_30_000000_create_addresses_and_tenant_addresses_tables.php');
}
private function dataMigration(): Migration
{
return require database_path('migrations/2026_09_30_000100_copy_contact_menu_addresses.php');
}
}

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