Compare commits
9 Commits
feature/sh
...
09faa22920
| Author | SHA1 | Date | |
|---|---|---|---|
| 09faa22920 | |||
| bebf6a7ed5 | |||
| 86ca57a3ad | |||
| e64393812c | |||
| 655364bfec | |||
| 55c28f33ef | |||
| 5a0ce08adb | |||
| a770d435eb | |||
| 44bf67bd12 |
@@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Requests\AdminApp\UpdateCategoryConfigurationRequest;
|
||||
use App\Domains\Commerce\Catalog\Resources\AdminApp\CategoryConfigurationResource;
|
||||
use App\Domains\Commerce\Catalog\Services\CategoryConfigurationService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class CategoryConfigurationController extends Controller
|
||||
{
|
||||
public function index(Request $request, CategoryConfigurationService $service): AnonymousResourceCollection
|
||||
{
|
||||
return CategoryConfigurationResource::collection(
|
||||
$service->forTenant($request->user()->tenant()->firstOrFail())
|
||||
);
|
||||
}
|
||||
|
||||
public function update(UpdateCategoryConfigurationRequest $request, CategoryConfigurationService $service): AnonymousResourceCollection
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
$service->update($tenant, $request->validated());
|
||||
|
||||
return CategoryConfigurationResource::collection($service->forTenant($tenant));
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ use App\Domains\Commerce\Catalog\Resources\CatalogFeaturedGroupResource;
|
||||
use App\Domains\Commerce\Catalog\Resources\CatalogItemDetailResource;
|
||||
use App\Domains\Commerce\Catalog\Resources\CatalogSearchItemResource;
|
||||
use App\Domains\Commerce\Catalog\Resources\CatalogVariantOptionsResource;
|
||||
use App\Domains\Commerce\Catalog\Resources\CategoryResource;
|
||||
use App\Domains\Commerce\Catalog\Services\CatalogItemAllowanceService;
|
||||
use App\Domains\Commerce\Catalog\Services\CatalogService;
|
||||
use App\Domains\Commerce\Catalog\Services\FeaturedGroupService;
|
||||
@@ -79,7 +78,11 @@ class CatalogController extends Controller
|
||||
$allowances->attach($items->getCollection(), $this->userId($request));
|
||||
|
||||
return CatalogSearchItemResource::collection($items)->additional([
|
||||
'category' => CategoryResource::make($category)->resolve($request),
|
||||
'category' => [
|
||||
'id' => $category->id,
|
||||
'nombre' => $category->nombre,
|
||||
'categoria_id' => $category->categoria_id,
|
||||
],
|
||||
'layout' => $tenant->search_product_layout->value,
|
||||
'group_layout' => $tenant->search_group_layout->value,
|
||||
]);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Enums;
|
||||
|
||||
enum InventoryMovementOperation: string
|
||||
{
|
||||
case StockInitialized = 'stock_initialized';
|
||||
case StockIncreased = 'stock_increased';
|
||||
case StockDecreased = 'stock_decreased';
|
||||
case StockReserved = 'stock_reserved';
|
||||
case StockReleased = 'stock_released';
|
||||
case PurchaseCommitted = 'purchase_committed';
|
||||
case StockRefunded = 'stock_refunded';
|
||||
case EntryStockReserved = 'entry_stock_reserved';
|
||||
case EntryStockReleased = 'entry_stock_released';
|
||||
case InventoryReset = 'inventory_reset';
|
||||
case InventoryTransferred = 'inventory_transferred';
|
||||
}
|
||||
@@ -19,7 +19,6 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
@@ -214,14 +213,10 @@ class CatalogItem extends Model
|
||||
/** @param Builder<CatalogItem> $query */
|
||||
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 {
|
||||
$query->whereNull('catalog_items.sales_end_at')
|
||||
->orWhere('catalog_items.sales_end_at', '>', now());
|
||||
})->where(function (Builder $query) use ($availableInventory): void {
|
||||
})->where(function (Builder $query): void {
|
||||
$query
|
||||
->where(function (Builder $unlimitedQuery): void {
|
||||
$unlimitedQuery
|
||||
@@ -241,18 +236,20 @@ class CatalogItem extends Model
|
||||
->whereNull('replaced_by_variant_id')
|
||||
->whereHas(
|
||||
'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
|
||||
->whereDoesntHave('variants')
|
||||
->where(function (Builder $inventoryQuery) use ($availableInventory): void {
|
||||
->where(function (Builder $inventoryQuery): void {
|
||||
$inventoryQuery
|
||||
->whereNull('catalog_items.inventory_id')
|
||||
->orWhereHas(
|
||||
'inventory',
|
||||
$availableInventory
|
||||
fn (Builder $availableInventoryQuery): Builder => $availableInventoryQuery
|
||||
->whereRaw('inventories.real_stock > inventories.reserved_stock + inventories.entry_reserved_stock')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Domains\Commerce\Catalog\Models;
|
||||
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
@@ -17,9 +16,6 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
'categoria_id',
|
||||
'nombre',
|
||||
'is_enabled',
|
||||
'banner_id',
|
||||
'banner_enabled',
|
||||
'sort_order',
|
||||
])]
|
||||
class Category extends Model
|
||||
{
|
||||
@@ -29,7 +25,6 @@ class Category extends Model
|
||||
|
||||
protected $attributes = [
|
||||
'is_enabled' => true,
|
||||
'banner_enabled' => false,
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -40,9 +35,6 @@ class Category extends Model
|
||||
return [
|
||||
'categoria_id' => 'integer',
|
||||
'is_enabled' => 'boolean',
|
||||
'banner_id' => 'integer',
|
||||
'banner_enabled' => 'boolean',
|
||||
'sort_order' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -67,12 +59,6 @@ class Category extends Model
|
||||
return $this->belongsTo(self::class, 'categoria_id');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Attachment, $this> */
|
||||
public function banner(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Attachment::class, 'banner_id');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return HasMany<Category, $this>
|
||||
*/
|
||||
|
||||
@@ -2,17 +2,11 @@
|
||||
|
||||
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\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
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([
|
||||
'sold_units',
|
||||
@@ -44,7 +38,6 @@ class Inventory extends Model
|
||||
'reserved_stock' => 'integer',
|
||||
'entry_reserved_stock' => 'integer',
|
||||
'real_stock' => 'integer',
|
||||
'available_stock' => 'integer',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -66,295 +59,70 @@ class Inventory extends Model
|
||||
return $this->hasMany(StockReservationLine::class);
|
||||
}
|
||||
|
||||
/** @return HasMany<InventoryMovement, $this> */
|
||||
public function movements(): HasMany
|
||||
{
|
||||
return $this->hasMany(InventoryMovement::class);
|
||||
}
|
||||
|
||||
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 ?? 0));
|
||||
}
|
||||
|
||||
return (int) $this->getAttribute('available_stock');
|
||||
return max(0, $this->real_stock - $this->reserved_stock - $this->entry_reserved_stock);
|
||||
}
|
||||
|
||||
public function reserveEntry(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::EntryStockReserved,
|
||||
['entry_reserved_stock' => $amount],
|
||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
||||
if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) {
|
||||
if ($amount < 1 || ($tracksInventory && $this->availableStock() < $amount)) {
|
||||
throw new \InvalidArgumentException('No hay stock disponible para la reserva de entradas.');
|
||||
}
|
||||
},
|
||||
);
|
||||
$this->entry_reserved_stock += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function releaseEntry(int $amount): void
|
||||
{
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::EntryStockReleased,
|
||||
['entry_reserved_stock' => -$amount],
|
||||
function (self $inventory) use ($amount): void {
|
||||
if ($amount < 1 || $inventory->entry_reserved_stock < $amount) {
|
||||
if ($amount < 1 || $this->entry_reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad de entradas reservadas no es válida.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$this->entry_reserved_stock -= $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function reserve(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::StockReserved,
|
||||
['reserved_stock' => $amount],
|
||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
||||
if ($amount < 0) {
|
||||
throw new \InvalidArgumentException('La cantidad a reservar debe ser positiva.');
|
||||
}
|
||||
if ($tracksInventory && $inventory->availableStock() < $amount) {
|
||||
|
||||
if ($tracksInventory && $this->availableStock() < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock disponible para reservar.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$this->reserved_stock += $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function release(int $amount): void
|
||||
{
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::StockReleased,
|
||||
['reserved_stock' => -$amount],
|
||||
function (self $inventory) use ($amount): void {
|
||||
if ($amount < 0 || $inventory->reserved_stock < $amount) {
|
||||
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no es válida.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
$this->reserved_stock -= $amount;
|
||||
$this->save();
|
||||
}
|
||||
|
||||
public function buy(int $amount, bool $tracksInventory): void
|
||||
{
|
||||
$counterDeltas = ['reserved_stock' => -$amount, 'sold_units' => $amount];
|
||||
if (! array_key_exists('available_stock', $this->attributes) && $tracksInventory) {
|
||||
$counterDeltas['real_stock'] = -$amount;
|
||||
}
|
||||
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::PurchaseCommitted,
|
||||
$counterDeltas,
|
||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
||||
if ($amount < 0 || $inventory->reserved_stock < $amount) {
|
||||
if ($amount < 0 || $this->reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('La cantidad reservada no alcanza para confirmar la compra.');
|
||||
}
|
||||
if ($tracksInventory && $inventory->availableStock() < 0) {
|
||||
|
||||
if ($tracksInventory && $this->real_stock - $this->entry_reserved_stock < $amount) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock real para confirmar la compra.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if ($tracksInventory) {
|
||||
$this->real_stock -= $amount;
|
||||
}
|
||||
|
||||
public function adjustAvailableStock(
|
||||
int $delta,
|
||||
?User $responsibleUser = null,
|
||||
?string $idempotencyKey = null,
|
||||
): void {
|
||||
if ($delta === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
$operation = $delta > 0
|
||||
? InventoryMovementOperation::StockIncreased
|
||||
: InventoryMovementOperation::StockDecreased;
|
||||
|
||||
$this->mutate(
|
||||
$operation,
|
||||
['real_stock' => $delta],
|
||||
function (self $inventory) use ($delta): void {
|
||||
if ($inventory->real_stock + $delta < 0 || $inventory->availableStock() + $delta < 0) {
|
||||
throw ValidationException::withMessages([
|
||||
'stock_difference' => ['El ajuste dejaría el stock en un valor inválido.'],
|
||||
]);
|
||||
}
|
||||
},
|
||||
$responsibleUser,
|
||||
$idempotencyKey,
|
||||
);
|
||||
}
|
||||
|
||||
public function refundStock(int $amount, ?User $responsibleUser = null): void
|
||||
{
|
||||
$counterDeltas = ['refunded_units' => $amount];
|
||||
if (! array_key_exists('available_stock', $this->attributes)) {
|
||||
$counterDeltas['real_stock'] = $amount;
|
||||
}
|
||||
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::StockRefunded,
|
||||
$counterDeltas,
|
||||
function () use ($amount): void {
|
||||
if ($amount < 1) {
|
||||
throw new \InvalidArgumentException('La cantidad devuelta debe ser positiva.');
|
||||
}
|
||||
},
|
||||
$responsibleUser,
|
||||
);
|
||||
}
|
||||
|
||||
public function commitDirectSale(int $amount, bool $tracksInventory = true): void
|
||||
{
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::PurchaseCommitted,
|
||||
['sold_units' => $amount],
|
||||
function (self $inventory) use ($amount, $tracksInventory): void {
|
||||
if ($amount < 1 || ($tracksInventory && $inventory->availableStock() < $amount)) {
|
||||
throw new \InvalidArgumentException('No hay suficiente stock para confirmar la compra.');
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public function recordInitialization(?User $responsibleUser = null): void
|
||||
{
|
||||
$this->recordCurrentState(InventoryMovementOperation::StockInitialized, $responsibleUser);
|
||||
}
|
||||
|
||||
public function recordTransferInitialization(?User $responsibleUser = null): void
|
||||
{
|
||||
$this->recordCurrentState(InventoryMovementOperation::InventoryTransferred, $responsibleUser);
|
||||
}
|
||||
|
||||
/** @param array<string, int> $counterDeltas */
|
||||
public function transferCounters(array $counterDeltas, ?User $responsibleUser = null): void
|
||||
{
|
||||
$counterDeltas = array_filter($counterDeltas, fn (int $delta): bool => $delta !== 0);
|
||||
if ($counterDeltas === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::InventoryTransferred,
|
||||
$counterDeltas,
|
||||
function (self $inventory) use ($counterDeltas): void {
|
||||
foreach ($counterDeltas as $counter => $delta) {
|
||||
if ((int) $inventory->getAttribute($counter) + $delta < 0) {
|
||||
throw new \LogicException("El traslado dejaría {$counter} en un valor negativo.");
|
||||
}
|
||||
}
|
||||
},
|
||||
$responsibleUser,
|
||||
);
|
||||
}
|
||||
|
||||
private function recordCurrentState(
|
||||
InventoryMovementOperation $operation,
|
||||
?User $responsibleUser,
|
||||
): void {
|
||||
if (! Schema::hasTable('inventory_movements')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->refresh();
|
||||
$counterDeltas = array_filter([
|
||||
'real_stock' => $this->real_stock,
|
||||
'sold_units' => $this->sold_units,
|
||||
'reserved_stock' => $this->reserved_stock,
|
||||
'entry_reserved_stock' => $this->entry_reserved_stock,
|
||||
'refunded_units' => $this->refunded_units,
|
||||
], fn (int $delta): bool => $delta !== 0);
|
||||
|
||||
$this->movements()->create([
|
||||
'operation' => $operation,
|
||||
'available_stock_delta' => $this->availableStock(),
|
||||
'available_stock_before' => 0,
|
||||
'available_stock_after' => $this->availableStock(),
|
||||
'counter_deltas' => $counterDeltas,
|
||||
'responsible_user_id' => $responsibleUser?->getKey() ?? Auth::id(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function resetTransactionCounters(?User $responsibleUser = null): void
|
||||
{
|
||||
$this->refresh();
|
||||
$counterDeltas = array_filter([
|
||||
'sold_units' => -$this->sold_units,
|
||||
'reserved_stock' => -$this->reserved_stock,
|
||||
'entry_reserved_stock' => -$this->entry_reserved_stock,
|
||||
'refunded_units' => -$this->refunded_units,
|
||||
], fn (int $delta): bool => $delta !== 0);
|
||||
|
||||
if ($counterDeltas === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->mutate(
|
||||
InventoryMovementOperation::InventoryReset,
|
||||
$counterDeltas,
|
||||
static function (): void {},
|
||||
$responsibleUser,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, int> $counterDeltas
|
||||
* @param callable(self): void $validate
|
||||
*/
|
||||
private function mutate(
|
||||
InventoryMovementOperation $operation,
|
||||
array $counterDeltas,
|
||||
callable $validate,
|
||||
?User $responsibleUser = null,
|
||||
?string $idempotencyKey = null,
|
||||
): void {
|
||||
DB::transaction(function () use ($operation, $counterDeltas, $validate, $responsibleUser, $idempotencyKey): void {
|
||||
/** @var self $inventory */
|
||||
$inventory = self::query()->lockForUpdate()->findOrFail($this->getKey());
|
||||
$this->setRawAttributes($inventory->getAttributes(), true);
|
||||
|
||||
$recordsMovements = Schema::hasTable('inventory_movements');
|
||||
if ($recordsMovements && $idempotencyKey !== null) {
|
||||
$existing = $this->movements()
|
||||
->where('idempotency_key', $idempotencyKey)
|
||||
->first();
|
||||
if ($existing !== null) {
|
||||
if ($existing->counter_deltas !== $counterDeltas) {
|
||||
throw ValidationException::withMessages([
|
||||
'stock_adjustment_id' => ['La operación ya fue utilizada con otro ajuste.'],
|
||||
]);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
$validate($this);
|
||||
$counterDeltas = array_filter($counterDeltas, fn (int $delta): bool => $delta !== 0);
|
||||
if ($counterDeltas === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$availableBefore = $this->availableStock();
|
||||
foreach ($counterDeltas as $counter => $delta) {
|
||||
$this->setAttribute($counter, (int) $this->getAttribute($counter) + $delta);
|
||||
}
|
||||
$this->reserved_stock -= $amount;
|
||||
$this->sold_units += $amount;
|
||||
$this->save();
|
||||
$this->refresh();
|
||||
|
||||
$availableAfter = $this->availableStock();
|
||||
if ($recordsMovements) {
|
||||
$this->movements()->create([
|
||||
'operation' => $operation,
|
||||
'available_stock_delta' => $availableAfter - $availableBefore,
|
||||
'available_stock_before' => $availableBefore,
|
||||
'available_stock_after' => $availableAfter,
|
||||
'counter_deltas' => $counterDeltas,
|
||||
'responsible_user_id' => $responsibleUser?->getKey() ?? Auth::id(),
|
||||
'idempotency_key' => $idempotencyKey,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Models;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Enums\InventoryMovementOperation;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'inventory_id',
|
||||
'operation',
|
||||
'available_stock_delta',
|
||||
'available_stock_before',
|
||||
'available_stock_after',
|
||||
'counter_deltas',
|
||||
'responsible_user_id',
|
||||
'idempotency_key',
|
||||
])]
|
||||
class InventoryMovement extends Model
|
||||
{
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'operation' => InventoryMovementOperation::class,
|
||||
'available_stock_delta' => 'integer',
|
||||
'available_stock_before' => 'integer',
|
||||
'available_stock_after' => 'integer',
|
||||
'counter_deltas' => 'array',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Inventory, $this> */
|
||||
public function inventory(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Inventory::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<User, $this> */
|
||||
public function responsibleUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'responsible_user_id');
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Requests\AdminApp;
|
||||
|
||||
use App\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Validator as ValidationValidator;
|
||||
|
||||
class UpdateCategoryConfigurationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'categories' => ['present', 'array', 'max:500'],
|
||||
'deleted_ids' => ['present', 'array', 'max:1000'],
|
||||
'deleted_ids.*' => ['required', 'integer', 'min:1', 'distinct'],
|
||||
];
|
||||
}
|
||||
|
||||
public function withValidator(ValidationValidator $validator): void
|
||||
{
|
||||
$validator->after(function (ValidationValidator $validator): void {
|
||||
if ($validator->errors()->isNotEmpty()) {
|
||||
return;
|
||||
}
|
||||
$this->validateNodes($validator, $this->input('categories'), 'categories', 0);
|
||||
});
|
||||
}
|
||||
|
||||
private function validateNodes(ValidationValidator $validator, array $nodes, string $path, int $depth): void
|
||||
{
|
||||
if ($depth > 10) {
|
||||
$validator->errors()->add($path, 'La jerarquía de categorías es demasiado profunda.');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($nodes as $index => $node) {
|
||||
$key = "{$path}.{$index}";
|
||||
if (! is_array($node)) {
|
||||
$validator->errors()->add($key, 'La categoría debe ser un objeto.');
|
||||
|
||||
continue;
|
||||
}
|
||||
$nested = Validator::make($node, [
|
||||
'id' => ['present', 'nullable', 'integer', 'min:1'],
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'is_enabled' => ['required', 'boolean'],
|
||||
'banner_enabled' => ['required', 'boolean'],
|
||||
'banner' => ['present', 'nullable', new ImageOrBase64Rule],
|
||||
'subcategories' => ['present', 'array', 'max:500'],
|
||||
]);
|
||||
foreach ($nested->errors()->messages() as $field => $messages) {
|
||||
foreach ($messages as $message) {
|
||||
$validator->errors()->add("{$key}.{$field}", $message);
|
||||
}
|
||||
}
|
||||
if (! $nested->fails()) {
|
||||
$this->validateNodes($validator, $node['subcategories'], "{$key}.subcategories", $depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Category */
|
||||
class CategoryConfigurationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'nombre' => $this->nombre,
|
||||
'is_enabled' => $this->is_enabled,
|
||||
'banner_enabled' => $this->banner_enabled,
|
||||
'banner_key' => $this->banner?->key,
|
||||
'banner_name' => $this->banner?->filename,
|
||||
'banner_url' => $this->banner?->getTemporaryUrl(1440),
|
||||
'subcategories' => self::collection($this->subCategories),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Resources;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Category */
|
||||
class CategoryResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'nombre' => $this->nombre,
|
||||
'categoria_id' => $this->categoria_id,
|
||||
'banner_url' => $this->banner_enabled
|
||||
? $this->banner?->getTemporaryUrl(1440)
|
||||
: null,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -64,7 +64,6 @@ class CatalogInventoryService
|
||||
$selection->loadMissing('variants.inventory');
|
||||
|
||||
return $selection->variants
|
||||
->each(fn (Variant $variant) => $variant->setRelation('catalogItem', $selection))
|
||||
->filter(fn (Variant $variant): bool => $variant->isSellable())
|
||||
->unique(fn (Variant $variant): string => $variant->inventory_id === null
|
||||
? 'object:'.spl_object_id($variant->inventory)
|
||||
@@ -154,7 +153,7 @@ class CatalogInventoryService
|
||||
|
||||
if ($operation === 'commit'
|
||||
&& $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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -370,12 +370,9 @@ class CatalogService
|
||||
|
||||
private function createInventory(int $realStock): Inventory
|
||||
{
|
||||
$inventory = Inventory::query()->create([
|
||||
return Inventory::query()->create([
|
||||
'real_stock' => $realStock,
|
||||
]);
|
||||
$inventory->recordInitialization();
|
||||
|
||||
return $inventory;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Commerce\Catalog\Services;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
use App\Domains\Commerce\Catalog\Models\FeaturedGroup;
|
||||
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\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class CategoryConfigurationService
|
||||
{
|
||||
public function __construct(private readonly AttachmentService $attachments) {}
|
||||
|
||||
/** @return Collection<int, Category> */
|
||||
public function forTenant(Tenant $tenant): Collection
|
||||
{
|
||||
$all = $tenant->categories()->with('banner')->orderBy('sort_order')->orderBy('id')->get();
|
||||
$children = $all->groupBy('categoria_id');
|
||||
foreach ($all as $category) {
|
||||
$category->setRelation('subCategories', $children->get($category->id, collect()));
|
||||
}
|
||||
|
||||
return $all->filter(fn (Category $category) => $category->categoria_id === null)->values();
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function update(Tenant $tenant, array $data): void
|
||||
{
|
||||
$created = [];
|
||||
try {
|
||||
DB::transaction(function () use ($tenant, $data, &$created): void {
|
||||
// Serialize saves for this tenant, including creation of new categories.
|
||||
Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail();
|
||||
$existing = $tenant->categories()->with('banner')->lockForUpdate()->get()->keyBy('id');
|
||||
$deleted = array_map('intval', $data['deleted_ids']);
|
||||
$seen = [];
|
||||
$allowedBannerKeys = $existing->pluck('banner.key')->filter()->all();
|
||||
$this->validateNodes($data['categories'], $existing, $deleted, $seen, $allowedBannerKeys);
|
||||
|
||||
foreach ($deleted as $id) {
|
||||
$category = $existing->get($id);
|
||||
if ($category === null) {
|
||||
$this->invalid('deleted_ids', 'Una categoría a eliminar no pertenece a esta tienda.');
|
||||
}
|
||||
if ($category->catalogItems()->withTrashed()->exists()
|
||||
|| FeaturedGroup::query()->where('category_id', $id)->exists()) {
|
||||
$this->invalid('deleted_ids', "No se puede eliminar «{$category->nombre}» porque tiene productos o grupos destacados asociados. Podés ocultarla.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->saveNodes($tenant, $data['categories'], null, $existing, $created);
|
||||
$remaining = $tenant->categories()->whereNotIn('id', $deleted)->get();
|
||||
$parents = $remaining->pluck('categoria_id', 'id');
|
||||
foreach ($remaining as $category) {
|
||||
$visited = [$category->id => true];
|
||||
$parentId = $category->categoria_id;
|
||||
while ($parentId !== null) {
|
||||
if (isset($visited[$parentId]) || ! $parents->has($parentId)) {
|
||||
$this->invalid('categories', 'La jerarquía contiene un ciclo o una categoría padre eliminada.');
|
||||
}
|
||||
$visited[$parentId] = true;
|
||||
$parentId = $parents->get($parentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Detach only explicitly deleted nodes before removing parents and children together.
|
||||
$tenant->categories()->whereIn('id', $deleted)->update(['categoria_id' => null]);
|
||||
$tenant->categories()->whereIn('id', $deleted)->delete();
|
||||
});
|
||||
} catch (Throwable $error) {
|
||||
foreach ($created as $attachment) {
|
||||
Storage::disk('s3')->delete($attachment->path);
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
private function validateNodes(array $nodes, Collection $existing, array $deleted, array &$seen, array $bannerKeys, string $path = 'categories'): void
|
||||
{
|
||||
foreach ($nodes as $index => $node) {
|
||||
$field = "{$path}.{$index}";
|
||||
if ($node['id'] !== null) {
|
||||
$id = (int) $node['id'];
|
||||
if (! $existing->has($id) || isset($seen[$id]) || in_array($id, $deleted, true)) {
|
||||
$this->invalid("{$field}.id", 'La categoría es ajena a la tienda, está repetida o fue marcada para eliminar.');
|
||||
}
|
||||
$seen[$id] = true;
|
||||
}
|
||||
if (trim($node['nombre']) === '') {
|
||||
$this->invalid("{$field}.nombre", 'Completá el nombre de la categoría.');
|
||||
}
|
||||
if (is_string($node['banner']) && Str::isUuid($node['banner'])
|
||||
&& ! in_array($node['banner'], $bannerKeys, true)) {
|
||||
$this->invalid("{$field}.banner", 'El banner no pertenece a las categorías de esta tienda.');
|
||||
}
|
||||
$this->validateNodes($node['subcategories'], $existing, $deleted, $seen, $bannerKeys, "{$field}.subcategories");
|
||||
}
|
||||
}
|
||||
|
||||
private function saveNodes(Tenant $tenant, array $nodes, ?int $parentId, Collection $existing, array &$created): void
|
||||
{
|
||||
foreach (array_values($nodes) as $order => $node) {
|
||||
$category = $node['id'] === null ? new Category : $existing->get((int) $node['id']);
|
||||
$banner = null;
|
||||
if ($node['banner'] !== null) {
|
||||
if (is_string($node['banner']) && Str::isUuid($node['banner'])) {
|
||||
$banner = Attachment::query()->where('key', $node['banner'])->firstOrFail();
|
||||
if ($banner->type !== AttachmentType::Image) {
|
||||
$this->invalid('categories', 'El banner debe ser una imagen.');
|
||||
}
|
||||
} else {
|
||||
$banner = $this->attachments->store($node['banner'], "tenants/{$tenant->codigo}/categories");
|
||||
$created[] = $banner;
|
||||
}
|
||||
}
|
||||
$category->fill([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'categoria_id' => $parentId,
|
||||
'nombre' => trim($node['nombre']),
|
||||
'is_enabled' => $node['is_enabled'],
|
||||
'banner_id' => $banner?->id,
|
||||
'banner_enabled' => $node['banner_enabled'],
|
||||
'sort_order' => $order,
|
||||
])->save();
|
||||
$this->saveNodes($tenant, $node['subcategories'], $category->id, $existing, $created);
|
||||
}
|
||||
}
|
||||
|
||||
private function invalid(string $field, string $message): never
|
||||
{
|
||||
throw ValidationException::withMessages([$field => $message]);
|
||||
}
|
||||
}
|
||||
@@ -225,7 +225,7 @@ class StockReservationService
|
||||
$inventory = $inventories->get($line->inventory_id)
|
||||
?? throw new \InvalidArgumentException('No se encontró el inventario reservado.');
|
||||
if ($inventory->reserved_stock < $line->quantity
|
||||
|| ($line->tracks_inventory && $inventory->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.');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -194,7 +194,6 @@ class VariantReplacementService
|
||||
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
|
||||
'real_stock' => $sourceInventory->real_stock,
|
||||
]);
|
||||
$replacementInventory->recordTransferInitialization();
|
||||
|
||||
if ($activeLines->isNotEmpty()) {
|
||||
StockReservationLine::query()
|
||||
@@ -203,10 +202,7 @@ class VariantReplacementService
|
||||
}
|
||||
EntryReservation::query()->where('inventory_id', $sourceInventory->id)
|
||||
->update(['inventory_id' => $replacementInventory->id]);
|
||||
$sourceInventory->transferCounters([
|
||||
'reserved_stock' => -$sourceInventory->reserved_stock,
|
||||
'entry_reserved_stock' => -$sourceInventory->entry_reserved_stock,
|
||||
]);
|
||||
$sourceInventory->update(['reserved_stock' => 0, 'entry_reserved_stock' => 0]);
|
||||
|
||||
return $replacementInventory;
|
||||
}
|
||||
@@ -240,14 +236,13 @@ class VariantReplacementService
|
||||
throw new \LogicException('El inventario reservado de la variante es inconsistente.');
|
||||
}
|
||||
|
||||
$transferredCounters = [
|
||||
'real_stock' => $sourceInventory->real_stock,
|
||||
'reserved_stock' => $sourceInventory->reserved_stock,
|
||||
'entry_reserved_stock' => $sourceInventory->entry_reserved_stock,
|
||||
'sold_units' => $sourceInventory->sold_units,
|
||||
'refunded_units' => $sourceInventory->refunded_units,
|
||||
];
|
||||
$destinationInventory->transferCounters($transferredCounters);
|
||||
$destinationInventory->update([
|
||||
'real_stock' => $destinationInventory->real_stock + $sourceInventory->real_stock,
|
||||
'reserved_stock' => $destinationInventory->reserved_stock + $sourceInventory->reserved_stock,
|
||||
'entry_reserved_stock' => $destinationInventory->entry_reserved_stock + $sourceInventory->entry_reserved_stock,
|
||||
'sold_units' => $destinationInventory->sold_units + $sourceInventory->sold_units,
|
||||
'refunded_units' => $destinationInventory->refunded_units + $sourceInventory->refunded_units,
|
||||
]);
|
||||
if ($activeLines->isNotEmpty()) {
|
||||
StockReservationLine::query()
|
||||
->whereKey($activeLines->modelKeys())
|
||||
@@ -255,10 +250,13 @@ class VariantReplacementService
|
||||
}
|
||||
EntryReservation::query()->where('inventory_id', $sourceInventory->id)
|
||||
->update(['inventory_id' => $destinationInventory->id]);
|
||||
$sourceInventory->transferCounters(array_map(
|
||||
fn (int $value): int => -$value,
|
||||
$transferredCounters,
|
||||
));
|
||||
$sourceInventory->update([
|
||||
'real_stock' => 0,
|
||||
'reserved_stock' => 0,
|
||||
'entry_reserved_stock' => 0,
|
||||
'sold_units' => 0,
|
||||
'refunded_units' => 0,
|
||||
]);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
|
||||
@@ -31,16 +31,11 @@ Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, de
|
||||
|
||||
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
|
||||
|
||||
### Configuración de categorías de producto
|
||||
|
||||
`GET /v1/adminapp/tenant/categories` devuelve un árbol ordenado de las categorías del tenant autenticado, incluidas las ocultas. Cada nodo contiene `id`, `nombre`, `is_enabled`, `banner_enabled`, `banner_key`, `banner_name`, `banner_url` y `subcategories`. La URL administrativa se devuelve incluso si el banner está deshabilitado.
|
||||
|
||||
`PUT /v1/adminapp/tenant/categories` recibe `categories` y `deleted_ids`. Cada nodo enviado contiene `id` (null para altas), `nombre`, `is_enabled`, `banner_enabled`, `banner` y `subcategories`. `banner` es una clave UUID de un banner ya asociado a las categorías del tenant, una imagen base64/archivo para reemplazarlo o null para quitarlo. Deshabilitar un banner conserva el adjunto. Duplicar una categoría puede compartir su banner; los adjuntos previos no se eliminan al quitar o reemplazar esa referencia.
|
||||
|
||||
La posición de cada nodo en su lista determina `sort_order`; este campo no se expone en las respuestas públicas ni administrativas. La migración inicial conserva el orden alfabético existente. El bootstrap del storefront ordena por `sort_order` e ID y no muestra categorías ocultas ni sus descendientes.
|
||||
|
||||
El guardado es transaccional. Solo se eliminan los IDs explícitos, se rechazan IDs y banners ajenos, categorías repetidas y jerarquías inválidas. No se pueden eliminar categorías con productos (incluidos los archivados) o grupos destacados asociados. Los archivos recién subidos se limpian si falla la transacción. La respuesta devuelve el árbol persistido y los IDs asignados a las altas.
|
||||
|
||||
## Dependencias y reglas
|
||||
|
||||
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
|
||||
|
||||
## Menús deprecados
|
||||
|
||||
Los menús `adminapp.combos` y `adminapp.categories` están retirados; las
|
||||
categorías del catálogo y sus datos comerciales no se eliminan.
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Commerce\Catalog\Controllers\AdminApp\CategoryConfigurationController;
|
||||
use App\Domains\Commerce\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::get('categories', [CategoryConfigurationController::class, 'index']);
|
||||
Route::put('categories', [CategoryConfigurationController::class, 'update']);
|
||||
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
||||
->name('adminapp.featured-groups.index');
|
||||
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
||||
|
||||
@@ -13,9 +13,8 @@ class PurchaseRefundSummaryService
|
||||
$total = TicketRefund::query()
|
||||
->whereHas(
|
||||
'purchaseItem.purchase',
|
||||
fn (Builder $query): Builder => $query
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId))
|
||||
fn (Builder $query): Builder => $query->where('tenant_codigo', $tenant->codigo)
|
||||
->when($eventId !== null, fn (Builder $purchase) => $purchase->where('event_id', $eventId))
|
||||
)
|
||||
->sum('amount');
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\Commerce\Purchase\Services;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||
use App\Domains\Commerce\Purchase\Models\Purchase;
|
||||
use Illuminate\Database\Query\Builder;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -70,12 +69,15 @@ class TenantTransactionResetService
|
||||
'users_preserved' => DB::table('users')->where('tenant_codigo', $tenantCode)->count(),
|
||||
];
|
||||
|
||||
Inventory::query()
|
||||
->whereKey($scope['inventory_ids'])
|
||||
->orderBy('id')
|
||||
->lockForUpdate()
|
||||
->get()
|
||||
->each(fn (Inventory $inventory) => $inventory->resetTransactionCounters());
|
||||
DB::table('inventories')
|
||||
->whereIn('id', $scope['inventory_ids'])
|
||||
->update([
|
||||
'real_stock' => DB::raw('real_stock + sold_units - refunded_units'),
|
||||
'reserved_stock' => 0,
|
||||
'entry_reserved_stock' => 0,
|
||||
'sold_units' => 0,
|
||||
'refunded_units' => 0,
|
||||
]);
|
||||
|
||||
return $summary;
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ use App\Domains\Commerce\Sale\Resources\AdminApp\SaleTicketResource;
|
||||
use App\Domains\Commerce\Sale\Services\AdminAppSaleExcelService;
|
||||
use App\Domains\Commerce\Sale\Services\AdminAppSalePdfService;
|
||||
use App\Domains\Commerce\Sale\Services\AdminAppSaleService;
|
||||
use App\Domains\Core\Auth\Services\EventScopeService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
@@ -27,15 +28,20 @@ class SaleController extends Controller
|
||||
protected AdminAppSaleExcelService $saleExcelService,
|
||||
) {}
|
||||
|
||||
private function eventId(Request $request): ?int
|
||||
{
|
||||
return app(EventScopeService::class)->eventId($request->user());
|
||||
}
|
||||
|
||||
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return SaleResource::collection(
|
||||
$this->saleService->sales($tenant, $request->validated(), $request->user()->event_id)
|
||||
$this->saleService->sales($tenant, $request->validated(), $this->eventId($request))
|
||||
)->additional([
|
||||
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $request->user()->event_id),
|
||||
'refunded_total' => $this->saleService->refundedTotal($tenant, $request->user()->event_id),
|
||||
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant, $this->eventId($request)),
|
||||
'refunded_total' => $this->saleService->refundedTotal($tenant, $this->eventId($request)),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -43,7 +49,7 @@ class SaleController extends Controller
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return new SaleDetailResource($this->saleService->detail($tenant, $sale, $request->user()->event_id));
|
||||
return new SaleDetailResource($this->saleService->detail($tenant, $sale, $this->eventId($request)));
|
||||
}
|
||||
|
||||
public function tickets(Request $request, int $sale): AnonymousResourceCollection
|
||||
@@ -51,7 +57,7 @@ class SaleController extends Controller
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return SaleTicketResource::collection(
|
||||
$this->saleService->tickets($tenant, $sale, $request->user()->event_id)
|
||||
$this->saleService->tickets($tenant, $sale, $this->eventId($request))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,14 +65,14 @@ class SaleController extends Controller
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return new SaleResource($this->saleService->confirm($tenant, $sale, $request->user()->event_id));
|
||||
return new SaleResource($this->saleService->confirm($tenant, $sale, $this->eventId($request)));
|
||||
}
|
||||
|
||||
public function cancel(Request $request, int $sale): SaleResource
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return new SaleResource($this->saleService->cancel($tenant, $sale, $request->user()->event_id));
|
||||
return new SaleResource($this->saleService->cancel($tenant, $sale, $this->eventId($request)));
|
||||
}
|
||||
|
||||
public function modifications(
|
||||
@@ -76,7 +82,7 @@ class SaleController extends Controller
|
||||
$this->saleService->modifications(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated(),
|
||||
$request->user()->event_id,
|
||||
$this->eventId($request),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -87,7 +93,7 @@ class SaleController extends Controller
|
||||
|
||||
return $this->salePdfService->downloadSales(
|
||||
$tenant,
|
||||
$this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||
$this->saleService->salesForExport($tenant, $request->validated(), $this->eventId($request)),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
@@ -98,7 +104,7 @@ class SaleController extends Controller
|
||||
|
||||
return $this->salePdfService->downloadModifications(
|
||||
$tenant,
|
||||
$this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||
$this->saleService->modificationsForExport($tenant, $request->validated(), $this->eventId($request)),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
@@ -109,7 +115,7 @@ class SaleController extends Controller
|
||||
|
||||
return $this->saleExcelService->downloadSales(
|
||||
$tenant,
|
||||
$this->saleService->salesForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||
$this->saleService->salesForExport($tenant, $request->validated(), $this->eventId($request)),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
@@ -121,7 +127,7 @@ class SaleController extends Controller
|
||||
|
||||
return $this->saleExcelService->downloadModifications(
|
||||
$tenant,
|
||||
$this->saleService->modificationsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||
$this->saleService->modificationsForExport($tenant, $request->validated(), $this->eventId($request)),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,9 @@ class AdminAppSaleService
|
||||
|
||||
public function confirmedSalesTotal(Tenant $tenant, ?int $eventId = null): string
|
||||
{
|
||||
$total = $this->purchasesQuery($tenant, $eventId)
|
||||
$total = Purchase::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
|
||||
->where('status', Purchase::STATUS_PAID)
|
||||
->sum('total');
|
||||
|
||||
@@ -55,7 +57,9 @@ class AdminAppSaleService
|
||||
|
||||
public function detail(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||
{
|
||||
return $this->purchasesQuery($tenant, $eventId)
|
||||
return Purchase::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
|
||||
->with('items')
|
||||
->findOrFail($saleId);
|
||||
}
|
||||
@@ -135,7 +139,9 @@ class AdminAppSaleService
|
||||
? $requestedDirection
|
||||
: 'desc';
|
||||
|
||||
return $this->purchasesQuery($tenant, $eventId)
|
||||
return Purchase::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
|
||||
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||
$term = trim($search);
|
||||
|
||||
@@ -178,13 +184,9 @@ class AdminAppSaleService
|
||||
return ValueChange::query()
|
||||
->where('tenant_code', $tenant->codigo)
|
||||
->where('trackable_type', (new Purchase)->getMorphClass())
|
||||
->when($eventId !== null, fn (Builder $query): Builder => $query->whereHasMorph(
|
||||
'trackable',
|
||||
[Purchase::class],
|
||||
fn (Builder $sales): Builder => $sales
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('event_id', $eventId),
|
||||
))
|
||||
->when($eventId !== null, fn (Builder $query) => $query->whereHasMorph(
|
||||
'trackable', [Purchase::class],
|
||||
fn (Builder $sales) => $sales->where('event_id', $eventId)))
|
||||
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||
$term = trim($search);
|
||||
|
||||
@@ -226,17 +228,11 @@ class AdminAppSaleService
|
||||
}
|
||||
|
||||
protected function findForTenant(Tenant $tenant, int $saleId, ?int $eventId = null): Purchase
|
||||
{
|
||||
return $this->purchasesQuery($tenant, $eventId)
|
||||
->findOrFail($saleId);
|
||||
}
|
||||
|
||||
/** @return Builder<Purchase> */
|
||||
protected function purchasesQuery(Tenant $tenant, ?int $eventId): Builder
|
||||
{
|
||||
return Purchase::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
|
||||
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId))
|
||||
->findOrFail($saleId);
|
||||
}
|
||||
|
||||
protected function saleForResponse(Purchase $sale): Purchase
|
||||
|
||||
@@ -29,8 +29,11 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
||||
|
||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
||||
|
||||
Cuando el usuario autenticado tiene `event_id`, el controlador lo pasa al servicio como alcance obligatorio para ventas, totales, historial y exportaciones. El alcance se combina con el tenant y no se obtiene de los filtros enviados por el cliente. Los administradores sin `event_id` conservan el alcance del tenant.
|
||||
|
||||
El detalle, los tickets de una venta, la confirmación y la cancelación buscan la compra dentro del mismo alcance. Una venta de otro evento o sin evento devuelve 404 para un administrador con `event_id`, antes de ejecutar cualquier acción en `CheckoutService`.
|
||||
|
||||
El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta.
|
||||
|
||||
## Alcance por evento
|
||||
|
||||
Las rutas usan `adminapp.tenant:event`. Para admins de evento, listados, totales,
|
||||
detalles, tickets de compras, confirmación, cancelación, historial y exportaciones
|
||||
se filtran por `user.event_id` además del tenant. Las compras pertenecen a un
|
||||
único evento. Un admin de tenant conserva acceso a sus compras de todos los eventos.
|
||||
|
||||
@@ -4,7 +4,7 @@ use App\Domains\Commerce\Sale\Controllers\AdminApp\SaleController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant:event'])
|
||||
->group(function (): void {
|
||||
Route::get('sales', [SaleController::class, 'index']);
|
||||
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Address\Models;
|
||||
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticketing\Event\Models\Event;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Address extends Model
|
||||
{
|
||||
protected $fillable = ['label', 'address_text', 'latitude', 'longitude'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'latitude' => 'decimal:7',
|
||||
'longitude' => 'decimal:7',
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array{latitude: float, longitude: float}|null */
|
||||
public function coordinates(): ?array
|
||||
{
|
||||
if ($this->latitude === null || $this->longitude === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ['latitude' => (float) $this->latitude, 'longitude' => (float) $this->longitude];
|
||||
}
|
||||
|
||||
/** @return HasMany<Event, $this> */
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(Event::class);
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Tenant, $this> */
|
||||
public function tenants(): BelongsToMany
|
||||
{
|
||||
return $this->belongsToMany(Tenant::class, 'tenant_addresses', 'address_id', 'tenant_codigo', 'id', 'codigo')
|
||||
->using(TenantAddress::class)
|
||||
->withPivot(['id', 'is_main'])
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Address\Models;
|
||||
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class TenantAddress extends Pivot
|
||||
{
|
||||
protected $table = 'tenant_addresses';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $fillable = ['tenant_codigo', 'address_id', 'is_main'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_main' => 'boolean'];
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Address, $this> */
|
||||
public function address(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Address::class);
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Address\Resources;
|
||||
|
||||
use App\Domains\Core\Address\Models\Address;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Address */
|
||||
class AddressResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'label' => $this->label,
|
||||
'address_text' => $this->address_text,
|
||||
'latitude' => $this->latitude === null ? null : (float) $this->latitude,
|
||||
'longitude' => $this->longitude === null ? null : (float) $this->longitude,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,6 @@ class AdminAppAdministratorController extends Controller
|
||||
return AdministratorResource::collection($this->administratorService->list(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->string('search')->trim()->toString() ?: null,
|
||||
$request->user()->event_id,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -29,7 +28,6 @@ class AdminAppAdministratorController extends Controller
|
||||
return AdministratorResource::make($this->administratorService->create(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated(),
|
||||
$request->user()->event_id,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -39,7 +37,6 @@ class AdminAppAdministratorController extends Controller
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$administrator,
|
||||
$request->validated(),
|
||||
$request->user()->event_id,
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ class AdministratorResource extends JsonResource
|
||||
'dni' => $this->dni,
|
||||
'email' => $this->email,
|
||||
'rol_codigo' => $this->rol_codigo,
|
||||
'event_id' => $this->event_id,
|
||||
'role' => $this->whenLoaded('role', fn () => [
|
||||
'codigo' => $this->role?->codigo,
|
||||
'nombre' => $this->role?->nombre,
|
||||
|
||||
@@ -19,9 +19,9 @@ class AdministratorService
|
||||
public function __construct(private readonly ResetPasswordAttemptService $resetPasswordAttemptService) {}
|
||||
|
||||
/** @return Collection<int, User> */
|
||||
public function list(Tenant $tenant, ?string $search = null, ?int $eventId = null): Collection
|
||||
public function list(Tenant $tenant, ?string $search = null): Collection
|
||||
{
|
||||
return $this->query($tenant, $eventId)->with('role')
|
||||
return $this->query($tenant)->with('role')
|
||||
->when($search, fn (Builder $query, string $search) => $query->where(function (Builder $query) use ($search): void {
|
||||
$query->where('nombre_apellido', 'like', "%{$search}%")
|
||||
->orWhere('dni', 'like', "%{$search}%")
|
||||
@@ -31,15 +31,14 @@ class AdministratorService
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function create(Tenant $tenant, array $data, ?int $eventId = null): User
|
||||
public function create(Tenant $tenant, array $data): User
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data, $eventId): User {
|
||||
return DB::transaction(function () use ($tenant, $data): User {
|
||||
$administrator = User::query()->create([
|
||||
...$this->attributes($data),
|
||||
'password' => Str::random(64),
|
||||
'rol_codigo' => RoleCode::AdminApp->value,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'event_id' => $eventId,
|
||||
]);
|
||||
$this->resetPasswordAttemptService->createForAdminAppEmail(
|
||||
$administrator->email,
|
||||
@@ -51,10 +50,10 @@ class AdministratorService
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function update(Tenant $tenant, int $administratorId, array $data, ?int $eventId = null): User
|
||||
public function update(Tenant $tenant, int $administratorId, array $data): User
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $administratorId, $data, $eventId): User {
|
||||
$administrator = $this->query($tenant, $eventId)->lockForUpdate()->findOrFail($administratorId);
|
||||
return DB::transaction(function () use ($tenant, $administratorId, $data): User {
|
||||
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||
$administrator->update($this->attributes($data));
|
||||
|
||||
return $administrator->load('role');
|
||||
@@ -67,11 +66,11 @@ class AdministratorService
|
||||
// Serialize deletions for this tenant, including requests already authenticated
|
||||
// when another administrator removes their account.
|
||||
Tenant::query()->whereKey($tenant->getKey())->lockForUpdate()->firstOrFail();
|
||||
$administrator = $this->query($tenant, $actor->event_id)->lockForUpdate()->findOrFail($administratorId);
|
||||
$administrator = $this->query($tenant)->lockForUpdate()->findOrFail($administratorId);
|
||||
if ($administrator->is($actor)) {
|
||||
throw ValidationException::withMessages(['administrator' => 'No podés eliminar tu propio usuario.']);
|
||||
}
|
||||
$activeAdministrators = $this->query($tenant, $actor->event_id)->lockForUpdate()->get();
|
||||
$activeAdministrators = $this->query($tenant)->lockForUpdate()->get();
|
||||
if ($activeAdministrators->count() <= 1) {
|
||||
throw ValidationException::withMessages(['administrator' => 'El tenant debe conservar al menos un administrador.']);
|
||||
}
|
||||
@@ -81,11 +80,10 @@ class AdministratorService
|
||||
});
|
||||
}
|
||||
|
||||
private function query(Tenant $tenant, ?int $eventId = null): Builder
|
||||
private function query(Tenant $tenant): Builder
|
||||
{
|
||||
return User::query()->where('tenant_codigo', $tenant->codigo)
|
||||
->where('rol_codigo', RoleCode::AdminApp->value)
|
||||
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
|
||||
->where('rol_codigo', RoleCode::AdminApp->value);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data
|
||||
|
||||
@@ -55,8 +55,6 @@ No agrega tablas ni migraciones. No modifica el CRUD de escáneres ni el fronten
|
||||
|
||||
## Verificación
|
||||
|
||||
Cuando el actor tiene `event_id`, los nuevos administradores heredan su evento y el listado, la búsqueda, la edición y la baja se limitan a ese evento dentro del tenant. La comprobación de administradores activos también usa ese alcance. El evento se toma del usuario autenticado, no del cuerpo de la solicitud; sin `event_id` se conserva el comportamiento por tenant.
|
||||
|
||||
`php artisan test tests/Feature/Administrator/AdministratorControllerTest.php`
|
||||
|
||||
Las pruebas cubren CRUD, normalización y unicidad del email, establecimiento de
|
||||
|
||||
9
app/Domains/Core/Auth/Enums/AdminScope.php
Normal file
9
app/Domains/Core/Auth/Enums/AdminScope.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Auth\Enums;
|
||||
|
||||
enum AdminScope: string
|
||||
{
|
||||
case Tenant = 'tenant';
|
||||
case Event = 'event';
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Core\Auth\Models;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Category;
|
||||
use App\Domains\Core\Auth\Enums\AdminScope;
|
||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Core\Authorization\Models\Role;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
@@ -21,7 +22,7 @@ use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
|
||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo', 'event_id'])]
|
||||
#[Fillable(['nombre_apellido', 'email', 'password', 'dni', 'telefono', 'google_id', 'rol_codigo', 'tenant_codigo', 'admin_scope', 'event_id'])]
|
||||
#[Hidden(['password', 'remember_token', 'active_email', 'active_google_id'])]
|
||||
class User extends Authenticatable
|
||||
{
|
||||
@@ -30,6 +31,7 @@ class User extends Authenticatable
|
||||
|
||||
protected $attributes = [
|
||||
'rol_codigo' => RoleCode::User->value,
|
||||
'admin_scope' => AdminScope::Tenant->value,
|
||||
];
|
||||
|
||||
protected static function newFactory(): UserFactory
|
||||
@@ -93,6 +95,13 @@ class User extends Authenticatable
|
||||
return $this->belongsTo(Event::class);
|
||||
}
|
||||
|
||||
public function isTenantAdministrator(): bool
|
||||
{
|
||||
return $this->rol_codigo === RoleCode::AdminApp->value
|
||||
&& $this->admin_scope === AdminScope::Tenant->value
|
||||
&& $this->event_id === null;
|
||||
}
|
||||
|
||||
/** @return BelongsToMany<Category, $this> */
|
||||
public function scanCategories(): BelongsToMany
|
||||
{
|
||||
|
||||
@@ -20,6 +20,11 @@ class AdminAppMeResource extends JsonResource
|
||||
return [
|
||||
'user' => UserResource::make($this->resource),
|
||||
'tenant' => TenantResource::make($this->tenant),
|
||||
'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [
|
||||
'id' => $this->event->id,
|
||||
'title' => $this->event->title,
|
||||
'tenant_code' => $this->event->tenant_code,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,9 @@ class ScannerMeResource extends JsonResource
|
||||
return [
|
||||
'user' => UserResource::make($this->resource),
|
||||
'tenant' => TenantResource::make($this->tenant),
|
||||
'event' => $this->whenLoaded('event', fn () => $this->event === null ? null : [
|
||||
'id' => $this->event->id, 'title' => $this->event->title,
|
||||
]),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace App\Domains\Core\Auth\Resources;
|
||||
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -24,7 +25,8 @@ class UserResource extends JsonResource
|
||||
'telefono' => $this->telefono,
|
||||
'rol_codigo' => $this->rol_codigo,
|
||||
'tenant_codigo' => $this->tenant_codigo,
|
||||
'event_id' => $this->event_id,
|
||||
'admin_scope' => $this->when($this->rol_codigo === RoleCode::AdminApp->value, $this->admin_scope),
|
||||
'event_id' => $this->when(in_array($this->rol_codigo, [RoleCode::AdminApp->value, RoleCode::Scanner->value], true), $this->event_id),
|
||||
'categories' => $this->whenLoaded('scanCategories', fn () => $this->scanCategories
|
||||
->map(fn ($category) => [
|
||||
'id' => $category->id,
|
||||
|
||||
27
app/Domains/Core/Auth/Services/AdminAppAccessService.php
Normal file
27
app/Domains/Core/Auth/Services/AdminAppAccessService.php
Normal file
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Auth\Services;
|
||||
|
||||
use App\Domains\Core\Auth\Enums\AdminScope;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||
|
||||
class AdminAppAccessService
|
||||
{
|
||||
public function hasValidScope(User $user): bool
|
||||
{
|
||||
if ($user->rol_codigo !== RoleCode::AdminApp->value
|
||||
|| ! $user->tenant_codigo
|
||||
|| ! $user->tenant()->exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user->isTenantAdministrator()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return $user->admin_scope === AdminScope::Event->value
|
||||
&& $user->event_id !== null
|
||||
&& $user->event()->where('tenant_code', $user->tenant_codigo)->exists();
|
||||
}
|
||||
}
|
||||
@@ -11,9 +11,6 @@ class AdminAppContextService
|
||||
{
|
||||
$tenant = $user->tenant()
|
||||
->with([
|
||||
'socialMedia',
|
||||
'addresses',
|
||||
'phoneNumbers',
|
||||
'menues' => fn ($query) => $query
|
||||
->whereHas(
|
||||
'roles',
|
||||
@@ -23,6 +20,7 @@ class AdminAppContextService
|
||||
->firstOrFail();
|
||||
|
||||
$user->setRelation('tenant', $tenant);
|
||||
$user->load('event');
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
24
app/Domains/Core/Auth/Services/EventScopeService.php
Normal file
24
app/Domains/Core/Auth/Services/EventScopeService.php
Normal file
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Auth\Services;
|
||||
|
||||
use App\Domains\Core\Auth\Enums\AdminScope;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
|
||||
class EventScopeService
|
||||
{
|
||||
public function eventId(User $user): ?int
|
||||
{
|
||||
if ($user->admin_scope === AdminScope::Event->value || $user->event_id !== null) {
|
||||
if ($user->event_id === null
|
||||
|| ! $user->event()->where('tenant_code', $user->tenant_codigo)->exists()) {
|
||||
throw new AuthorizationException;
|
||||
}
|
||||
|
||||
return $user->event_id;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ use App\Domains\Core\Authorization\Enums\PermissionCode;
|
||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||
use App\Shared\Notification\Events\PasswordResetRequested;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
@@ -19,6 +20,7 @@ class PasswordLoginService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ResetPasswordAttemptService $resetPasswordAttemptService,
|
||||
private readonly AdminAppAccessService $adminAppAccessService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -196,6 +198,26 @@ class PasswordLoginService
|
||||
];
|
||||
}
|
||||
|
||||
if ($requiredRole === RoleCode::AdminApp && ! $this->adminAppAccessService->hasValidScope($user)) {
|
||||
$this->recordAttempt(
|
||||
$user, $normalizedEmail, $attemptTenantCode,
|
||||
LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent,
|
||||
);
|
||||
|
||||
return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null];
|
||||
}
|
||||
|
||||
if ($requiredRole === RoleCode::Scanner) {
|
||||
try {
|
||||
app(EventScopeService::class)->eventId($user);
|
||||
} catch (AuthorizationException) {
|
||||
$this->recordAttempt($user, $normalizedEmail, $attemptTenantCode,
|
||||
LoginAttempt::OUTCOME_INVALID_CREDENTIALS, $ipAddress, $userAgent);
|
||||
|
||||
return ['outcome' => LoginAttempt::OUTCOME_INVALID_CREDENTIALS, 'user' => $user, 'locked_until' => null];
|
||||
}
|
||||
}
|
||||
|
||||
$user->forceFill([
|
||||
'failed_login_attempts' => 0,
|
||||
'last_failed_login_at' => null,
|
||||
|
||||
@@ -18,6 +18,7 @@ class ScannerContextService
|
||||
->firstOrFail();
|
||||
|
||||
$user->setRelation('tenant', $tenant);
|
||||
$user->load('event');
|
||||
|
||||
if ($tenant->requiresScannerCategoryValidation()) {
|
||||
$categories = $user->scanCategories()
|
||||
|
||||
@@ -17,6 +17,6 @@ Route::prefix('v1/adminapp')->group(function (): void {
|
||||
Route::post('password/reset', ResetPasswordController::class)
|
||||
->defaults('reset_role', 'adminapp')
|
||||
->middleware('throttle:5,1');
|
||||
Route::middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
Route::middleware(['auth:sanctum', 'adminapp.tenant:context'])
|
||||
->get('me', AdminAppMeController::class);
|
||||
});
|
||||
|
||||
@@ -33,17 +33,12 @@ class TenantBootstrapService
|
||||
$tenant,
|
||||
[
|
||||
'eventDateChanges',
|
||||
'menues' => fn ($query) => $query
|
||||
->where(fn ($query) => $query
|
||||
->where('menues.code', 'help.contact')
|
||||
->orWhere('tenants_menues.is_enabled', true))
|
||||
->whereHas(
|
||||
'menues' => fn ($query) => $query->whereHas(
|
||||
'roles',
|
||||
fn ($query) => $query->where('codigo', RoleCode::User->value)
|
||||
),
|
||||
...($tenant->storefront_website_type_code === 'onticket_multi_event'
|
||||
? ['eventCategories' => fn ($query) => $query->orderBy('nombre')]
|
||||
: ['categories' => fn ($query) => $query->orderBy('sort_order')->orderBy('id')]),
|
||||
$tenant->storefront_website_type_code === 'onticket_multi_event'
|
||||
? 'eventCategories' : 'categories' => fn ($query) => $query->orderBy('nombre'),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ class Menu extends Model
|
||||
'codigo'
|
||||
)
|
||||
->using(TenantMenu::class)
|
||||
->withPivot('static_content', 'is_enabled')
|
||||
->withPivot('static_content')
|
||||
->withTimestamps();
|
||||
}
|
||||
|
||||
|
||||
@@ -16,14 +16,12 @@ class TenantMenu extends Pivot
|
||||
'tenant_code',
|
||||
'menu_code',
|
||||
'static_content',
|
||||
'is_enabled',
|
||||
];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'static_content' => 'array',
|
||||
'is_enabled' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\PhoneNumber\Models;
|
||||
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\Pivot;
|
||||
|
||||
class TenantPhoneNumber extends Pivot
|
||||
{
|
||||
protected $table = 'tenant_phone_numbers';
|
||||
|
||||
public $incrementing = true;
|
||||
|
||||
protected $fillable = ['tenant_codigo', 'phone_number_id', 'is_main'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_main' => 'boolean'];
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
|
||||
}
|
||||
|
||||
public function phoneNumber(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PhoneNumber::class);
|
||||
}
|
||||
}
|
||||
@@ -1,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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Core\Staff\Controllers;
|
||||
|
||||
use App\Domains\Core\Auth\Services\EventScopeService;
|
||||
use App\Domains\Core\Staff\Requests\StoreStaffRequest;
|
||||
use App\Domains\Core\Staff\Requests\UpdateStaffRequest;
|
||||
use App\Domains\Core\Staff\Resources\StaffResource;
|
||||
@@ -21,12 +22,17 @@ class AdminAppStaffController extends Controller
|
||||
private readonly ScannerTicketService $scannerTicketService,
|
||||
) {}
|
||||
|
||||
private function eventId(Request $request): ?int
|
||||
{
|
||||
return app(EventScopeService::class)->eventId($request->user());
|
||||
}
|
||||
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
return StaffResource::collection($this->staffService->list(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->string('search')->trim()->toString() ?: null,
|
||||
$request->user()->event_id,
|
||||
$this->eventId($request),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -35,7 +41,7 @@ class AdminAppStaffController extends Controller
|
||||
return StaffResource::make($this->staffService->create(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated(),
|
||||
$request->user()->event_id,
|
||||
$this->eventId($request),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -45,13 +51,13 @@ class AdminAppStaffController extends Controller
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$staff,
|
||||
$request->validated(),
|
||||
$request->user()->event_id,
|
||||
$this->eventId($request),
|
||||
));
|
||||
}
|
||||
|
||||
public function destroy(Request $request, int $staff): Response
|
||||
{
|
||||
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $request->user()->event_id);
|
||||
$this->staffService->delete($request->user()->tenant()->firstOrFail(), $staff, $this->eventId($request));
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
@@ -63,7 +69,7 @@ class AdminAppStaffController extends Controller
|
||||
$scanner = $this->staffService->find(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$staff,
|
||||
$request->user()->event_id,
|
||||
$this->eventId($request),
|
||||
);
|
||||
|
||||
return ScanAttemptResource::collection(
|
||||
|
||||
@@ -14,11 +14,11 @@ class StaffResource extends JsonResource
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'event_id' => $this->event_id,
|
||||
'nombre_apellido' => $this->nombre_apellido,
|
||||
'dni' => $this->dni,
|
||||
'email' => $this->email,
|
||||
'rol_codigo' => $this->rol_codigo,
|
||||
'event_id' => $this->event_id,
|
||||
'role' => $this->whenLoaded('role', fn () => [
|
||||
'codigo' => $this->role?->codigo,
|
||||
'nombre' => $this->role?->nombre,
|
||||
|
||||
@@ -8,6 +8,7 @@ use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Core\Auth\Services\ResetPasswordAttemptService;
|
||||
use App\Domains\Core\Authorization\Enums\RoleCode;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticketing\Event\Models\Event;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
@@ -38,7 +39,7 @@ class StaffService
|
||||
}
|
||||
|
||||
/** @return Collection<int, Category> */
|
||||
private function assignableCategories(Tenant $tenant): Collection
|
||||
private function assignableCategories(Tenant $tenant, ?int $eventId = null): Collection
|
||||
{
|
||||
return Category::query()
|
||||
->whereNull('categoria_id')
|
||||
@@ -47,6 +48,8 @@ class StaffService
|
||||
->orWhereHas('catalogItems', fn (Builder $items) => $items
|
||||
->where('tenant_code', $tenant->codigo));
|
||||
})
|
||||
->when($eventId !== null, fn (Builder $query) => $query->whereHas(
|
||||
'catalogItems', fn (Builder $items) => $items->where('tenant_code', $tenant->codigo)->where('event_id', $eventId)))
|
||||
->orderBy('nombre')
|
||||
->get();
|
||||
}
|
||||
@@ -54,8 +57,11 @@ class StaffService
|
||||
/** @param array<string, mixed> $data */
|
||||
public function create(Tenant $tenant, array $data, ?int $eventId = null): User
|
||||
{
|
||||
$eventId ??= $tenant->active_event_id;
|
||||
Event::query()
|
||||
->where('tenant_code', $tenant->codigo)->findOrFail($eventId);
|
||||
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
||||
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
||||
$this->assertCategoriesBelongToTenant($tenant, $categoryIds, $eventId);
|
||||
|
||||
return DB::transaction(function () use ($tenant, $data, $categoryIds, $eventId): User {
|
||||
$staff = User::query()->create([
|
||||
@@ -65,6 +71,7 @@ class StaffService
|
||||
'rol_codigo' => RoleCode::Scanner->value,
|
||||
'tenant_codigo' => $tenant->codigo,
|
||||
'event_id' => $eventId,
|
||||
'admin_scope' => $eventId === null ? 'tenant' : 'event',
|
||||
]);
|
||||
$staff->scanCategories()->sync($categoryIds);
|
||||
$this->resetPasswordAttemptService->createForScannerEmail(
|
||||
@@ -81,7 +88,7 @@ class StaffService
|
||||
{
|
||||
$staff = $this->find($tenant, $staffId, $eventId);
|
||||
$categoryIds = $this->categoryIdsFor($tenant, $data);
|
||||
$this->assertCategoriesBelongToTenant($tenant, $categoryIds);
|
||||
$this->assertCategoriesBelongToTenant($tenant, $categoryIds, $eventId);
|
||||
|
||||
return DB::transaction(function () use ($staff, $data, $categoryIds): User {
|
||||
$attributes = Arr::only($data, ['nombre_apellido', 'dni', 'email']);
|
||||
@@ -113,14 +120,14 @@ class StaffService
|
||||
return User::query()
|
||||
->where('tenant_codigo', $tenant->codigo)
|
||||
->where('rol_codigo', RoleCode::Scanner->value)
|
||||
->when($eventId !== null, fn (Builder $query): Builder => $query->where('event_id', $eventId));
|
||||
->when($eventId !== null, fn (Builder $query) => $query->where('event_id', $eventId));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
* @return array<int, int>
|
||||
*/
|
||||
private function categoryIdsFor(Tenant $tenant, array $data): array
|
||||
private function categoryIdsFor(Tenant $tenant, array $data, ?int $eventId = null): array
|
||||
{
|
||||
if (! $tenant->requiresScannerCategoryValidation()) {
|
||||
return [];
|
||||
@@ -130,9 +137,9 @@ class StaffService
|
||||
}
|
||||
|
||||
/** @param array<int, int> $categoryIds */
|
||||
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds): void
|
||||
private function assertCategoriesBelongToTenant(Tenant $tenant, array $categoryIds, ?int $eventId = null): void
|
||||
{
|
||||
$validIds = $this->assignableCategories($tenant)
|
||||
$validIds = $this->assignableCategories($tenant, $eventId)
|
||||
->whereIn('id', $categoryIds)
|
||||
->pluck('id');
|
||||
|
||||
|
||||
@@ -19,4 +19,12 @@ Recurso REST `/v1/adminapp/tenant/staff`, excepto detalle individual, protegido
|
||||
|
||||
Usa `Auth/User` como entidad de personal, `Authorization` para su rol, `Catalog/Category` para asignaciones y `Tenant` para aislamiento. Toda búsqueda, edición o borrado debe comprobar que el usuario pertenece al tenant autenticado.
|
||||
|
||||
Si el administrador autenticado tiene `event_id`, el alta de scanners hereda ese valor y las búsquedas, ediciones, bajas y consultas de intentos de escaneo se limitan a personal del mismo evento. El cliente no puede elegir ni cambiar el evento. Sin `event_id`, se mantiene el alcance por tenant.
|
||||
## Alcance por evento
|
||||
|
||||
Los endpoints de Staff y su formulario aceptan `adminapp.tenant:event`. Un admin
|
||||
de evento lista, edita, elimina y consulta el historial solo de scanners de su
|
||||
evento. El backend asigna el evento al crear un scanner y no acepta cambios de
|
||||
asignación desde el formulario. Las categorías autorizables se limitan a las
|
||||
usadas por productos del evento. Los scanners aplican además su evento en
|
||||
lectura de tickets, escaneo e historial. Los intentos registran `event_id` para
|
||||
conservar el aislamiento aunque cambie la asignación del scanner.
|
||||
|
||||
@@ -4,7 +4,7 @@ use App\Domains\Core\Staff\Controllers\AdminAppStaffController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant:event'])
|
||||
->group(function (): void {
|
||||
Route::get('staff/{staff}/scan-attempts', [AdminAppStaffController::class, 'scanAttempts']);
|
||||
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Core\Tenant\Requests\AdminApp\UpdateBrandRequest;
|
||||
use App\Domains\Core\Tenant\Resources\AdminApp\BrandResource;
|
||||
use App\Domains\Core\Tenant\Services\BrandService;
|
||||
use App\Domains\Core\Tenant\Services\TenantInformationService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class BrandController extends Controller
|
||||
{
|
||||
public function show(Request $request, TenantInformationService $tenantInformationService): BrandResource
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return BrandResource::make($tenantInformationService->load($tenant));
|
||||
}
|
||||
|
||||
public function update(
|
||||
UpdateBrandRequest $request,
|
||||
BrandService $brandService,
|
||||
TenantInformationService $tenantInformationService,
|
||||
): BrandResource {
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
$brandService->update($tenant, $request->validated());
|
||||
|
||||
return BrandResource::make($tenantInformationService->load($tenant->fresh()));
|
||||
}
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Core\Tenant\Requests\AdminApp\UpdateContactRequest;
|
||||
use App\Domains\Core\Tenant\Resources\AdminApp\ContactResource;
|
||||
use App\Domains\Core\Tenant\Services\ContactService;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class ContactController extends Controller
|
||||
{
|
||||
public function show(Request $request, ContactService $service): ContactResource
|
||||
{
|
||||
return ContactResource::make($service->load($request->user()->tenant()->firstOrFail()));
|
||||
}
|
||||
|
||||
public function update(UpdateContactRequest $request, ContactService $service): ContactResource
|
||||
{
|
||||
return ContactResource::make($service->update($request->user()->tenant()->firstOrFail(), $request->validated()));
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Core\Tenant\Requests\AdminApp\UpdateHelpMenuContentRequest;
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class HelpMenuController extends Controller
|
||||
{
|
||||
private const MENU_CODES = [
|
||||
'help.faq',
|
||||
'help.terms-and-conditions',
|
||||
];
|
||||
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
$menus = $tenant->menues()
|
||||
->whereIn('menues.code', self::MENU_CODES)
|
||||
->get()
|
||||
->keyBy('code');
|
||||
|
||||
return response()->json([
|
||||
'data' => collect(self::MENU_CODES)->mapWithKeys(
|
||||
fn (string $code): array => [$code => [
|
||||
'code' => $code,
|
||||
'is_enabled' => (bool) ($menus->get($code)?->pivot?->is_enabled ?? false),
|
||||
'static_content' => $menus->get($code)?->pivot?->static_content
|
||||
?? $this->emptyContent($code),
|
||||
]]
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
public function toggle(
|
||||
Request $request,
|
||||
string $menuCode,
|
||||
): JsonResponse {
|
||||
abort_unless(in_array($menuCode, self::MENU_CODES, true), 404);
|
||||
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
$tenantMenu = $tenant->menues()
|
||||
->where('menues.code', $menuCode)
|
||||
->firstOrFail();
|
||||
$isEnabled = ! $tenantMenu->pivot->is_enabled;
|
||||
|
||||
$tenant->menues()->updateExistingPivot($menuCode, [
|
||||
'is_enabled' => $isEnabled,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'code' => $tenantMenu->code,
|
||||
'is_enabled' => $isEnabled,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function updateContent(
|
||||
UpdateHelpMenuContentRequest $request,
|
||||
string $menuCode,
|
||||
): JsonResponse {
|
||||
abort_unless(in_array($menuCode, self::MENU_CODES, true), 404);
|
||||
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
$tenantMenu = $tenant->menues()
|
||||
->where('menues.code', $menuCode)
|
||||
->firstOrFail();
|
||||
$staticContent = $request->validated('static_content');
|
||||
|
||||
$tenant->menues()->updateExistingPivot($menuCode, [
|
||||
'static_content' => $staticContent,
|
||||
]);
|
||||
|
||||
return response()->json([
|
||||
'data' => [
|
||||
'code' => $tenantMenu->code,
|
||||
'is_enabled' => (bool) $tenantMenu->pivot->is_enabled,
|
||||
'static_content' => $staticContent,
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
private function emptyContent(string $menuCode): array
|
||||
{
|
||||
return $menuCode === 'help.faq' ? [] : ['html' => ''];
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,6 @@ use App\Domains\Commerce\Catalog\Enums\GroupLayout;
|
||||
use App\Domains\Commerce\Catalog\Enums\ProductLayout;
|
||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
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\Menu\Models\Menu;
|
||||
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\BelongsToMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\Relations\HasOneThrough;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
#[Fillable([
|
||||
@@ -313,39 +308,6 @@ class Tenant extends Model
|
||||
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
|
||||
{
|
||||
return $this->belongsToMany(
|
||||
@@ -357,7 +319,7 @@ class Tenant extends Model
|
||||
'code'
|
||||
)
|
||||
->using(TenantMenu::class)
|
||||
->withPivot('static_content', 'is_enabled')
|
||||
->withPivot('static_content')
|
||||
->withTimestamps();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Requests\AdminApp;
|
||||
|
||||
use App\Shared\Rules\CroppedImageOrBase64Rule;
|
||||
use App\Shared\Rules\ImageOrBase64Rule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateBrandRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
$shopit = $this->user()->tenant()->firstOrFail()->storefront_website_type_code === 'shopit';
|
||||
|
||||
return [
|
||||
'nombre' => ['required', 'string', 'max:255'],
|
||||
'primary_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
'footer_bg_color' => ['required', 'regex:/^#[0-9a-fA-F]{6}$/'],
|
||||
'header_logo' => ['present', 'nullable', new ImageOrBase64Rule],
|
||||
'footer_logo' => ['present', 'nullable', new ImageOrBase64Rule],
|
||||
'banner_images' => [$shopit ? 'present' : 'prohibited', 'array', 'max:10'],
|
||||
'banner_images.*' => ['required', new CroppedImageOrBase64Rule],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Requests\AdminApp;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateContactRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function rules(): array
|
||||
{
|
||||
$tenantCode = $this->user()->tenant_codigo;
|
||||
|
||||
return [
|
||||
'social_media' => ['present', 'array', 'max:30'],
|
||||
'social_media.*' => ['array:code,url'],
|
||||
'social_media.*.code' => ['required', 'string', 'distinct', Rule::exists('social_media', 'code')],
|
||||
'social_media.*.url' => ['required', 'url:http,https', 'max:255'],
|
||||
'addresses' => ['present', 'array', 'list', 'max:50'],
|
||||
'addresses.*' => ['array:id,address_text,is_main'],
|
||||
'addresses.*.id' => ['nullable', 'integer', 'distinct', Rule::exists('tenant_addresses', 'address_id')->where('tenant_codigo', $tenantCode)],
|
||||
'addresses.*.address_text' => ['required', 'string', 'max:1000'],
|
||||
'addresses.*.is_main' => ['sometimes', 'boolean'],
|
||||
'phone_numbers' => ['present', 'array', 'list', 'max:50'],
|
||||
'phone_numbers.*' => ['array:id,country_code,mobile_prefix,area_code,local_number,is_main'],
|
||||
'phone_numbers.*.id' => ['nullable', 'integer', 'distinct', Rule::exists('tenant_phone_numbers', 'phone_number_id')->where('tenant_codigo', $tenantCode)],
|
||||
'phone_numbers.*.country_code' => ['present', 'nullable', 'regex:/^\+[0-9]{1,4}$/'],
|
||||
'phone_numbers.*.mobile_prefix' => ['present', 'nullable', 'regex:/^[0-9]{1,4}$/'],
|
||||
'phone_numbers.*.area_code' => ['present', 'nullable', 'regex:/^[0-9]{1,8}$/'],
|
||||
'phone_numbers.*.local_number' => ['required', 'string', 'max:50', 'regex:/^[0-9+() .-]+$/', 'regex:/[0-9]/'],
|
||||
'phone_numbers.*.is_main' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
|
||||
public function attributes(): array
|
||||
{
|
||||
return [
|
||||
'social_media' => 'redes sociales',
|
||||
'social_media.*.code' => 'red social',
|
||||
'social_media.*.url' => 'URL de la red social',
|
||||
'addresses' => 'direcciones',
|
||||
'addresses.*.id' => 'dirección',
|
||||
'addresses.*.address_text' => 'dirección',
|
||||
'addresses.*.is_main' => 'dirección principal',
|
||||
'phone_numbers' => 'teléfonos',
|
||||
'phone_numbers.*.id' => 'teléfono',
|
||||
'phone_numbers.*.country_code' => 'código de país',
|
||||
'phone_numbers.*.mobile_prefix' => 'prefijo móvil',
|
||||
'phone_numbers.*.area_code' => 'código de área',
|
||||
'phone_numbers.*.local_number' => 'número de teléfono',
|
||||
'phone_numbers.*.is_main' => 'teléfono principal',
|
||||
];
|
||||
}
|
||||
|
||||
public function messages(): array
|
||||
{
|
||||
return [
|
||||
'required' => 'Completá :attribute.',
|
||||
'url' => 'La URL debe ser válida y comenzar con http:// o https://.',
|
||||
'regex' => 'El formato de :attribute no es válido.',
|
||||
'exists' => 'El valor de :attribute no está disponible para este comercio.',
|
||||
'distinct' => 'Hay un valor duplicado en :attribute.',
|
||||
'max.string' => 'El campo :attribute admite hasta :max caracteres.',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Requests\AdminApp;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateHelpMenuContentRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
public function rules(): array
|
||||
{
|
||||
return match ($this->route('menuCode')) {
|
||||
'help.faq' => [
|
||||
'static_content' => ['required', 'array'],
|
||||
'static_content.*.pregunta' => ['required', 'string'],
|
||||
'static_content.*.respuesta' => ['required', 'string'],
|
||||
'static_content.*.is_active' => ['required', 'boolean'],
|
||||
],
|
||||
'help.terms-and-conditions' => [
|
||||
'static_content' => ['required', 'array'],
|
||||
'static_content.html' => ['present', 'nullable', 'string'],
|
||||
],
|
||||
default => [],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use App\Shared\Attachable\Models\AttachmentCrop;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
/** @mixin Tenant */
|
||||
class BrandResource extends JsonResource
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'nombre' => $this->nombre,
|
||||
'primary_color' => $this->primary_color,
|
||||
'header_bg_color' => $this->header_bg_color,
|
||||
'footer_color' => $this->footerColor(),
|
||||
'header_logo' => $this->headerLogo?->getTemporaryUrl(1440),
|
||||
'header_logo_key' => $this->headerLogo?->key,
|
||||
'footer_logo' => $this->footerLogo?->getTemporaryUrl(1440),
|
||||
'footer_logo_key' => $this->footerLogo?->key,
|
||||
'can_edit_carousel' => $this->canEditCarousel(),
|
||||
'banner_images' => $this->bannerImages(),
|
||||
];
|
||||
}
|
||||
|
||||
private function footerColor(): string
|
||||
{
|
||||
$footerColor = strtolower(trim((string) $this->footer_bg_color));
|
||||
$primaryColor = strtolower(trim((string) $this->primary_color));
|
||||
|
||||
if ($footerColor !== '' && $footerColor === $primaryColor) {
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
if (in_array($footerColor, ['#000', '#000000', 'black'], true)) {
|
||||
return 'black';
|
||||
}
|
||||
|
||||
if ($footerColor === '' || in_array($footerColor, ['#fff', '#ffffff', 'white'], true)) {
|
||||
return 'white';
|
||||
}
|
||||
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
/** @return array<int, array<string, mixed>> */
|
||||
private function bannerImages(): array
|
||||
{
|
||||
if (! $this->canEditCarousel()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$extra = $this->websiteExtras->first(
|
||||
fn ($extra) => $extra->is_enabled && $extra->websiteTypeExtra->codigo === 'carousel'
|
||||
);
|
||||
|
||||
if ($extra === null) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$images = $extra->resolvedConfig();
|
||||
|
||||
if (! is_array($images)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return collect($images)
|
||||
->filter(fn ($image) => $image instanceof Attachment)
|
||||
->map(function (Attachment $image): array {
|
||||
$desktopCrop = $image->cropVariants
|
||||
->firstWhere('variant', AttachmentCrop::DESKTOP);
|
||||
|
||||
return [
|
||||
'key' => $image->key,
|
||||
'url' => ($desktopCrop?->croppedAttachment ?? $image)->getTemporaryUrl(1440),
|
||||
'original_url' => $image->getTemporaryUrl(1440),
|
||||
'crop' => $desktopCrop === null ? null : [
|
||||
'crop_horizontal' => $desktopCrop->crop_horizontal,
|
||||
'crop_vertical' => $desktopCrop->crop_vertical,
|
||||
],
|
||||
];
|
||||
})
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
private function canEditCarousel(): bool
|
||||
{
|
||||
if ($this->storefront_website_type_code !== 'shopit') {
|
||||
return false;
|
||||
}
|
||||
|
||||
$extra = $this->websiteExtras->first(
|
||||
fn ($extra) => $extra->websiteTypeExtra->codigo === 'carousel'
|
||||
);
|
||||
|
||||
return $extra === null || $extra->is_enabled;
|
||||
}
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Resources\AdminApp;
|
||||
|
||||
use App\Domains\Core\PhoneNumber\Resources\PhoneNumberResource;
|
||||
use App\Domains\Core\Tenant\Resources\TenantAddressResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class ContactResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'social_media' => $this->socialMedia->map(fn ($network) => [
|
||||
'code' => $network->code,
|
||||
'url' => $network->pivot->url,
|
||||
])->values(),
|
||||
'addresses' => TenantAddressResource::collection($this->addresses),
|
||||
'phone_numbers' => PhoneNumberResource::collection($this->phoneNumbers),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Resources;
|
||||
|
||||
use App\Domains\Core\Address\Resources\AddressResource;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TenantAddressResource extends AddressResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [...parent::toArray($request), 'is_main' => (bool) $this->pivot->is_main];
|
||||
}
|
||||
}
|
||||
@@ -3,9 +3,7 @@
|
||||
namespace App\Domains\Core\Tenant\Resources;
|
||||
|
||||
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\PhoneNumber\Resources\PhoneNumberResource;
|
||||
use App\Domains\Core\Tenant\Models\StorefrontWebsiteType;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticketing\Event\Models\Event;
|
||||
@@ -43,11 +41,7 @@ class TenantResource extends JsonResource
|
||||
'site_title' => $this->site_title ?? 'ShopitFront',
|
||||
'asset_url' => config('filesystems.disks.s3.url'),
|
||||
'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_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),
|
||||
'primary_color' => $this->primary_color,
|
||||
'secondary_color' => $this->secondary_color,
|
||||
@@ -105,13 +99,6 @@ class TenantResource extends JsonResource
|
||||
])
|
||||
->values()
|
||||
),
|
||||
'tenant_social_media' => $this->whenLoaded('socialMedia', fn () => $this->socialMedia
|
||||
->map(fn ($socialMedia) => [
|
||||
'code' => $socialMedia->code,
|
||||
'icon' => $socialMedia->icon,
|
||||
'name' => $socialMedia->name,
|
||||
'url' => $socialMedia->pivot->url,
|
||||
])->values()),
|
||||
'menues' => $this->whenLoaded(
|
||||
'menues',
|
||||
fn () => $this->menuTree($this->menues)
|
||||
@@ -140,7 +127,6 @@ class TenantResource extends JsonResource
|
||||
'subtitle' => $event->subtitle,
|
||||
'description' => $event->description,
|
||||
'location' => $event->location,
|
||||
'address' => AddressResource::make($event->address),
|
||||
'date_text' => $event->date_text,
|
||||
'dates' => $event->dates
|
||||
->filter(fn (EventDate $date): bool => $date->rescheduled_to_event_date_id === null)
|
||||
@@ -207,14 +193,12 @@ class TenantResource extends JsonResource
|
||||
'nombre' => $category->nombre,
|
||||
'subcategories' => $childrenByParent
|
||||
->get($category->id, collect())
|
||||
->filter(fn (Category $child) => $child->is_enabled)
|
||||
->map($formatCategory)
|
||||
->values(),
|
||||
];
|
||||
};
|
||||
|
||||
return $categories
|
||||
->filter(fn (Category $category) => $category->is_enabled)
|
||||
->filter(fn (Category $category) => $category->categoria_id === null
|
||||
|| ! $categoryIds->has($category->categoria_id))
|
||||
->map($formatCategory)
|
||||
@@ -242,7 +226,7 @@ class TenantResource extends JsonResource
|
||||
'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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Services;
|
||||
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Shared\Attachable\Enums\AttachmentType;
|
||||
use App\Shared\Attachable\Models\Attachment;
|
||||
use App\Shared\Attachable\Services\AttachmentService;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Throwable;
|
||||
|
||||
class BrandService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AttachmentService $attachmentService,
|
||||
private readonly WebsiteExtraService $websiteExtraService,
|
||||
) {}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function update(Tenant $tenant, array $data): void
|
||||
{
|
||||
$carousel = $tenant->storefront_website_type_code === 'shopit'
|
||||
? $tenant->websiteExtras()->whereHas('websiteTypeExtra',
|
||||
fn ($query) => $query->where('codigo', 'carousel'))->first()
|
||||
: null;
|
||||
$allowedBannerIds = is_array($carousel?->config) ? $carousel->config : [];
|
||||
if ($carousel !== null && ! $carousel->is_enabled && ($data['banner_images'] ?? []) !== []) {
|
||||
throw ValidationException::withMessages(['banner_images' => 'El carrusel está deshabilitado.']);
|
||||
}
|
||||
$seen = [];
|
||||
|
||||
foreach (['header_logo' => $tenant->header_logo_id, 'footer_logo' => $tenant->footer_logo_id] as $field => $id) {
|
||||
$this->existingImage($data[$field], $id === null ? [] : [$id], $field);
|
||||
}
|
||||
foreach ($data['banner_images'] ?? [] as $index => $item) {
|
||||
$image = is_array($item) ? $item['image'] : $item;
|
||||
$attachment = $this->existingImage($image, $allowedBannerIds, "banner_images.{$index}");
|
||||
if ($attachment !== null) {
|
||||
if (isset($seen[$attachment->id])) {
|
||||
throw ValidationException::withMessages(["banner_images.{$index}" => 'La imagen está repetida.']);
|
||||
}
|
||||
$seen[$attachment->id] = true;
|
||||
}
|
||||
}
|
||||
|
||||
$created = [];
|
||||
|
||||
try {
|
||||
DB::transaction(function () use ($tenant, $data, $carousel, &$created): void {
|
||||
$header = $this->logo($data['header_logo'], $tenant->header_logo_id, $tenant, $created);
|
||||
$footer = $this->logo($data['footer_logo'], $tenant->footer_logo_id, $tenant, $created);
|
||||
|
||||
$tenant->update([
|
||||
'nombre' => trim($data['nombre']),
|
||||
'primary_color' => $data['primary_color'],
|
||||
'footer_bg_color' => $data['footer_bg_color'],
|
||||
'header_logo_id' => $header?->id,
|
||||
'footer_logo_id' => $footer?->id,
|
||||
]);
|
||||
|
||||
if ($tenant->storefront_website_type_code !== 'shopit'
|
||||
|| ($carousel !== null && ! $carousel->is_enabled)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$definition = $this->websiteExtraService->definitionForTenant($tenant, 'carousel');
|
||||
$ids = [];
|
||||
$recrops = [];
|
||||
foreach ($data['banner_images'] as $item) {
|
||||
$image = is_array($item) ? $item['image'] : $item;
|
||||
$attachment = $this->existingImage($image, $carousel?->config ?? [], 'banner_images');
|
||||
$crops = $this->crops($item);
|
||||
|
||||
if ($attachment === null) {
|
||||
$attachment = $crops === null
|
||||
? $this->attachmentService->store($image, "tenants/{$tenant->codigo}/extras/carousel")
|
||||
: $this->attachmentService->storeCroppedImageVariants(
|
||||
$image, "tenants/{$tenant->codigo}/extras/carousel", $crops
|
||||
);
|
||||
$created[] = $attachment;
|
||||
} elseif ($crops !== null) {
|
||||
$recrops[] = [$attachment, $crops];
|
||||
}
|
||||
$ids[] = $attachment->id;
|
||||
}
|
||||
|
||||
$tenant->websiteExtras()->updateOrCreate(
|
||||
['website_type_extra_id' => $definition->id],
|
||||
['config' => $ids]
|
||||
);
|
||||
|
||||
foreach ($recrops as [$attachment, $crops]) {
|
||||
$this->attachmentService->updateImageCropVariants($attachment, $crops);
|
||||
}
|
||||
});
|
||||
} catch (Throwable $error) {
|
||||
foreach ($created as $attachment) {
|
||||
$paths = [$attachment->path];
|
||||
foreach ($attachment->cropVariants as $crop) {
|
||||
if ($crop->croppedAttachment !== null) {
|
||||
$paths[] = $crop->croppedAttachment->path;
|
||||
}
|
||||
}
|
||||
Storage::disk('s3')->delete($paths);
|
||||
}
|
||||
throw $error;
|
||||
}
|
||||
}
|
||||
|
||||
/** @param array<int, int> $allowedIds */
|
||||
private function existingImage(mixed $value, array $allowedIds, string $field): ?Attachment
|
||||
{
|
||||
if (! is_string($value) || ! Str::isUuid($value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$attachment = Attachment::query()->where('key', $value)->first();
|
||||
if ($attachment === null || $attachment->type !== AttachmentType::Image
|
||||
|| ! in_array($attachment->id, $allowedIds, true)) {
|
||||
throw ValidationException::withMessages([$field => 'La imagen no pertenece a esta marca.']);
|
||||
}
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
/** @param array<int, Attachment> $created */
|
||||
private function logo(mixed $value, ?int $currentId, Tenant $tenant, array &$created): ?Attachment
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$existing = $this->existingImage($value, $currentId === null ? [] : [$currentId], 'logo');
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
$attachment = $this->attachmentService->store($value, "tenants/{$tenant->codigo}/logos");
|
||||
$created[] = $attachment;
|
||||
|
||||
return $attachment;
|
||||
}
|
||||
|
||||
/** @return array<string, array<string, mixed>>|null */
|
||||
private function crops(mixed $item): ?array
|
||||
{
|
||||
if (! is_array($item)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (! isset($item['crop_horizontal'], $item['crop_vertical'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$crop = [
|
||||
'crop_horizontal' => $item['crop_horizontal'],
|
||||
'crop_vertical' => $item['crop_vertical'],
|
||||
];
|
||||
|
||||
return ['desktop' => $crop, 'mobile' => $crop];
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Services;
|
||||
|
||||
use App\Domains\Core\Address\Models\Address;
|
||||
use App\Domains\Core\PhoneNumber\Models\PhoneNumber;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ContactService
|
||||
{
|
||||
public function load(Tenant $tenant): Tenant
|
||||
{
|
||||
return $tenant->load([
|
||||
'socialMedia',
|
||||
'addresses' => fn ($query) => $query->reorder()->orderByPivot('is_main', 'desc')->orderByPivot('id'),
|
||||
'phoneNumbers',
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Tenant $tenant, array $data): Tenant
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data): Tenant {
|
||||
// Serialize contact writes, including changes to the principal links.
|
||||
Tenant::query()->whereKey($tenant->id)->lockForUpdate()->firstOrFail();
|
||||
$social = [];
|
||||
foreach ($data['social_media'] as $index => $item) {
|
||||
$social[$item['code']] = ['url' => $item['url'], 'orden' => $index];
|
||||
}
|
||||
$tenant->socialMedia()->sync($social);
|
||||
|
||||
foreach (['addresses', 'phone_numbers'] as $collection) {
|
||||
$relation = $collection === 'addresses' ? $tenant->addresses() : $tenant->phoneNumbers();
|
||||
$existing = $relation->get()->keyBy('id');
|
||||
$links = [];
|
||||
foreach (array_values($data[$collection]) as $index => $item) {
|
||||
$id = $item['id'] ?? null;
|
||||
if ($id !== null && ! $existing->has($id)) {
|
||||
throw ValidationException::withMessages(["{$collection}.{$index}.id" => 'El contacto no pertenece al comercio.']);
|
||||
}
|
||||
$record = $id === null
|
||||
? ($collection === 'addresses' ? new Address : new PhoneNumber)
|
||||
: $existing->get($id);
|
||||
$attributes = collect($item)->except(['id', 'is_main'])->all();
|
||||
$record->fill($attributes);
|
||||
if (! $record->exists) {
|
||||
$record->label = $collection === 'addresses' ? 'Dirección' : 'Teléfono';
|
||||
}
|
||||
if ($record->exists && $record->isDirty()) {
|
||||
$shared = $record->tenants()->where('tenants.codigo', '!=', $tenant->codigo)->exists()
|
||||
|| ($record instanceof Address && $record->events()->exists());
|
||||
if ($shared) {
|
||||
$record = $record->replicate();
|
||||
}
|
||||
// An edited street address must not retain coordinates for the old location.
|
||||
if ($record instanceof Address && $record->isDirty('address_text')) {
|
||||
$record->latitude = null;
|
||||
$record->longitude = null;
|
||||
}
|
||||
}
|
||||
$record->save();
|
||||
$links[$record->id] = ['is_main' => $index === 0];
|
||||
}
|
||||
// Clear the previous principal before sync to respect the unique index.
|
||||
$relation->newPivotStatement()->where('tenant_codigo', $tenant->codigo)->update(['is_main' => false]);
|
||||
$relation->sync($links);
|
||||
}
|
||||
|
||||
return $this->load($tenant);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Core\Tenant\Services;
|
||||
|
||||
use App\Domains\Core\Address\Models\Address;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class TenantAddressService
|
||||
{
|
||||
public function setMainAddress(Tenant $tenant, Address $address): void
|
||||
{
|
||||
DB::transaction(function () use ($tenant, $address): void {
|
||||
// Serialize changes for this tenant, including when it has no principal address yet.
|
||||
Tenant::query()->where('codigo', $tenant->codigo)->lockForUpdate()->firstOrFail();
|
||||
$tenant->addresses()->where('addresses.id', $address->id)->firstOrFail();
|
||||
|
||||
DB::table('tenant_addresses')->where('tenant_codigo', $tenant->codigo)
|
||||
->where('is_main', true)->update(['is_main' => false, 'updated_at' => now()]);
|
||||
$tenant->addresses()->updateExistingPivot($address->id, ['is_main' => true]);
|
||||
});
|
||||
|
||||
$tenant->unsetRelation('addresses');
|
||||
$tenant->unsetRelation('mainAddress');
|
||||
}
|
||||
}
|
||||
@@ -18,14 +18,9 @@ class TenantInformationService
|
||||
'footerBackgroundImage',
|
||||
'storefrontWebsiteType',
|
||||
'socialMedia',
|
||||
'addresses',
|
||||
'mainAddress',
|
||||
'phoneNumbers',
|
||||
'mainPhoneNumber',
|
||||
'websiteExtras.websiteTypeExtra',
|
||||
'activeEvent.dates',
|
||||
'activeEvent.socialMedia',
|
||||
'activeEvent.address',
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,7 +12,6 @@ Es la raíz del modelo multi-tenant. Gestiona organizaciones/sitios, tipos de we
|
||||
- `StorefrontWebsiteTypeExtra`: definición de un extra y su configuración admitida.
|
||||
- `WebsiteExtra`: valor resuelto y estado del extra para un tenant.
|
||||
- `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
|
||||
|
||||
@@ -28,16 +27,7 @@ El par `(dominio, base_path)` es único. Un mismo dominio puede alojar el tenant
|
||||
- `GET /tenants`, `GET /tenants/{codigo}` y `DELETE /tenants/{codigo}`.
|
||||
- La creación y actualización general de tenants se administran mediante seeders o base de datos; no se exponen por API.
|
||||
- Bajo `/v1/adminapp/tenant/website-extras`, con autenticación y contexto de tenant: consulta general, detalle, actualización y activación/desactivación.
|
||||
- `GET /v1/adminapp/tenant/contact` y `PUT /v1/adminapp/tenant/contact`, con `auth:sanctum` y `adminapp.tenant`: consulta y reemplazo completo del contacto del comercio autenticado.
|
||||
|
||||
El PUT de contacto recibe `social_media` (`code`, `url`), `addresses` (`id` opcional para existentes, `address_text`) y `phone_numbers` (`id` opcional, `country_code`, `mobile_prefix`, `area_code`, `local_number`). Las tres colecciones son obligatorias; un array vacío elimina sus vínculos. El primer elemento enviado en cada lista de direcciones o teléfonos es siempre el principal: el backend calcula `is_main` por posición e ignora las marcas enviadas por clientes anteriores. GET y PUT devuelven el principal primero para conservarlo en el siguiente guardado. Los IDs existentes deben estar vinculados al comercio autenticado. Todos los cambios se realizan en una transacción, serializada por tenant, y la respuesta devuelve los registros persistidos y sus IDs.
|
||||
|
||||
Se conservan etiquetas y coordenadas de direcciones sin modificar; al cambiar el texto se limpian las coordenadas para evitar mostrar el mapa de una ubicación anterior. Las ediciones de registros compartidos crean una copia para el comercio, preservando otros comercios y eventos. Las bajas desvinculan registros sin borrar datos compartidos.
|
||||
|
||||
Contacto administra exclusivamente las redes del comercio. `TenantResource.tenant_social_media` siempre expone esas redes y alimenta Contacto y el footer públicos. `social_media` mantiene la selección histórica del evento activo; `event.social_media` sigue disponible para las vistas de evento. Guardar Contacto no modifica redes del evento.
|
||||
|
||||
## 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.
|
||||
|
||||
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 y el adminapp las edita mediante el endpoint de contacto.
|
||||
|
||||
@@ -1,21 +1,11 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Core\Tenant\Controllers\AdminApp\BrandController;
|
||||
use App\Domains\Core\Tenant\Controllers\AdminApp\ContactController;
|
||||
use App\Domains\Core\Tenant\Controllers\AdminApp\HelpMenuController;
|
||||
use App\Domains\Core\Tenant\Controllers\AdminApp\WebsiteExtraController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->group(function (): void {
|
||||
Route::get('contact', [ContactController::class, 'show']);
|
||||
Route::put('contact', [ContactController::class, 'update']);
|
||||
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/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
|
||||
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace App\Domains\Ticketing\Desfile\Services;
|
||||
|
||||
use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||
use DateTimeInterface;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
@@ -394,22 +393,14 @@ class InvitationPurchaseProvisioner
|
||||
|
||||
$inventory = DB::table('inventories')->where('id', $variant->inventory_id)->lockForUpdate()->first();
|
||||
|
||||
$available = $inventory?->available_stock
|
||||
?? ($inventory === null ? 0 : $inventory->real_stock - $inventory->reserved_stock - ($inventory->entry_reserved_stock ?? 0));
|
||||
if ($available < 1) {
|
||||
if ($inventory === null || $inventory->real_stock < 1 || $inventory->reserved_stock > 0 || ($inventory->entry_reserved_stock ?? 0) > 0) {
|
||||
throw new RuntimeException("El asiento {$variant->descripcion} ya no está disponible.");
|
||||
}
|
||||
|
||||
if (property_exists($inventory, 'available_stock')) {
|
||||
Inventory::query()->findOrFail($inventory->id)->commitDirectSale(1);
|
||||
} else {
|
||||
// This provisioner is also used by historical migrations that run
|
||||
// before generated availability and the movement ledger exist.
|
||||
DB::table('inventories')->where('id', $inventory->id)->update([
|
||||
'real_stock' => $inventory->real_stock - 1,
|
||||
'sold_units' => $inventory->sold_units + 1,
|
||||
]);
|
||||
}
|
||||
|
||||
$reservationId = DB::table('compras')->where('id', $purchaseId)->value('stock_reservation_id');
|
||||
if ($reservationId === null) {
|
||||
|
||||
@@ -5,28 +5,28 @@ use App\Domains\Ticketing\Desfile\Controllers\EntryReservationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
Route::get('v1/adminapp/tenant/desfile/entry-reservations/{reservation}/ticket/pdf', [EntryReservationController::class, 'downloadTicketPdf'])
|
||||
->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');
|
||||
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');
|
||||
Route::delete('v1/adminapp/tenant/desfile/entry-reservations/{reservation}', [EntryReservationController::class, 'destroy'])
|
||||
->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');
|
||||
|
||||
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 {
|
||||
Route::get('entries', [EntryController::class, 'show'])
|
||||
->name('adminapp.desfile.entries.show');
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Domains\Ticketing\Event\Controllers\AdminApp;
|
||||
|
||||
use App\Domains\Core\Auth\Services\EventScopeService;
|
||||
use App\Domains\Ticketing\Event\Models\EventDate;
|
||||
use App\Domains\Ticketing\Event\Requests\RescheduleEventDateRequest;
|
||||
use App\Domains\Ticketing\Event\Requests\StoreEventDateRequest;
|
||||
@@ -19,7 +20,8 @@ class EventController extends Controller
|
||||
public function show(Request $request): EventResource
|
||||
{
|
||||
return EventResource::make(
|
||||
$this->eventService->forTenant($request->user()->tenant()->firstOrFail())
|
||||
$this->eventService->forTenant($request->user()->tenant()->firstOrFail(),
|
||||
app(EventScopeService::class)->eventId($request->user()))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +30,8 @@ class EventController extends Controller
|
||||
return EventResource::make(
|
||||
$this->eventService->updateForTenant(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated()
|
||||
$request->validated(),
|
||||
app(EventScopeService::class)->eventId($request->user())
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -39,6 +42,7 @@ class EventController extends Controller
|
||||
$this->eventService->createDateForTenant(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated(),
|
||||
app(EventScopeService::class)->eventId($request->user()),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Domains\Ticketing\Event\Models;
|
||||
|
||||
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\Tenant\Models\SocialMedia;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
@@ -16,14 +15,27 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||||
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', 'allow_ticket_refund', 'allow_ticket_total_refund', 'allow_ticket_partial_refund', 'ticket_partial_refund_percentage'])]
|
||||
class Event extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['client_id' => 'integer', 'address_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array'];
|
||||
return ['allow_ticket_refund' => 'boolean', 'allow_ticket_total_refund' => 'boolean',
|
||||
'allow_ticket_partial_refund' => 'boolean', 'ticket_partial_refund_percentage' => 'decimal:2', 'client_id' => 'integer', 'published_at' => 'datetime', 'exact_location' => 'array'];
|
||||
}
|
||||
|
||||
public function allow_refund(): bool
|
||||
{
|
||||
return (bool) $this->allow_ticket_refund
|
||||
&& ((bool) $this->allow_ticket_total_refund || $this->allow_partial_refund());
|
||||
}
|
||||
|
||||
public function allow_partial_refund(): bool
|
||||
{
|
||||
return (bool) $this->allow_ticket_refund && (bool) $this->allow_ticket_partial_refund
|
||||
&& (float) $this->ticket_partial_refund_percentage > 0;
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Tenant, $this> */
|
||||
@@ -38,12 +50,6 @@ class Event extends Model
|
||||
return $this->belongsTo(Client::class);
|
||||
}
|
||||
|
||||
/** @return BelongsTo<Address, $this> */
|
||||
public function address(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Address::class);
|
||||
}
|
||||
|
||||
public function effectiveClient(): Client
|
||||
{
|
||||
return $this->client ?? $this->tenant->client;
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
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\Services\EventDateGroupingService;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -22,16 +21,15 @@ class EventResource extends JsonResource
|
||||
'title' => $this->title,
|
||||
'subtitle' => $this->subtitle,
|
||||
'description' => $this->description,
|
||||
'address' => AddressResource::make($this->address),
|
||||
'location' => $this->location,
|
||||
'exact_location' => $this->exact_location,
|
||||
'date_text' => $this->date_text,
|
||||
'published_at' => $this->published_at?->toIso8601String(),
|
||||
'attachment_id' => $this->attachment_id,
|
||||
'allow_ticket_refund' => $this->tenant->allow_ticket_refund,
|
||||
'allow_ticket_total_refund' => $this->tenant->allow_ticket_total_refund,
|
||||
'allow_ticket_partial_refund' => $this->tenant->allow_ticket_partial_refund,
|
||||
'ticket_partial_refund_percentage' => $this->tenant->ticket_partial_refund_percentage,
|
||||
'allow_ticket_refund' => $this->allow_ticket_refund,
|
||||
'allow_ticket_total_refund' => $this->allow_ticket_total_refund,
|
||||
'allow_ticket_partial_refund' => $this->allow_ticket_partial_refund,
|
||||
'ticket_partial_refund_percentage' => $this->ticket_partial_refund_percentage,
|
||||
'dates' => EventDateResource::collection(
|
||||
app(EventDateGroupingService::class)->group($this->dates)
|
||||
),
|
||||
|
||||
@@ -5,7 +5,6 @@ namespace App\Domains\Ticketing\Event\Resources;
|
||||
use App\Domains\Commerce\Catalog\Enums\InventoryPolicy;
|
||||
use App\Domains\Commerce\Catalog\Models\CatalogItem;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
use App\Domains\Core\Address\Resources\AddressResource;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
@@ -20,7 +19,6 @@ class PublicEventResource extends JsonResource
|
||||
'title' => $this->title,
|
||||
'subtitle' => $this->subtitle,
|
||||
'description' => $this->description,
|
||||
'address' => AddressResource::make($this->address),
|
||||
'location' => $this->location,
|
||||
'exact_location' => $this->exact_location,
|
||||
'date_text' => $this->date_text,
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Domains\Ticketing\Event\Services;
|
||||
|
||||
use App\Domains\Core\Address\Models\Address;
|
||||
use App\Domains\Ticketing\Event\Models\Event;
|
||||
|
||||
class EventAddressService
|
||||
{
|
||||
/** Called inside the event update transaction, after validating the legacy location fields. */
|
||||
public function syncLocation(Event $event, bool $coordinatesProvided = false): void
|
||||
{
|
||||
$address = $event->address;
|
||||
$coordinates = $coordinatesProvided || $address === null ? $event->exact_location : $address->coordinates();
|
||||
$values = [
|
||||
'address_text' => $event->location,
|
||||
'latitude' => $coordinates['latitude'] ?? null,
|
||||
'longitude' => $coordinates['longitude'] ?? null,
|
||||
];
|
||||
|
||||
if ($address !== null && ! (clone $address)->fill($values)->isDirty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Editing one event must not change the location of another event or a tenant.
|
||||
if ($address === null || $address->tenants()->exists()
|
||||
|| $address->events()->where('events.id', '!=', $event->id)->exists()) {
|
||||
$address = Address::create(['label' => $address?->label ?? 'Ubicación del evento', ...$values]);
|
||||
$event->address()->associate($address);
|
||||
$event->save();
|
||||
} else {
|
||||
$address->update($values);
|
||||
}
|
||||
|
||||
$event->setRelation('address', $address);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
use App\Domains\Commerce\Catalog\Services\StockReservationService;
|
||||
use App\Domains\Commerce\Catalog\Services\VariantReplacementService;
|
||||
use App\Domains\Core\Auth\Models\User;
|
||||
use App\Domains\Core\Auth\Services\EventScopeService;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use App\Domains\Ticketing\Event\Enums\EventDateChangeType;
|
||||
use App\Domains\Ticketing\Event\Events\EventDateRescheduled;
|
||||
@@ -32,26 +33,24 @@ class EventService
|
||||
private readonly AffectedEventDatePurchaseResolver $affectedPurchaseResolver,
|
||||
private readonly VariantReplacementService $variantReplacementService,
|
||||
private readonly InvalidateEventDateCartsService $invalidateEventDateCarts,
|
||||
private readonly EventAddressService $eventAddressService,
|
||||
) {}
|
||||
|
||||
public function forTenant(Tenant $tenant): Event
|
||||
public function forTenant(Tenant $tenant, ?int $eventId = null): Event
|
||||
{
|
||||
return $tenant->activeEvent->load(['dates.validityTime', 'socialMedia', 'address']);
|
||||
return $this->resolveEvent($tenant, $eventId)->load(['dates.validityTime', 'socialMedia']);
|
||||
}
|
||||
|
||||
/** @param array<string, mixed> $data */
|
||||
public function updateForTenant(Tenant $tenant, array $data): Event
|
||||
public function updateForTenant(Tenant $tenant, array $data, ?int $eventId = null): Event
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data): Event {
|
||||
$event = $tenant->activeEvent;
|
||||
return DB::transaction(function () use ($tenant, $data, $eventId): Event {
|
||||
$event = $this->resolveEvent($tenant, $eventId);
|
||||
$event->update([
|
||||
'title' => $data['title'],
|
||||
'location' => $data['location'],
|
||||
...array_intersect_key($data, ['attachment_id' => true, 'exact_location' => true]),
|
||||
]);
|
||||
$this->eventAddressService->syncLocation($event, array_key_exists('exact_location', $data));
|
||||
$tenant->update([
|
||||
$event->update([
|
||||
...array_intersect_key($data, array_flip([
|
||||
'allow_ticket_refund',
|
||||
'allow_ticket_total_refund',
|
||||
@@ -66,15 +65,15 @@ class EventService
|
||||
$this->syncLegacyContact($event, $data['contact']);
|
||||
}
|
||||
|
||||
return $event->load(['dates.validityTime', 'socialMedia', 'address']);
|
||||
return $event->load(['dates.validityTime', 'socialMedia']);
|
||||
});
|
||||
}
|
||||
|
||||
/** @param array{date: string, start_time: string, end_time: string} $data */
|
||||
public function createDateForTenant(Tenant $tenant, array $data): EventDate
|
||||
public function createDateForTenant(Tenant $tenant, array $data, ?int $eventId = null): EventDate
|
||||
{
|
||||
return DB::transaction(function () use ($tenant, $data): EventDate {
|
||||
$event = $tenant->activeEvent;
|
||||
return DB::transaction(function () use ($tenant, $data, $eventId): EventDate {
|
||||
$event = $this->resolveEvent($tenant, $eventId);
|
||||
$attributes = $this->dateAttributes($data);
|
||||
|
||||
if ($event->dates()->where($attributes)->exists()) {
|
||||
@@ -95,7 +94,7 @@ class EventService
|
||||
?User $createdBy = null,
|
||||
): EventDate {
|
||||
return DB::transaction(function () use ($tenant, $eventDate, $data, $createdBy): EventDate {
|
||||
$source = $this->lockedDateForTenant($tenant, $eventDate);
|
||||
$source = $this->lockedDateForTenant($tenant, $eventDate, $createdBy);
|
||||
|
||||
if ($source->suspended_at !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -174,7 +173,7 @@ class EventService
|
||||
?User $createdBy = null,
|
||||
): EventDate {
|
||||
return DB::transaction(function () use ($tenant, $eventDate, $createdBy): EventDate {
|
||||
$date = $this->lockedDateForTenant($tenant, $eventDate);
|
||||
$date = $this->lockedDateForTenant($tenant, $eventDate, $createdBy);
|
||||
|
||||
if ($date->rescheduled_to_event_date_id !== null) {
|
||||
throw ValidationException::withMessages([
|
||||
@@ -218,10 +217,18 @@ class EventService
|
||||
});
|
||||
}
|
||||
|
||||
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate): EventDate
|
||||
private function resolveEvent(Tenant $tenant, ?int $eventId): Event
|
||||
{
|
||||
return Event::query()->where('tenant_code', $tenant->codigo)
|
||||
->findOrFail($eventId ?? $tenant->active_event_id);
|
||||
}
|
||||
|
||||
private function lockedDateForTenant(Tenant $tenant, EventDate $eventDate, ?User $actor = null): EventDate
|
||||
{
|
||||
return $tenant->eventDates()
|
||||
->whereKey($eventDate->getKey())
|
||||
->when($actor !== null && ! $actor->isTenantAdministrator(),
|
||||
fn ($query) => $query->where('event_id', app(EventScopeService::class)->eventId($actor)))
|
||||
->lockForUpdate()
|
||||
->firstOrFail();
|
||||
}
|
||||
|
||||
@@ -23,12 +23,11 @@ class PublicEventService
|
||||
$query->where(function ($query) use ($term): void {
|
||||
$query->where('title', 'like', '%'.$term.'%')
|
||||
->orWhere('subtitle', 'like', '%'.$term.'%')
|
||||
->orWhereHas('address', fn ($query) => $query->where('address_text', 'like', '%'.$term.'%'))
|
||||
->orWhere(fn ($query) => $query->whereNull('address_id')->where('location', 'like', '%'.$term.'%'));
|
||||
->orWhere('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
|
||||
@@ -39,7 +38,6 @@ class PublicEventService
|
||||
|
||||
return $event->load([
|
||||
'attachment',
|
||||
'address',
|
||||
'dates' => fn ($query) => $query->whereNull('rescheduled_to_event_date_id')->whereNull('suspended_at'),
|
||||
'socialMedia',
|
||||
'catalogItems.attachments',
|
||||
|
||||
@@ -13,32 +13,6 @@ mediante `event_id`. El catálogo `social_media` define las plataformas;
|
||||
`exact_location` guarda opcionalmente un objeto JSON con `latitude` (-90 a 90)
|
||||
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
|
||||
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
|
||||
@@ -63,10 +37,10 @@ se guardan en el evento. Sin evento activo se conservan las redes propias del te
|
||||
|
||||
## API
|
||||
|
||||
Los endpoints de AdminApp usan `auth:sanctum` y `adminapp.tenant`:
|
||||
Los endpoints de AdminApp usan `auth:sanctum` y `adminapp.tenant:event`:
|
||||
|
||||
- `GET/PUT /v1/adminapp/tenant/event`: acceso al evento seleccionado para el
|
||||
storefront de evento único.
|
||||
- `GET/PUT /v1/adminapp/tenant/event`: acceso al evento asociado al usuario con scope de evento;
|
||||
para admins de tenant se conserva el evento activo.
|
||||
- `POST /v1/adminapp/tenant/event-dates`: crea una fecha para ese evento.
|
||||
- `POST /v1/adminapp/tenant/event-dates/{eventDate}/reschedule` y
|
||||
`POST /v1/adminapp/tenant/event-dates/{eventDate}/suspend`: cambios de fecha.
|
||||
@@ -79,3 +53,8 @@ una lista de eventos ni existe todavía una pantalla para gestionarlos.
|
||||
Las fechas se vinculan con variantes de `Catalog`, que a su vez pueden generar
|
||||
tickets. Los avisos por suspensión y reprogramación se construyen dinámicamente
|
||||
después de excluir los cambios que el usuario ya vio tres veces.
|
||||
|
||||
La configuración de devoluciones se persiste en `events`, se entrega en
|
||||
`EventResource` y se aplica al evento de cada ticket. La migración inicial copia
|
||||
los valores anteriores del tenant a sus eventos. Las fechas se autorizan por
|
||||
tenant y por evento antes de cualquier suspensión o reprogramación.
|
||||
|
||||
@@ -4,7 +4,7 @@ use App\Domains\Ticketing\Event\Controllers\AdminApp\EventController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::prefix('v1/adminapp/tenant')
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||
->middleware(['auth:sanctum', 'adminapp.tenant:event'])
|
||||
->group(function (): void {
|
||||
Route::get('event', [EventController::class, 'show']);
|
||||
Route::put('event', [EventController::class, 'update']);
|
||||
|
||||
@@ -28,7 +28,6 @@ class AccommodationController extends Controller
|
||||
$this->accommodationService->upsertMany(
|
||||
$tenant,
|
||||
$request->validated('variants'),
|
||||
$request->validated('stock_adjustment_id'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ class EntryController extends Controller
|
||||
$entries = $this->entryService->upsertMany(
|
||||
$tenant,
|
||||
$request->validated('entries'),
|
||||
$request->validated('stock_adjustment_id'),
|
||||
);
|
||||
|
||||
return EntryResource::collection($entries)
|
||||
|
||||
@@ -29,7 +29,6 @@ class FoodController extends Controller
|
||||
$this->foodService->upsertMany(
|
||||
$tenant,
|
||||
$request->validated('variants'),
|
||||
$request->validated('stock_adjustment_id'),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -40,7 +39,6 @@ class FoodController extends Controller
|
||||
$this->foodService->updateHistoricalStock(
|
||||
$request->user()->tenant()->firstOrFail(),
|
||||
$request->validated('variants'),
|
||||
$request->validated('stock_adjustment_id'),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,7 +28,6 @@ class MerchandiseController extends Controller
|
||||
$items = $this->merchandiseService->upsertMany(
|
||||
$tenant,
|
||||
$request->validated('items'),
|
||||
$request->validated('stock_adjustment_id'),
|
||||
);
|
||||
|
||||
return MerchandiseResource::collection($items)
|
||||
|
||||
@@ -16,10 +16,9 @@ class UpdateHistoricalFoodStockRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
||||
'variants.*' => ['required', 'array:id,stock_difference'],
|
||||
'variants.*' => ['required', 'array:id,stock'],
|
||||
'variants.*.id' => ['required', 'integer', 'distinct'],
|
||||
'variants.*.stock_difference' => ['required', 'integer'],
|
||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
namespace App\Domains\Ticketing\FiestaFutbolInfantil\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpsertAccommodationVariantsRequest extends FormRequest
|
||||
{
|
||||
@@ -17,42 +16,12 @@ class UpsertAccommodationVariantsRequest extends FormRequest
|
||||
{
|
||||
return [
|
||||
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
||||
'variants.*' => ['required', 'array:id,title,description,stock,stock_difference,price'],
|
||||
'variants.*' => ['required', 'array:id,title,description,stock,price'],
|
||||
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
|
||||
'variants.*.title' => ['required', 'string', 'max:255'],
|
||||
'variants.*.description' => ['sometimes', 'nullable', 'string'],
|
||||
'variants.*.stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'variants.*.stock_difference' => ['sometimes', 'integer'],
|
||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, callable> */
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator): void {
|
||||
foreach ($this->input('variants', []) as $index => $variant) {
|
||||
if (! is_array($variant)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isset($variant['id']) && ! array_key_exists('stock_difference', $variant)) {
|
||||
$validator->errors()->add(
|
||||
"variants.{$index}.stock_difference",
|
||||
'La diferencia de stock es obligatoria al actualizar una variante.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! isset($variant['id']) && ! array_key_exists('stock', $variant)) {
|
||||
$validator->errors()->add(
|
||||
"variants.{$index}.stock",
|
||||
'El stock inicial es obligatorio al crear una variante.',
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,8 +20,7 @@ class UpsertEntriesRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'entries' => ['required', 'array', 'min:1', 'max:100'],
|
||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
||||
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,stock_difference,price'],
|
||||
'entries.*' => ['required', 'array:id,title,description,event_date_ids,stock,price'],
|
||||
'entries.*.id' => [
|
||||
'sometimes',
|
||||
'nullable',
|
||||
@@ -47,8 +46,7 @@ class UpsertEntriesRequest extends FormRequest
|
||||
fn ($query) => $query->where('tenant_code', $tenantCode)
|
||||
),
|
||||
],
|
||||
'entries.*.stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'entries.*.stock_difference' => ['sometimes', 'integer'],
|
||||
'entries.*.stock' => ['required', 'integer', 'min:0'],
|
||||
'entries.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||
];
|
||||
}
|
||||
@@ -63,20 +61,6 @@ class UpsertEntriesRequest extends FormRequest
|
||||
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'] ?? [];
|
||||
|
||||
if (! is_array($dateIds)) {
|
||||
|
||||
@@ -20,8 +20,7 @@ class UpsertFoodVariantsRequest extends FormRequest
|
||||
|
||||
return [
|
||||
'variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
||||
'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,stock_difference,price'],
|
||||
'variants.*' => ['required', 'array:id,event_date_id,schedule,service,description,stock,price'],
|
||||
'variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
|
||||
'variants.*.event_date_id' => [
|
||||
'required',
|
||||
@@ -33,8 +32,7 @@ class UpsertFoodVariantsRequest extends FormRequest
|
||||
'variants.*.schedule' => ['required', 'string', 'max:255'],
|
||||
'variants.*.service' => ['required', 'string', 'max:255'],
|
||||
'variants.*.description' => ['sometimes', 'nullable', 'string'],
|
||||
'variants.*.stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'variants.*.stock_difference' => ['sometimes', 'integer'],
|
||||
'variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||
'variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||
];
|
||||
}
|
||||
@@ -51,20 +49,6 @@ class UpsertFoodVariantsRequest extends FormRequest
|
||||
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('|', [
|
||||
$variant['event_date_id'] ?? '',
|
||||
mb_strtolower(trim((string) ($variant['schedule'] ?? ''))),
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace App\Domains\Ticketing\FiestaFutbolInfantil\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Validator;
|
||||
|
||||
class UpsertMerchandiseRequest extends FormRequest
|
||||
{
|
||||
@@ -40,49 +39,12 @@ class UpsertMerchandiseRequest extends FormRequest
|
||||
'items.*.description' => ['sometimes', 'nullable', 'string'],
|
||||
'items.*.max_units_per_user' => ['required', 'integer', 'min:1'],
|
||||
'items.*.variants' => ['required', 'array', 'min:1', 'max:500'],
|
||||
'stock_adjustment_id' => ['nullable', 'uuid'],
|
||||
'items.*.variants.*' => ['required', 'array:id,color,size,stock,stock_difference,price'],
|
||||
'items.*.variants.*' => ['required', 'array:id,color,size,stock,price'],
|
||||
'items.*.variants.*.id' => ['sometimes', 'nullable', 'integer', 'distinct'],
|
||||
'items.*.variants.*.color' => ['required', 'string', 'max:255'],
|
||||
'items.*.variants.*.size' => ['required', 'string', 'max:255'],
|
||||
'items.*.variants.*.stock' => ['sometimes', 'integer', 'min:0'],
|
||||
'items.*.variants.*.stock_difference' => ['sometimes', 'integer'],
|
||||
'items.*.variants.*.stock' => ['required', 'integer', 'min:0'],
|
||||
'items.*.variants.*.price' => ['required', 'numeric', 'min:0', 'max:99999999.99'],
|
||||
];
|
||||
}
|
||||
|
||||
/** @return array<int, callable> */
|
||||
public function after(): array
|
||||
{
|
||||
return [
|
||||
function (Validator $validator): void {
|
||||
foreach ($this->input('items', []) as $itemIndex => $item) {
|
||||
if (! is_array($item)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach ($item['variants'] ?? [] as $variantIndex => $variant) {
|
||||
if (! is_array($variant)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$prefix = "items.{$itemIndex}.variants.{$variantIndex}";
|
||||
if (isset($variant['id']) && ! array_key_exists('stock_difference', $variant)) {
|
||||
$validator->errors()->add(
|
||||
"{$prefix}.stock_difference",
|
||||
'La diferencia de stock es obligatoria al actualizar una variante.',
|
||||
);
|
||||
}
|
||||
|
||||
if (! isset($variant['id']) && ! array_key_exists('stock', $variant)) {
|
||||
$validator->errors()->add(
|
||||
"{$prefix}.stock",
|
||||
'El stock inicial es obligatorio al crear una variante.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class AccommodationResource extends JsonResource
|
||||
'title' => $options->get($value)?->label ?? $value,
|
||||
'value' => $value,
|
||||
'description' => $variant->descripcion,
|
||||
'stock' => $variant->inventory->availableStock(),
|
||||
'stock' => $variant->inventory->real_stock,
|
||||
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
];
|
||||
})->values(),
|
||||
|
||||
@@ -33,7 +33,7 @@ class EntryResource extends JsonResource
|
||||
'title' => $this->nombre,
|
||||
'description' => $this->descripcion,
|
||||
'event_date_ids' => $variant->selectedEventDates()->pluck('id')->values(),
|
||||
'stock' => $variant->inventory->availableStock(),
|
||||
'stock' => $variant->inventory->real_stock,
|
||||
'price' => $this->precio,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class FoodResource extends JsonResource
|
||||
'schedule' => $values->get('horario'),
|
||||
'service' => $values->get('servicio'),
|
||||
'description' => $variant->descripcion,
|
||||
'stock' => $variant->inventory->availableStock(),
|
||||
'stock' => $variant->inventory->real_stock,
|
||||
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ class MerchandiseResource extends JsonResource
|
||||
'color_value' => $colorValue,
|
||||
'size' => $sizeOptions->get($sizeValue)?->label ?? $sizeValue,
|
||||
'size_value' => $sizeValue,
|
||||
'stock' => $variant->inventory->availableStock(),
|
||||
'stock' => $variant->inventory->real_stock,
|
||||
'price' => number_format($variant->getPrice(), 2, '.', ''),
|
||||
];
|
||||
})->values(),
|
||||
|
||||
@@ -39,9 +39,9 @@ class AccommodationService
|
||||
/**
|
||||
* @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);
|
||||
$accommodation = $this->accommodation($tenant, $variants);
|
||||
$itemAttribute = $accommodation->itemAttributes()->firstOrCreate(
|
||||
@@ -70,7 +70,7 @@ class AccommodationService
|
||||
if ($variant === null) {
|
||||
$this->createVariant($attribute, $accommodation, $itemAttribute, $data);
|
||||
} 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']),
|
||||
'value' => $this->valueCode($variant['title']),
|
||||
'description' => $variant['description'] ?? null,
|
||||
...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
|
||||
'stock' => (int) $variant['stock'],
|
||||
])->all();
|
||||
}
|
||||
|
||||
@@ -212,7 +212,6 @@ class AccommodationService
|
||||
$this->createOption($attribute, $data['value'], $data['title']);
|
||||
|
||||
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
|
||||
$inventory->recordInitialization();
|
||||
$variant = $accommodation->variants()->create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'descripcion' => $data['description'],
|
||||
@@ -231,13 +230,20 @@ class AccommodationService
|
||||
ItemAttribute $itemAttribute,
|
||||
array $data,
|
||||
int $index,
|
||||
?string $stockAdjustmentId,
|
||||
): void {
|
||||
$inventory = Inventory::query()
|
||||
->whereKey($variant->inventory_id)
|
||||
->lockForUpdate()
|
||||
->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
|
||||
->firstWhere('item_attribute_id', $itemAttribute->id);
|
||||
$option = $definition === null
|
||||
@@ -257,10 +263,7 @@ class AccommodationService
|
||||
'descripcion' => $data['description'],
|
||||
'precio' => $data['price'],
|
||||
]);
|
||||
$inventory->adjustAvailableStock(
|
||||
(int) $data['stock_difference'],
|
||||
idempotencyKey: $stockAdjustmentId,
|
||||
);
|
||||
$inventory->update(['real_stock' => $data['stock']]);
|
||||
$variant->definitions()->updateOrCreate(
|
||||
['item_attribute_id' => $itemAttribute->id],
|
||||
['value' => $data['value']],
|
||||
|
||||
@@ -43,18 +43,18 @@ class EntryService
|
||||
* @param array<int, array<string, mixed>> $entries
|
||||
* @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 = [];
|
||||
$category = Category::query()->firstOrCreate([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
'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'])) {
|
||||
return $this->update($tenant, $category, $entry, $index, $stockAdjustmentId);
|
||||
return $this->update($tenant, $category, $entry, $index);
|
||||
}
|
||||
|
||||
$slug = $this->uniqueSlug($tenant, $entry['title'], $reservedSlugs);
|
||||
@@ -92,7 +92,7 @@ class EntryService
|
||||
}
|
||||
|
||||
/** @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()
|
||||
->whereKey($entry['id'])
|
||||
@@ -121,6 +121,14 @@ class EntryService
|
||||
->lockForUpdate()
|
||||
->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'])
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
@@ -141,10 +149,7 @@ class EntryService
|
||||
$catalogItem->itemAttributes()
|
||||
->whereHas('attribute', fn ($query) => $query->where('codigo', 'event_date'))
|
||||
->update(['allow_multi_select' => true]);
|
||||
$inventory->adjustAvailableStock(
|
||||
(int) $entry['stock_difference'],
|
||||
idempotencyKey: $stockAdjustmentId,
|
||||
);
|
||||
$inventory->update(['real_stock' => $entry['stock']]);
|
||||
|
||||
return $catalogItem->load([
|
||||
'variants.inventory',
|
||||
|
||||
@@ -11,9 +11,9 @@ use App\Domains\Commerce\Catalog\Models\Inventory;
|
||||
use App\Domains\Commerce\Catalog\Models\ItemAttribute;
|
||||
use App\Domains\Commerce\Catalog\Models\Variant;
|
||||
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\Models\EventDate;
|
||||
use App\Domains\Core\Tenant\Models\Tenant;
|
||||
use Illuminate\Support\Collection;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
@@ -50,9 +50,9 @@ class FoodService
|
||||
/**
|
||||
* @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);
|
||||
$food = $this->food($tenant, $variants);
|
||||
$itemAttributes = $this->itemAttributes($food, $attributes);
|
||||
@@ -85,7 +85,7 @@ class FoodService
|
||||
if ($variant === null) {
|
||||
$this->createVariant($food, $itemAttributes, $data);
|
||||
} 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()
|
||||
->forTenantCatalog($tenant)
|
||||
->where('slug', 'comida')
|
||||
@@ -142,11 +142,17 @@ class FoodService
|
||||
]);
|
||||
}
|
||||
|
||||
$stock = (int) $data['stock'];
|
||||
$inventory = $this->inventoryForHistoricalStockUpdate($variant);
|
||||
$inventory->adjustAvailableStock(
|
||||
(int) $data['stock_difference'],
|
||||
idempotencyKey: $stockAdjustmentId,
|
||||
);
|
||||
if ($stock < $inventory->reserved_stock) {
|
||||
throw ValidationException::withMessages([
|
||||
"variants.{$index}.stock" => [
|
||||
'El stock no puede ser menor que la cantidad actualmente reservada.',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$inventory->update(['real_stock' => $stock]);
|
||||
}
|
||||
|
||||
return $this->current($tenant) ?? $food;
|
||||
@@ -175,7 +181,6 @@ class FoodService
|
||||
'reserved_stock' => 0,
|
||||
'real_stock' => $inventory->real_stock,
|
||||
]);
|
||||
$historicalInventory->recordTransferInitialization();
|
||||
$variant->update(['inventory_id' => $historicalInventory->getKey()]);
|
||||
|
||||
return $historicalInventory;
|
||||
@@ -296,7 +301,7 @@ class FoodService
|
||||
'schedule' => $schedule->value,
|
||||
'service' => $service->value,
|
||||
'description' => (string) ($variant['description'] ?? ''),
|
||||
...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
|
||||
'stock' => (int) $variant['stock'],
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
@@ -379,7 +384,6 @@ class FoodService
|
||||
private function createVariant(CatalogItem $food, Collection $itemAttributes, array $data): void
|
||||
{
|
||||
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
|
||||
$inventory->recordInitialization();
|
||||
$variant = $food->variants()->create([
|
||||
'event_date_id' => $data['event_date_id'],
|
||||
'inventory_id' => $inventory->id,
|
||||
@@ -396,23 +400,27 @@ class FoodService
|
||||
Collection $itemAttributes,
|
||||
array $data,
|
||||
int $index,
|
||||
?string $stockAdjustmentId,
|
||||
): void {
|
||||
$inventory = Inventory::query()
|
||||
->whereKey($variant->inventory_id)
|
||||
->lockForUpdate()
|
||||
->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([
|
||||
'event_date_id' => $data['event_date_id'],
|
||||
'descripcion' => $data['description'],
|
||||
'precio' => $data['price'],
|
||||
]);
|
||||
$variant->eventDates()->sync([$data['event_date_id']]);
|
||||
$inventory->adjustAvailableStock(
|
||||
(int) $data['stock_difference'],
|
||||
idempotencyKey: $stockAdjustmentId,
|
||||
);
|
||||
$inventory->update(['real_stock' => $data['stock']]);
|
||||
$this->syncDefinitions($variant, $itemAttributes, $data);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,9 @@ class MerchandiseService
|
||||
* @param array<int, array<string, mixed>> $items
|
||||
* @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);
|
||||
$category = Category::query()->firstOrCreate([
|
||||
'tenant_code' => $tenant->codigo,
|
||||
@@ -57,7 +57,6 @@ class MerchandiseService
|
||||
$tenant,
|
||||
$attributes,
|
||||
$category,
|
||||
$stockAdjustmentId,
|
||||
&$reservedSlugs,
|
||||
): CatalogItem {
|
||||
$item = isset($data['id'])
|
||||
@@ -102,7 +101,7 @@ class MerchandiseService
|
||||
if ($variant === null) {
|
||||
$this->createVariant($item, $itemAttributes, $variantData);
|
||||
} else {
|
||||
$this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex, $stockAdjustmentId);
|
||||
$this->updateVariant($variant, $itemAttributes, $variantData, $index, $variantIndex);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -245,7 +244,7 @@ class MerchandiseService
|
||||
...$variant,
|
||||
'color' => $color->value,
|
||||
'size' => $size->value,
|
||||
...(array_key_exists('stock', $variant) ? ['stock' => (int) $variant['stock']] : []),
|
||||
'stock' => (int) $variant['stock'],
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
@@ -343,7 +342,6 @@ class MerchandiseService
|
||||
array $data,
|
||||
): void {
|
||||
$inventory = Inventory::query()->create(['real_stock' => $data['stock']]);
|
||||
$inventory->recordInitialization();
|
||||
$variant = $item->variants()->create([
|
||||
'inventory_id' => $inventory->id,
|
||||
'precio' => $data['price'],
|
||||
@@ -361,18 +359,22 @@ class MerchandiseService
|
||||
array $data,
|
||||
int $itemIndex,
|
||||
int $variantIndex,
|
||||
?string $stockAdjustmentId,
|
||||
): void {
|
||||
$inventory = Inventory::query()
|
||||
->whereKey($variant->inventory_id)
|
||||
->lockForUpdate()
|
||||
->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']]);
|
||||
$inventory->adjustAvailableStock(
|
||||
(int) $data['stock_difference'],
|
||||
idempotencyKey: $stockAdjustmentId,
|
||||
);
|
||||
$inventory->update(['real_stock' => $data['stock']]);
|
||||
$this->syncDefinitions($variant, $itemAttributes, $data);
|
||||
}
|
||||
|
||||
|
||||
@@ -19,48 +19,48 @@ Route::prefix('v1/adminapp/tenant')
|
||||
->middleware("tenant.menu:{$menuCode}");
|
||||
};
|
||||
|
||||
$visibilityRoutes('entries', 'entries', 'onticket.adminapp.fiesta-futbol-infantil.entradas');
|
||||
$visibilityRoutes('foods', 'foods', 'onticket.adminapp.fiesta-futbol-infantil.comida');
|
||||
$visibilityRoutes('accommodations', 'accommodations', 'onticket.adminapp.fiesta-futbol-infantil.alojamientos');
|
||||
$visibilityRoutes('merchandise', 'merchandise', 'onticket.adminapp.fiesta-futbol-infantil.merchandising');
|
||||
$visibilityRoutes('entries', 'entries', 'adminapp.fiesta-futbol-infantil.entradas');
|
||||
$visibilityRoutes('foods', 'foods', 'adminapp.fiesta-futbol-infantil.comida');
|
||||
$visibilityRoutes('accommodations', 'accommodations', 'adminapp.fiesta-futbol-infantil.alojamientos');
|
||||
$visibilityRoutes('merchandise', 'merchandise', 'adminapp.fiesta-futbol-infantil.merchandising');
|
||||
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -29,7 +29,7 @@ class TicketController extends Controller
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return new AdminAppTicketCollection(
|
||||
$this->ticketService->search($tenant, $request->validated(), $request->user()->event_id)
|
||||
$this->ticketService->search($tenant, $request->validated())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ class TicketController extends Controller
|
||||
{
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket, $request->user()->event_id));
|
||||
return new AdminAppTicketResource($this->ticketService->cancel($tenant, $ticket));
|
||||
}
|
||||
|
||||
public function calculateRefund(Request $request, int $ticket): AdminAppTicketRefundCalculationResource
|
||||
@@ -45,7 +45,7 @@ class TicketController extends Controller
|
||||
$tenant = $request->user()->tenant()->firstOrFail();
|
||||
|
||||
return new AdminAppTicketRefundCalculationResource(
|
||||
$this->ticketService->calculateRefund($tenant, $ticket, $request->user()->event_id)
|
||||
$this->ticketService->calculateRefund($tenant, $ticket)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class TicketController extends Controller
|
||||
|
||||
return $this->ticketPdfService->download(
|
||||
$tenant,
|
||||
$this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
@@ -80,7 +80,7 @@ class TicketController extends Controller
|
||||
|
||||
return $this->ticketExcelService->download(
|
||||
$tenant,
|
||||
$this->ticketService->ticketsForExport($tenant, $request->validated(), $request->user()->event_id),
|
||||
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
||||
$request->validated('timezone'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
#[Fillable([
|
||||
'tenant_code',
|
||||
'event_id',
|
||||
'scanner_user_id',
|
||||
'ticket_id',
|
||||
'data',
|
||||
@@ -43,6 +44,7 @@ class ScanAttempt extends Model
|
||||
{
|
||||
return [
|
||||
'scanner_user_id' => 'integer',
|
||||
'event_id' => 'integer',
|
||||
'ticket_id' => 'integer',
|
||||
'result' => ScanAttemptResult::class,
|
||||
'created_at' => 'datetime',
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user