Compare commits
17 Commits
feature/ti
...
6d123cd403
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d123cd403 | |||
| 3164a00e6c | |||
| 36cbc8da29 | |||
| 58e027cad5 | |||
| 971c5f6cd9 | |||
| 2524f00dfd | |||
| 045fcfab72 | |||
| cfe29b41a9 | |||
| bd600649b6 | |||
| 7685089540 | |||
| 889e188deb | |||
| 4a166e3cbf | |||
| d8b3a354d9 | |||
| 44f75185a1 | |||
| 3adef9341e | |||
| 3b1698ffd1 | |||
| c436302d7a |
@@ -5,10 +5,6 @@ APP_DEBUG=false
|
|||||||
APP_URL=http://localhost
|
APP_URL=http://localhost
|
||||||
|
|
||||||
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
PURCHASE_CHECKOUT_EXPIRATION_MINUTES=30
|
||||||
PURCHASE_QR_EXPIRATION_MINUTES=15
|
|
||||||
PURCHASE_TELEPAGOS_EXPIRATION_MINUTES=30
|
|
||||||
PURCHASE_TRANSFER_EXPIRATION_MINUTES=1440
|
|
||||||
PURCHASE_TRANSFER_CANDIDATE_AMOUNT_TOLERANCE_PERCENTAGE=5
|
|
||||||
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
STOCK_RESERVATION_EXPIRATION_MINUTES=30
|
||||||
FRONTEND_URLS=http://localhost:4200
|
FRONTEND_URLS=http://localhost:4200
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Controllers\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
|
||||||
use App\Domains\Catalog\Requests\AdminApp\UpsertOnTicketFeaturedGroupRequest;
|
|
||||||
use App\Domains\Catalog\Resources\AdminApp\OnTicketFeaturedGroupResource;
|
|
||||||
use App\Domains\Catalog\Services\OnTicketFeaturedGroupService;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
|
||||||
|
|
||||||
class OnTicketFeaturedGroupController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly OnTicketFeaturedGroupService $featuredGroupService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
|
||||||
{
|
|
||||||
return OnTicketFeaturedGroupResource::collection(
|
|
||||||
$this->featuredGroupService->forTenant($this->onTicketTenant($request))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(UpsertOnTicketFeaturedGroupRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$featuredGroup = $this->featuredGroupService->create(
|
|
||||||
$this->onTicketTenant($request),
|
|
||||||
$request->validated(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return OnTicketFeaturedGroupResource::make($featuredGroup)
|
|
||||||
->response()
|
|
||||||
->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(
|
|
||||||
UpsertOnTicketFeaturedGroupRequest $request,
|
|
||||||
FeaturedGroup $featuredGroup,
|
|
||||||
): OnTicketFeaturedGroupResource {
|
|
||||||
$tenant = $this->onTicketTenant($request);
|
|
||||||
|
|
||||||
abort_unless($featuredGroup->tenant_code === $tenant->codigo, 404);
|
|
||||||
|
|
||||||
return OnTicketFeaturedGroupResource::make(
|
|
||||||
$this->featuredGroupService->update(
|
|
||||||
$tenant,
|
|
||||||
$featuredGroup,
|
|
||||||
$request->validated(),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function onTicketTenant(Request $request): Tenant
|
|
||||||
{
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
|
|
||||||
abort_unless($tenant->website_type_code === 'onticket', 404);
|
|
||||||
|
|
||||||
return $tenant;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Requests\AdminApp;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpsertOnTicketFeaturedGroupRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'category_name' => ['required', 'string', 'max:255'],
|
|
||||||
'is_featured' => ['required', 'boolean'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Resources\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/** @mixin FeaturedGroup */
|
|
||||||
class OnTicketFeaturedGroupResource extends JsonResource
|
|
||||||
{
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'code' => $this->code,
|
|
||||||
'category_id' => $this->category_id,
|
|
||||||
'category_name' => $this->category->nombre,
|
|
||||||
'group_name' => $this->group_name,
|
|
||||||
'is_featured' => $this->product_layout === ProductLayout::Row,
|
|
||||||
'type' => $this->source_type->value,
|
|
||||||
'product_layout' => $this->product_layout->value,
|
|
||||||
'group_layout' => $this->group_layout->value,
|
|
||||||
'order' => $this->group_order,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Catalog\Services;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\FeaturedGroupSource;
|
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
|
||||||
use App\Domains\Catalog\Models\Category;
|
|
||||||
use App\Domains\Catalog\Models\FeaturedGroup;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class OnTicketFeaturedGroupService
|
|
||||||
{
|
|
||||||
/** @return Collection<int, FeaturedGroup> */
|
|
||||||
public function forTenant(Tenant $tenant): Collection
|
|
||||||
{
|
|
||||||
return FeaturedGroup::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->where('source_type', FeaturedGroupSource::Category)
|
|
||||||
->whereHas('category', fn ($query) => $query->where('tenant_code', $tenant->codigo))
|
|
||||||
->with('category')
|
|
||||||
->orderBy('group_order')
|
|
||||||
->orderBy('id')
|
|
||||||
->get();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array{category_name: string, is_featured: bool} $data */
|
|
||||||
public function create(Tenant $tenant, array $data): FeaturedGroup
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($tenant, $data): FeaturedGroup {
|
|
||||||
$category = $tenant->categories()->create([
|
|
||||||
'nombre' => $data['category_name'],
|
|
||||||
]);
|
|
||||||
|
|
||||||
$featuredGroup = FeaturedGroup::query()->create([
|
|
||||||
'tenant_code' => $tenant->codigo,
|
|
||||||
'source_type' => FeaturedGroupSource::Category,
|
|
||||||
'category_id' => $category->id,
|
|
||||||
'product_layout' => $this->productLayout($data['is_featured']),
|
|
||||||
'group_layout' => GroupLayout::Paginated,
|
|
||||||
'group_name' => $data['category_name'],
|
|
||||||
'group_order' => $this->nextOrder($tenant),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $featuredGroup->setRelation('category', $category);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array{category_name: string, is_featured: bool} $data */
|
|
||||||
public function update(
|
|
||||||
Tenant $tenant,
|
|
||||||
FeaturedGroup $featuredGroup,
|
|
||||||
array $data,
|
|
||||||
): FeaturedGroup {
|
|
||||||
return DB::transaction(function () use ($tenant, $featuredGroup, $data): FeaturedGroup {
|
|
||||||
$featuredGroup = FeaturedGroup::query()
|
|
||||||
->whereKey($featuredGroup->getKey())
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->where('source_type', FeaturedGroupSource::Category)
|
|
||||||
->lockForUpdate()
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$category = Category::query()
|
|
||||||
->whereKey($featuredGroup->category_id)
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->lockForUpdate()
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
$category->update(['nombre' => $data['category_name']]);
|
|
||||||
$featuredGroup->update([
|
|
||||||
'group_name' => $data['category_name'],
|
|
||||||
'product_layout' => $this->productLayout($data['is_featured']),
|
|
||||||
'group_layout' => GroupLayout::Paginated,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $featuredGroup->setRelation('category', $category);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function productLayout(bool $isFeatured): ProductLayout
|
|
||||||
{
|
|
||||||
return $isFeatured ? ProductLayout::Row : ProductLayout::ColumnWithCart;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function nextOrder(Tenant $tenant): int
|
|
||||||
{
|
|
||||||
$maximumOrder = FeaturedGroup::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->max('group_order');
|
|
||||||
|
|
||||||
return $maximumOrder === null ? 0 : ((int) $maximumOrder) + 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -21,16 +21,11 @@ Modela y publica la oferta comercial del tenant: productos, variantes, categorí
|
|||||||
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
- `StockReservationService`: sincroniza el carrito como conjunto, bloquea todos sus inventarios en orden estable y mantiene el ledger agregado consistente con `Inventory.reserved_stock`.
|
||||||
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
|
- `ExpireStockReservationsService`: detecta en un único recorrido reservas vencidas de compras, carritos y huérfanas, y delega los efectos comerciales sin mezclar esas reglas con la liberación física del inventario.
|
||||||
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
- `FeaturedGroupService`: pagina los ítems destacados para la tienda.
|
||||||
- `OnTicketFeaturedGroupService`: administra grupos destacados del panel para sitios de tickets.
|
|
||||||
|
|
||||||
## Endpoints de tienda
|
## Endpoints de tienda
|
||||||
|
|
||||||
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
|
Bajo `/tenants/{tenant:codigo}` se publican catálogo, búsqueda, categoría, detalle, alta de ítems y paginación de grupos destacados.
|
||||||
|
|
||||||
## Endpoints administrativos
|
|
||||||
|
|
||||||
Bajo `/v1/adminapp/tenant/featured-groups`, con `auth:sanctum` y `adminapp.tenant`, se listan, crean y actualizan grupos destacados.
|
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
|
Usa `Attachable` para imágenes/archivos, `Tenant` para aislamiento y `Ticket`/`Event` para vigencia y fechas. `Cart` y `Purchase` consumen sus precios, variantes e inventario. Los cambios de stock deben pasar por `CatalogInventoryService` para conservar reservas y disponibilidad.
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Controllers\AdminApp\OnTicketFeaturedGroupController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/tenant')
|
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
|
||||||
->group(function (): void {
|
|
||||||
Route::get('featured-groups', [OnTicketFeaturedGroupController::class, 'index'])
|
|
||||||
->name('adminapp.featured-groups.index');
|
|
||||||
Route::post('featured-groups', [OnTicketFeaturedGroupController::class, 'store'])
|
|
||||||
->name('adminapp.featured-groups.store');
|
|
||||||
Route::put('featured-groups/{featuredGroup}', [OnTicketFeaturedGroupController::class, 'update'])
|
|
||||||
->name('adminapp.featured-groups.update');
|
|
||||||
});
|
|
||||||
@@ -15,5 +15,3 @@ Route::prefix('tenants/{tenant:codigo}')->group(function (): void {
|
|||||||
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
|
Route::post('catalog-items/{catalogItem}/variant-options', [CatalogController::class, 'variantOptions']);
|
||||||
Route::post('catalog-items', [CatalogController::class, 'store']);
|
Route::post('catalog-items', [CatalogController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
require __DIR__.'/adminapp.php';
|
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Client\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
|
||||||
use App\Domains\Client\Requests\StoreClientRequest;
|
|
||||||
use App\Domains\Client\Requests\UpdateClientRequest;
|
|
||||||
use App\Domains\Client\Resources\ClientResource;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
|
|
||||||
class ClientController extends Controller
|
|
||||||
{
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
return ClientResource::collection(
|
|
||||||
Client::query()->with('tenants')->latest()->paginateFromRequest()
|
|
||||||
)->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreClientRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
return ClientResource::make(
|
|
||||||
Client::query()->create($request->validated())->load('tenants')
|
|
||||||
)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Client $client): ClientResource
|
|
||||||
{
|
|
||||||
return ClientResource::make($client->load('tenants'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateClientRequest $request, Client $client): ClientResource
|
|
||||||
{
|
|
||||||
$client->update($request->validated());
|
|
||||||
|
|
||||||
return ClientResource::make($client->fresh()->load('tenants'));
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Client $client): Response
|
|
||||||
{
|
|
||||||
$client->delete();
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Client\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StoreClientRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'code' => ['required', 'string', 'max:255', Rule::unique('clients', 'code')],
|
|
||||||
'name' => ['required', 'string', 'max:255'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Client\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdateClientRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
/** @var Client|null $client */
|
|
||||||
$client = $this->route('client');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'code' => ['sometimes', 'string', 'max:255', Rule::unique('clients', 'code')->ignore($client?->id)],
|
|
||||||
'name' => ['sometimes', 'string', 'max:255'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Client\Resources;
|
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/** @mixin Client */
|
|
||||||
class ClientResource extends JsonResource
|
|
||||||
{
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => $this->id,
|
|
||||||
'code' => $this->code,
|
|
||||||
'name' => $this->name,
|
|
||||||
'tenants' => $this->whenLoaded('tenants', fn () => $this->tenants->map(fn ($tenant): array => [
|
|
||||||
'id' => $tenant->id,
|
|
||||||
'codigo' => $tenant->codigo,
|
|
||||||
'nombre' => $tenant->nombre,
|
|
||||||
'dominio' => $tenant->dominio,
|
|
||||||
])),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\Client\Controllers\ClientController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::apiResource('clients', ClientController::class);
|
|
||||||
@@ -10,7 +10,6 @@ use App\Domains\Desfile\Services\EntryService;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use Illuminate\Http\JsonResponse;
|
use Illuminate\Http\JsonResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Response;
|
|
||||||
|
|
||||||
class EntryController extends Controller
|
class EntryController extends Controller
|
||||||
{
|
{
|
||||||
@@ -48,10 +47,4 @@ class EntryController extends Controller
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroyImage(Request $request): Response
|
|
||||||
{
|
|
||||||
$this->entryService->deleteImage($request->user()->tenant()->firstOrFail());
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,19 +166,6 @@ class EntryService
|
|||||||
return $this->current($tenant);
|
return $this->current($tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function deleteImage(Tenant $tenant): void
|
|
||||||
{
|
|
||||||
$attachment = DB::transaction(function () use ($tenant): Attachment {
|
|
||||||
$entry = $this->entryQuery($tenant)->lockForUpdate()->firstOrFail();
|
|
||||||
$attachment = $entry->allAttachments()->lockForUpdate()->firstOrFail();
|
|
||||||
$entry->allAttachments()->detach($attachment->id);
|
|
||||||
|
|
||||||
return $attachment;
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->deleteIfUnused($attachment);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Collection<string, ItemAttribute> */
|
/** @return Collection<string, ItemAttribute> */
|
||||||
private function itemAttributes(CatalogItem $entry): Collection
|
private function itemAttributes(CatalogItem $entry): Collection
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ namespace App\Domains\Desfile\Services;
|
|||||||
use DateTimeInterface;
|
use DateTimeInterface;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Hash;
|
use Illuminate\Support\Facades\Hash;
|
||||||
use Illuminate\Support\Facades\Schema;
|
|
||||||
use Illuminate\Support\Str;
|
use Illuminate\Support\Str;
|
||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
|
|
||||||
@@ -23,7 +22,7 @@ class InvitationPurchaseProvisioner
|
|||||||
private const ALLOCATIONS = [
|
private const ALLOCATIONS = [
|
||||||
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
|
['sector' => 'A', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'NORMAL'],
|
||||||
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
|
['sector' => 'A', 'row' => 3, 'first_seat' => 1, 'last_seat' => 14, 'type' => 'NORMAL'],
|
||||||
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 17, 'type' => 'VIP + LUNCH'],
|
['sector' => 'C', 'row' => 1, 'first_seat' => 1, 'last_seat' => 16, 'type' => 'VIP + LUNCH'],
|
||||||
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
|
['sector' => 'C', 'row' => 3, 'first_seat' => 6, 'last_seat' => 7, 'type' => 'NORMAL'],
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -59,7 +58,7 @@ class InvitationPurchaseProvisioner
|
|||||||
$allocation['type'],
|
$allocation['type'],
|
||||||
);
|
);
|
||||||
|
|
||||||
$purchaseItemId = $this->createPurchaseItem(
|
$this->createPurchaseItem(
|
||||||
$purchaseId,
|
$purchaseId,
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$variant,
|
$variant,
|
||||||
@@ -71,7 +70,6 @@ class InvitationPurchaseProvisioner
|
|||||||
);
|
);
|
||||||
$this->createTicketAndCommitStock(
|
$this->createTicketAndCommitStock(
|
||||||
$purchaseId,
|
$purchaseId,
|
||||||
$purchaseItemId,
|
|
||||||
$userId,
|
$userId,
|
||||||
(int) $catalogItem->id,
|
(int) $catalogItem->id,
|
||||||
$variant,
|
$variant,
|
||||||
@@ -328,13 +326,12 @@ class InvitationPurchaseProvisioner
|
|||||||
int $seat,
|
int $seat,
|
||||||
string $type,
|
string $type,
|
||||||
DateTimeInterface $now,
|
DateTimeInterface $now,
|
||||||
): int {
|
): void {
|
||||||
$existingId = DB::table('compra_items')
|
if (DB::table('compra_items')
|
||||||
->where('compra_id', $purchaseId)
|
->where('compra_id', $purchaseId)
|
||||||
->where('source_variant_id', $variant->id)
|
->where('source_variant_id', $variant->id)
|
||||||
->value('id');
|
->exists()) {
|
||||||
if ($existingId !== null) {
|
return;
|
||||||
return (int) $existingId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$attributes = [
|
$attributes = [
|
||||||
@@ -344,7 +341,7 @@ class InvitationPurchaseProvisioner
|
|||||||
['name' => 'Asiento', 'value' => (string) $seat],
|
['name' => 'Asiento', 'value' => (string) $seat],
|
||||||
];
|
];
|
||||||
|
|
||||||
return DB::table('compra_items')->insertGetId([
|
DB::table('compra_items')->insert([
|
||||||
'compra_id' => $purchaseId,
|
'compra_id' => $purchaseId,
|
||||||
'source_catalog_item_id' => $catalogItem->id,
|
'source_catalog_item_id' => $catalogItem->id,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
@@ -370,18 +367,13 @@ class InvitationPurchaseProvisioner
|
|||||||
|
|
||||||
private function createTicketAndCommitStock(
|
private function createTicketAndCommitStock(
|
||||||
int $purchaseId,
|
int $purchaseId,
|
||||||
int $purchaseItemId,
|
|
||||||
int $userId,
|
int $userId,
|
||||||
int $catalogItemId,
|
int $catalogItemId,
|
||||||
object $variant,
|
object $variant,
|
||||||
DateTimeInterface $now,
|
DateTimeInterface $now,
|
||||||
): void {
|
): void {
|
||||||
$purchaseReference = Schema::hasColumn('tickets', 'source_purchase_item_id')
|
|
||||||
? ['source_purchase_item_id' => $purchaseItemId]
|
|
||||||
: ['source_purchase_id' => $purchaseId];
|
|
||||||
|
|
||||||
if (DB::table('tickets')
|
if (DB::table('tickets')
|
||||||
->where($purchaseReference)
|
->where('source_purchase_id', $purchaseId)
|
||||||
->where('source_variant_id', $variant->id)
|
->where('source_variant_id', $variant->id)
|
||||||
->exists()) {
|
->exists()) {
|
||||||
return;
|
return;
|
||||||
@@ -428,7 +420,7 @@ class InvitationPurchaseProvisioner
|
|||||||
'ticket' => (string) Str::uuid(),
|
'ticket' => (string) Str::uuid(),
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'description' => null,
|
'description' => null,
|
||||||
...$purchaseReference,
|
'source_purchase_id' => $purchaseId,
|
||||||
'source_catalog_item_id' => $catalogItemId,
|
'source_catalog_item_id' => $catalogItemId,
|
||||||
'source_variant_id' => $variant->id,
|
'source_variant_id' => $variant->id,
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
|
|||||||
@@ -14,6 +14,4 @@ Route::prefix('v1/adminapp/tenant/desfile')
|
|||||||
->name('adminapp.desfile.entries.image.replace');
|
->name('adminapp.desfile.entries.image.replace');
|
||||||
Route::patch('entries/image', [EntryController::class, 'updateImage'])
|
Route::patch('entries/image', [EntryController::class, 'updateImage'])
|
||||||
->name('adminapp.desfile.entries.image.update');
|
->name('adminapp.desfile.entries.image.update');
|
||||||
Route::delete('entries/image', [EntryController::class, 'destroyImage'])
|
|
||||||
->name('adminapp.desfile.entries.image.destroy');
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Forms\Controllers\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Forms\Resources\TicketFilterFormResource;
|
|
||||||
use App\Domains\Forms\Services\TicketFilterFormService;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TicketFilterFormController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(private readonly TicketFilterFormService $formService) {}
|
|
||||||
|
|
||||||
public function __invoke(Request $request): TicketFilterFormResource
|
|
||||||
{
|
|
||||||
$tenant = $request->user('sanctum')->tenant()->firstOrFail();
|
|
||||||
|
|
||||||
return TicketFilterFormResource::make($this->formService->get($tenant));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Forms\Controllers\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Forms\Resources\TicketFormResource;
|
|
||||||
use App\Domains\Forms\Services\TicketFormService;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class TicketFormController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(protected TicketFormService $ticketFormService) {}
|
|
||||||
|
|
||||||
public function __invoke(Request $request): TicketFormResource
|
|
||||||
{
|
|
||||||
return TicketFormResource::make(
|
|
||||||
$this->ticketFormService->get(
|
|
||||||
$request->user('sanctum')->tenant()->firstOrFail()
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Forms\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class TicketFilterFormResource extends JsonResource
|
|
||||||
{
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'code' => $this->resource['code'],
|
|
||||||
'action' => $this->resource['action'],
|
|
||||||
'method' => $this->resource['method'],
|
|
||||||
'fields' => $this->resource['fields'],
|
|
||||||
'columns' => $this->resource['columns'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Forms\Resources;
|
|
||||||
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
class TicketFormResource extends JsonResource
|
|
||||||
{
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'statuses' => $this->resource['statuses'],
|
|
||||||
'categories' => $this->resource['categories'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,18 +6,27 @@ use App\Domains\Purchase\Models\Purchase;
|
|||||||
|
|
||||||
class SaleFormService
|
class SaleFormService
|
||||||
{
|
{
|
||||||
/** @return array{statuses: list<array{value: string, label: string, real_statuses: list<string>}>} */
|
/** @return array{statuses: list<array{code: string, name: string}>} */
|
||||||
public function get(): array
|
public function get(): array
|
||||||
{
|
{
|
||||||
|
$names = [
|
||||||
|
Purchase::STATUS_CREATED => 'Creada',
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
|
||||||
|
Purchase::STATUS_IN_REVIEW => 'En revisión',
|
||||||
|
Purchase::STATUS_PAID => 'Confirmada',
|
||||||
|
Purchase::STATUS_CANCELLED => 'Cancelada',
|
||||||
|
Purchase::STATUS_REJECTED => 'Rechazada',
|
||||||
|
Purchase::STATUS_EXPIRED => 'Vencida',
|
||||||
|
Purchase::STATUS_SUPERSEDED => 'Reemplazada',
|
||||||
|
];
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'statuses' => array_map(
|
'statuses' => array_map(
|
||||||
fn (string $code, array $definition): array => [
|
fn (string $status): array => [
|
||||||
'value' => $code,
|
'code' => $status,
|
||||||
'label' => $definition['name'],
|
'name' => $names[$status],
|
||||||
'real_statuses' => $definition['statuses'],
|
|
||||||
],
|
],
|
||||||
array_keys(Purchase::adminStatuses()),
|
Purchase::statuses(),
|
||||||
array_values(Purchase::adminStatuses()),
|
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,152 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Forms\Services;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
|
||||||
|
|
||||||
class TicketFilterFormService
|
|
||||||
{
|
|
||||||
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly TicketFormService $ticketFormService,
|
|
||||||
private readonly AdminAppTicketColumnService $columnService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function get(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$fields = $this->commonFields();
|
|
||||||
|
|
||||||
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
|
|
||||||
$fields = [
|
|
||||||
...$this->fiestaFutbolInfantilFields($tenant),
|
|
||||||
...$this->commonFields(includeDate: false),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'code' => 'tickets_filter',
|
|
||||||
'action' => '/api/v1/adminapp/tenant/tickets',
|
|
||||||
'method' => 'GET',
|
|
||||||
'fields' => $fields,
|
|
||||||
'columns' => $this->columnService->publicColumns($tenant),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<array<string, mixed>> */
|
|
||||||
private function fiestaFutbolInfantilFields(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$form = $this->ticketFormService->getForFilters($tenant);
|
|
||||||
|
|
||||||
return [
|
|
||||||
[
|
|
||||||
'name' => 'category',
|
|
||||||
'query_param' => 'category',
|
|
||||||
'label' => 'Categoría',
|
|
||||||
'type' => 'select',
|
|
||||||
'required' => false,
|
|
||||||
'default' => null,
|
|
||||||
'placeholder' => 'Categoría',
|
|
||||||
'options' => array_map(
|
|
||||||
fn (array $category): array => [
|
|
||||||
'value' => $category['value'],
|
|
||||||
'label' => $category['label'],
|
|
||||||
'children' => [
|
|
||||||
[
|
|
||||||
'field' => 'product',
|
|
||||||
'disabled' => $category['products'] === [],
|
|
||||||
'options' => array_map(
|
|
||||||
fn (array $product): array => [
|
|
||||||
'value' => $product['value'],
|
|
||||||
'label' => $product['label'],
|
|
||||||
'children' => [
|
|
||||||
[
|
|
||||||
'field' => 'type',
|
|
||||||
'disabled' => $product['types'] === [],
|
|
||||||
'options' => array_map(
|
|
||||||
fn (array $type): array => [
|
|
||||||
'value' => $type['value'],
|
|
||||||
'label' => $type['label'],
|
|
||||||
'children' => [[
|
|
||||||
'field' => 'size',
|
|
||||||
'disabled' => ($type['sizes'] ?? []) === [],
|
|
||||||
'options' => $type['sizes'] ?? [],
|
|
||||||
]],
|
|
||||||
],
|
|
||||||
$product['types'],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
$category['products'],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'field' => 'date',
|
|
||||||
'disabled' => ! in_array($category['value'], ['comidas', 'comida'], true),
|
|
||||||
'options' => [],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
$form['categories'],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
$this->dependentSelect('product', 'Producto', 'category'),
|
|
||||||
$this->dependentSelect('type', 'Tipo', 'product'),
|
|
||||||
$this->dependentSelect('size', 'Talle', 'type'),
|
|
||||||
[
|
|
||||||
...$this->dependentSelect('date', 'Fecha', 'category'),
|
|
||||||
'type' => 'date',
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<array<string, mixed>> */
|
|
||||||
private function commonFields(bool $includeDate = true): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
...($includeDate ? [[
|
|
||||||
'name' => 'date',
|
|
||||||
'query_param' => 'date',
|
|
||||||
'label' => 'Fecha',
|
|
||||||
'type' => 'date',
|
|
||||||
'required' => false,
|
|
||||||
'default' => null,
|
|
||||||
]] : []),
|
|
||||||
[
|
|
||||||
'name' => 'status',
|
|
||||||
'query_param' => 'status',
|
|
||||||
'label' => 'Estado',
|
|
||||||
'type' => 'select',
|
|
||||||
'required' => false,
|
|
||||||
'default' => null,
|
|
||||||
'placeholder' => 'Estado',
|
|
||||||
'options' => [
|
|
||||||
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
|
||||||
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
|
||||||
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
private function dependentSelect(string $name, string $label, string $dependency): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'name' => $name,
|
|
||||||
'query_param' => $name,
|
|
||||||
'label' => $label,
|
|
||||||
'type' => 'select',
|
|
||||||
'required' => false,
|
|
||||||
'default' => null,
|
|
||||||
'placeholder' => $label,
|
|
||||||
'depends_on' => $dependency,
|
|
||||||
'disabled' => true,
|
|
||||||
'options' => [],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,400 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Forms\Services;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
|
||||||
use App\Domains\Catalog\Models\Variant;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
|
|
||||||
class TicketFormService
|
|
||||||
{
|
|
||||||
private const PRODUCT = 'product';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, array{label: string|null, product: string, type: string|null, order: int}>
|
|
||||||
*/
|
|
||||||
private const CATEGORY_PRESENTATIONS = [
|
|
||||||
'entradas' => [
|
|
||||||
'label' => null,
|
|
||||||
'product' => self::PRODUCT,
|
|
||||||
'type' => null,
|
|
||||||
'order' => 1,
|
|
||||||
],
|
|
||||||
'alojamientos' => [
|
|
||||||
'label' => 'Camping',
|
|
||||||
'product' => 'tipo_alojamiento',
|
|
||||||
'type' => null,
|
|
||||||
'order' => 2,
|
|
||||||
],
|
|
||||||
'camping' => [
|
|
||||||
'label' => null,
|
|
||||||
'product' => 'tipo_alojamiento',
|
|
||||||
'type' => null,
|
|
||||||
'order' => 2,
|
|
||||||
],
|
|
||||||
'comidas' => [
|
|
||||||
'label' => 'Comida',
|
|
||||||
'product' => 'event_date',
|
|
||||||
'type' => 'horario',
|
|
||||||
'order' => 3,
|
|
||||||
],
|
|
||||||
'comida' => [
|
|
||||||
'label' => null,
|
|
||||||
'product' => 'event_date',
|
|
||||||
'type' => 'horario',
|
|
||||||
'order' => 3,
|
|
||||||
],
|
|
||||||
'merchandising' => [
|
|
||||||
'label' => null,
|
|
||||||
'product' => self::PRODUCT,
|
|
||||||
'type' => 'color',
|
|
||||||
'order' => 4,
|
|
||||||
],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @var array<string, array{label: string|null, product: string, type: string|null, size: string|null, order: int}>
|
|
||||||
*/
|
|
||||||
private const FILTER_CATEGORY_PRESENTATIONS = [
|
|
||||||
'entradas' => ['label' => null, 'product' => self::PRODUCT, 'type' => null, 'size' => null, 'order' => 1],
|
|
||||||
'alojamientos' => ['label' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
|
|
||||||
'camping' => ['label' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'size' => null, 'order' => 2],
|
|
||||||
'comidas' => ['label' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
|
|
||||||
'comida' => ['label' => null, 'product' => 'horario', 'type' => 'servicio', 'size' => null, 'order' => 3],
|
|
||||||
'merchandising' => ['label' => null, 'product' => self::PRODUCT, 'type' => 'color', 'size' => 'talle', 'order' => 4],
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array{
|
|
||||||
* statuses: list<array{value: string, label: string}>,
|
|
||||||
* categories: list<array{
|
|
||||||
* value: string,
|
|
||||||
* label: string,
|
|
||||||
* products: list<array{
|
|
||||||
* value: string,
|
|
||||||
* label: string,
|
|
||||||
* types: list<array{value: string, label: string}>
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function get(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$items = CatalogItem::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->where('has_tickets', true)
|
|
||||||
->whereHas('category')
|
|
||||||
->with($this->relations())
|
|
||||||
->orderBy('group_order')
|
|
||||||
->orderBy('nombre')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $this->build($items, self::CATEGORY_PRESENTATIONS);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return active catalog options plus soft-deleted sources still referenced by
|
|
||||||
* tickets, so historical tickets never become impossible to filter.
|
|
||||||
*
|
|
||||||
* @return array{
|
|
||||||
* statuses: list<array{value: string, label: string}>,
|
|
||||||
* categories: list<array{
|
|
||||||
* value: string,
|
|
||||||
* label: string,
|
|
||||||
* products: list<array{
|
|
||||||
* value: string,
|
|
||||||
* label: string,
|
|
||||||
* types: list<array{value: string, label: string}>
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
public function getForFilters(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$historicalVariantIds = Ticket::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->whereNotNull('source_variant_id')
|
|
||||||
->distinct()
|
|
||||||
->pluck('source_variant_id')
|
|
||||||
->map(fn ($id): int => (int) $id)
|
|
||||||
->all();
|
|
||||||
$historicalCatalogItemIds = Ticket::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->whereNotNull('source_catalog_item_id')
|
|
||||||
->distinct()
|
|
||||||
->pluck('source_catalog_item_id')
|
|
||||||
->map(fn ($id): int => (int) $id)
|
|
||||||
->merge(
|
|
||||||
Variant::withTrashed()
|
|
||||||
->whereKey($historicalVariantIds)
|
|
||||||
->pluck('catalog_item_id')
|
|
||||||
->map(fn ($id): int => (int) $id),
|
|
||||||
)
|
|
||||||
->unique()
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
|
|
||||||
$items = CatalogItem::withTrashed()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->whereHas('category')
|
|
||||||
->where(function ($query) use ($historicalCatalogItemIds): void {
|
|
||||||
$query
|
|
||||||
->where(function ($activeQuery): void {
|
|
||||||
$activeQuery
|
|
||||||
->whereNull('catalog_items.deleted_at')
|
|
||||||
->where('has_tickets', true);
|
|
||||||
})
|
|
||||||
->orWhereIn('catalog_items.id', $historicalCatalogItemIds);
|
|
||||||
})
|
|
||||||
->with([
|
|
||||||
'category',
|
|
||||||
'itemAttributes.attribute.options',
|
|
||||||
'variants' => fn ($query) => $query
|
|
||||||
->withTrashed()
|
|
||||||
->where(function ($variantQuery) use ($historicalVariantIds): void {
|
|
||||||
$variantQuery
|
|
||||||
->whereNull('variantes.deleted_at')
|
|
||||||
->orWhereIn('variantes.id', $historicalVariantIds);
|
|
||||||
}),
|
|
||||||
'variants.definitions.itemAttribute.attribute.options',
|
|
||||||
'variants.eventDates',
|
|
||||||
'variants.eventDate',
|
|
||||||
])
|
|
||||||
->orderBy('group_order')
|
|
||||||
->orderBy('nombre')
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $this->build($items, self::FILTER_CATEGORY_PRESENTATIONS, includeSizes: true);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, CatalogItem> $items
|
|
||||||
* @return array{
|
|
||||||
* statuses: list<array{value: string, label: string}>,
|
|
||||||
* categories: list<array{
|
|
||||||
* value: string,
|
|
||||||
* label: string,
|
|
||||||
* products: list<array{
|
|
||||||
* value: string,
|
|
||||||
* label: string,
|
|
||||||
* types: list<array{value: string, label: string}>
|
|
||||||
* }>
|
|
||||||
* }>
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
private function build(Collection $items, array $presentations, bool $includeSizes = false): array
|
|
||||||
{
|
|
||||||
$categories = [];
|
|
||||||
|
|
||||||
foreach ($items as $item) {
|
|
||||||
$sourceCategory = trim((string) $item->category?->nombre);
|
|
||||||
$categoryValue = mb_strtolower($sourceCategory);
|
|
||||||
$presentation = $presentations[$categoryValue] ?? [
|
|
||||||
'label' => null,
|
|
||||||
'product' => self::PRODUCT,
|
|
||||||
'type' => null,
|
|
||||||
'size' => null,
|
|
||||||
'order' => PHP_INT_MAX,
|
|
||||||
];
|
|
||||||
|
|
||||||
$categories[$categoryValue] ??= [
|
|
||||||
'value' => $categoryValue,
|
|
||||||
'label' => $presentation['label'] ?? $sourceCategory,
|
|
||||||
'order' => $presentation['order'],
|
|
||||||
'products' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($this->products(
|
|
||||||
$item,
|
|
||||||
$presentation['product'],
|
|
||||||
$presentation['type'],
|
|
||||||
$presentation['size'] ?? null,
|
|
||||||
) as $product) {
|
|
||||||
$productValue = $product['value'];
|
|
||||||
$existingProduct = $categories[$categoryValue]['products'][$productValue] ?? [
|
|
||||||
'value' => $productValue,
|
|
||||||
'label' => $product['label'],
|
|
||||||
'types' => [],
|
|
||||||
'sizes' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($product['types'] as $type) {
|
|
||||||
$existingProduct['types'][$type['value']] = $type;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($product['sizes'] as $size) {
|
|
||||||
$existingProduct['sizes'][$size['value']] = $size;
|
|
||||||
}
|
|
||||||
|
|
||||||
$categories[$categoryValue]['products'][$productValue] = $existingProduct;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
uasort($categories, fn (array $left, array $right): int => $left['order'] <=> $right['order']
|
|
||||||
?: $left['label'] <=> $right['label']);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'statuses' => [
|
|
||||||
['value' => Ticket::STATUS_ACTIVE, 'label' => 'Activo'],
|
|
||||||
['value' => Ticket::STATUS_USED, 'label' => 'Usado'],
|
|
||||||
['value' => Ticket::STATUS_EXPIRED, 'label' => 'Vencido'],
|
|
||||||
],
|
|
||||||
'categories' => array_values(array_map(
|
|
||||||
fn (array $category): array => [
|
|
||||||
'value' => $category['value'],
|
|
||||||
'label' => $category['label'],
|
|
||||||
'products' => array_values(array_map(
|
|
||||||
fn (array $product): array => [
|
|
||||||
'value' => $product['value'],
|
|
||||||
'label' => $product['label'],
|
|
||||||
'types' => array_values($product['types']),
|
|
||||||
...($includeSizes ? ['sizes' => array_values($product['sizes'])] : []),
|
|
||||||
],
|
|
||||||
$category['products'],
|
|
||||||
)),
|
|
||||||
],
|
|
||||||
$categories,
|
|
||||||
)),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
private function relations(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'category',
|
|
||||||
'itemAttributes.attribute.options',
|
|
||||||
'variants.definitions.itemAttribute.attribute.options',
|
|
||||||
'variants.eventDates',
|
|
||||||
'variants.eventDate',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return list<array{
|
|
||||||
* value: string,
|
|
||||||
* label: string,
|
|
||||||
* types: list<array{value: string, label: string}>,
|
|
||||||
* sizes: list<array{value: string, label: string}>
|
|
||||||
* }>
|
|
||||||
*/
|
|
||||||
private function products(CatalogItem $item, string $productCode, ?string $typeCode, ?string $sizeCode): array
|
|
||||||
{
|
|
||||||
if ($productCode === self::PRODUCT) {
|
|
||||||
return [[
|
|
||||||
'value' => $item->slug,
|
|
||||||
'label' => $item->nombre,
|
|
||||||
'types' => $this->types($item, $typeCode, $sizeCode),
|
|
||||||
'sizes' => $this->types($item, $sizeCode),
|
|
||||||
]];
|
|
||||||
}
|
|
||||||
|
|
||||||
$products = [];
|
|
||||||
|
|
||||||
foreach ($item->variants as $variant) {
|
|
||||||
foreach ($this->variantOptions($variant, $productCode) as $productOption) {
|
|
||||||
$productValue = $productOption['value'];
|
|
||||||
$products[$productValue] ??= [
|
|
||||||
'value' => $productValue,
|
|
||||||
'label' => $this->optionLabel($productOption['label'], $productCode),
|
|
||||||
'types' => [],
|
|
||||||
'sizes' => [],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
|
|
||||||
$this->mergeTypeOption(
|
|
||||||
$products[$productValue]['types'],
|
|
||||||
$typeOption,
|
|
||||||
$variant,
|
|
||||||
$sizeCode,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
|
|
||||||
$products[$productValue]['sizes'][$sizeOption['value']] = $sizeOption;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_map(
|
|
||||||
fn (array $product): array => [
|
|
||||||
'value' => $product['value'],
|
|
||||||
'label' => $product['label'],
|
|
||||||
'types' => array_values($product['types']),
|
|
||||||
'sizes' => array_values($product['sizes']),
|
|
||||||
],
|
|
||||||
$products,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<array<string, mixed>> */
|
|
||||||
private function types(CatalogItem $item, ?string $typeCode, ?string $sizeCode = null): array
|
|
||||||
{
|
|
||||||
$types = [];
|
|
||||||
|
|
||||||
foreach ($item->variants as $variant) {
|
|
||||||
foreach ($this->variantOptions($variant, $typeCode) as $typeOption) {
|
|
||||||
$this->mergeTypeOption($types, $typeOption, $variant, $sizeCode);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_values(array_map(function (array $type) use ($sizeCode): array {
|
|
||||||
if ($sizeCode !== null) {
|
|
||||||
$type['sizes'] = array_values($type['sizes']);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $type;
|
|
||||||
}, $types));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, array<string, mixed>> $types
|
|
||||||
* @param array{value: string, label: string} $typeOption
|
|
||||||
*/
|
|
||||||
private function mergeTypeOption(
|
|
||||||
array &$types,
|
|
||||||
array $typeOption,
|
|
||||||
Variant $variant,
|
|
||||||
?string $sizeCode,
|
|
||||||
): void {
|
|
||||||
$typeValue = $typeOption['value'];
|
|
||||||
$types[$typeValue] ??= [
|
|
||||||
...$typeOption,
|
|
||||||
...($sizeCode !== null ? ['sizes' => []] : []),
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($this->variantOptions($variant, $sizeCode) as $sizeOption) {
|
|
||||||
$types[$typeValue]['sizes'][$sizeOption['value']] = $sizeOption;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<array{value: string, label: string}> */
|
|
||||||
private function variantOptions(Variant $variant, ?string $attributeCode): array
|
|
||||||
{
|
|
||||||
if ($attributeCode === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$selection = $variant->selectionOptions($variant->catalogItem->itemAttributes)
|
|
||||||
->get($attributeCode);
|
|
||||||
|
|
||||||
if ($selection === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_is_list($selection) ? $selection : [$selection];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function optionLabel(string $label, string $attributeCode): string
|
|
||||||
{
|
|
||||||
if ($attributeCode !== 'event_date') {
|
|
||||||
return $label;
|
|
||||||
}
|
|
||||||
|
|
||||||
[$day, $month] = array_pad(explode('/', $label), 2, null);
|
|
||||||
|
|
||||||
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ Provee catálogos y opciones auxiliares para construir formularios del panel adm
|
|||||||
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
|
- `EventFormService`: devuelve redes sociales disponibles y las URL configuradas para el tenant.
|
||||||
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
|
- `SaleFormService`: expone los estados admitidos para compras con sus etiquetas de presentación.
|
||||||
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
|
- `StaffFormService`: lista categorías raíz que pueden asignarse al personal del tenant.
|
||||||
- `TicketFormService`: expone estados y opciones anidadas de categoría, producto y tipo para los tickets de Fiesta Fútbol Infantil.
|
|
||||||
|
|
||||||
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
|
Cada servicio tiene un controlador invocable y un `JsonResource` específico. `SocialMediaOptionResource` representa las opciones de redes sociales.
|
||||||
|
|
||||||
@@ -20,7 +19,6 @@ Bajo `/v1/adminapp/forms`, con `auth:sanctum` y `adminapp.tenant`:
|
|||||||
- `GET /event`.
|
- `GET /event`.
|
||||||
- `GET /sale`.
|
- `GET /sale`.
|
||||||
- `GET /staff`.
|
- `GET /staff`.
|
||||||
- `GET /fiesta-futbol-infantil/ticket`: estados y jerarquía categoría → producto → tipo para filtros de tickets.
|
|
||||||
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
|
- `GET /fiesta-futbol-infantil/merchandise`: opciones de color y talle del tenant para merchandising.
|
||||||
|
|
||||||
## Dependencias
|
## Dependencias
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ use App\Domains\Forms\Controllers\AdminApp\FoodFormController;
|
|||||||
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
use App\Domains\Forms\Controllers\AdminApp\MerchandiseFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
use App\Domains\Forms\Controllers\AdminApp\StaffFormController;
|
||||||
use App\Domains\Forms\Controllers\AdminApp\TicketFilterFormController;
|
|
||||||
use App\Domains\Forms\Controllers\AdminApp\TicketFormController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/forms')
|
Route::prefix('v1/adminapp/forms')
|
||||||
@@ -15,13 +13,6 @@ Route::prefix('v1/adminapp/forms')
|
|||||||
Route::get('event', EventFormController::class);
|
Route::get('event', EventFormController::class);
|
||||||
Route::get('sale', SaleFormController::class);
|
Route::get('sale', SaleFormController::class);
|
||||||
Route::get('staff', StaffFormController::class);
|
Route::get('staff', StaffFormController::class);
|
||||||
Route::get('tickets-filter', TicketFilterFormController::class)
|
|
||||||
->middleware('tenant.menu:adminapp.tickets')
|
|
||||||
->name('adminapp.forms.tickets-filter');
|
|
||||||
Route::get(
|
|
||||||
'fiesta-futbol-infantil/ticket',
|
|
||||||
TicketFormController::class
|
|
||||||
);
|
|
||||||
Route::get(
|
Route::get(
|
||||||
'fiesta-futbol-infantil/merchandise',
|
'fiesta-futbol-infantil/merchandise',
|
||||||
MerchandiseFormController::class
|
MerchandiseFormController::class
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Integration\Models\Integration;
|
|
||||||
use App\Domains\Integration\Requests\StoreIntegrationRequest;
|
|
||||||
use App\Domains\Integration\Requests\UpdateIntegrationRequest;
|
|
||||||
use Illuminate\Routing\Controller;
|
|
||||||
|
|
||||||
class IntegrationController extends Controller
|
|
||||||
{
|
|
||||||
public function index()
|
|
||||||
{
|
|
||||||
return response()->json(Integration::all());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreIntegrationRequest $request)
|
|
||||||
{
|
|
||||||
$integration = Integration::create($request->validated());
|
|
||||||
|
|
||||||
return response()->json($integration, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Integration $integration)
|
|
||||||
{
|
|
||||||
return response()->json($integration);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateIntegrationRequest $request, Integration $integration)
|
|
||||||
{
|
|
||||||
$integration->update($request->validated());
|
|
||||||
|
|
||||||
return response()->json($integration->fresh());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Integration $integration)
|
|
||||||
{
|
|
||||||
$integration->delete();
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
|
||||||
use App\Domains\Integration\Requests\TelepagosWebhookRequest;
|
|
||||||
use App\Domains\Integration\Services\TelepagosWebhookService;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class TelepagosWebhookController extends Controller
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Handle the incoming Telepagos webhook.
|
|
||||||
*/
|
|
||||||
public function handle(TelepagosWebhookRequest $request, Client $client, TelepagosWebhookService $service): JsonResponse
|
|
||||||
{
|
|
||||||
try {
|
|
||||||
$cashinId = $request->validated('id');
|
|
||||||
|
|
||||||
$service->handleWebhook($client, $cashinId);
|
|
||||||
|
|
||||||
return response()->json(['status' => 'success']);
|
|
||||||
} catch (\Exception $e) {
|
|
||||||
return response()->json([
|
|
||||||
'status' => 'error',
|
|
||||||
'code' => 'integration.webhook_failed',
|
|
||||||
'message' => __('api.integration.webhook_failed'),
|
|
||||||
], 500);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class StoreIntegrationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'integration_code' => ['required', 'string', 'unique:integrations,integration_code'],
|
|
||||||
'name' => ['required', 'string', 'max:255'],
|
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
|
||||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class TelepagosWebhookRequest extends FormRequest
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Determine if the user is authorized to make this request.
|
|
||||||
*/
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the validation rules that apply to the request.
|
|
||||||
*
|
|
||||||
* @return array<string, \Illuminate\Contracts\Validation\ValidationRule|array|string>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'id' => ['required', 'string'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class UpdateIntegrationRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
$integration = $this->route('integration');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
|
||||||
'url' => ['nullable', 'url', 'max:255'],
|
|
||||||
'integration_data_schema' => ['nullable', 'array'],
|
|
||||||
'requires_client_configuration' => ['sometimes', 'boolean'],
|
|
||||||
// the code shouldn't ideally be updatable, but if it is:
|
|
||||||
'integration_code' => ['sometimes', 'required', 'string', 'unique:integrations,integration_code,'.($integration->id ?? '')],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,333 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Integration\Services;
|
|
||||||
|
|
||||||
use App\Domains\Client\Models\Client;
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
|
||||||
use App\Domains\Purchase\Models\TelepagosPayment;
|
|
||||||
use App\Domains\Purchase\Models\TelepagosQr;
|
|
||||||
use App\Domains\Purchase\Services\CheckoutService;
|
|
||||||
use App\Domains\Purchase\Services\DniDistanceService;
|
|
||||||
use Exception;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Database\Eloquent\Collection;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
|
|
||||||
class TelepagosWebhookService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly CheckoutService $checkoutService,
|
|
||||||
private readonly DniDistanceService $dniDistance,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle the Telepagos webhook notification.
|
|
||||||
*
|
|
||||||
* @throws Exception
|
|
||||||
*/
|
|
||||||
public function handleWebhook(Client $client, string $cashinId): void
|
|
||||||
{
|
|
||||||
Log::channel('telepagos')->info('Telepagos webhook received.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
$telepagosService = new TelepagosIntegrationService;
|
|
||||||
$telepagosService->forClient($client);
|
|
||||||
|
|
||||||
try {
|
|
||||||
$details = $telepagosService->getCashinDetails($cashinId);
|
|
||||||
|
|
||||||
$qrOrderId = $details['data']['qr_order_id'] ?? $details['qr_order_id'] ?? null;
|
|
||||||
$amount = $this->normalizeAmount($details['data']['amount'] ?? $details['amount'] ?? 0);
|
|
||||||
$operationId = $details['data']['operation_id'] ?? $details['operation_id'] ?? null;
|
|
||||||
|
|
||||||
$paymentData = [
|
|
||||||
'compra_id' => null,
|
|
||||||
'cuit_buyer' => $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null,
|
|
||||||
'cvu_buyer' => $details['data']['buyer']['cvu'] ?? $details['buyer']['cvu'] ?? null,
|
|
||||||
'amount' => $amount,
|
|
||||||
'concept' => $details['data']['concept'] ?? $details['concept'] ?? null,
|
|
||||||
'operation' => $details['data']['operation'] ?? $details['operation'] ?? null,
|
|
||||||
'operation_id' => $details['data']['operation_id'] ?? $details['operation_id'] ?? null,
|
|
||||||
'transaction_id' => $details['data']['transaction_id'] ?? $details['transaction_id'] ?? null,
|
|
||||||
'qr_order_id' => $qrOrderId,
|
|
||||||
'link_id' => $details['data']['link_id'] ?? $details['link_id'] ?? null,
|
|
||||||
];
|
|
||||||
|
|
||||||
$transferenciaOperationIds = [1, 3, 11];
|
|
||||||
$qrOperationIds = [31, 37, 47];
|
|
||||||
|
|
||||||
$compra = null;
|
|
||||||
|
|
||||||
if (in_array((int) $operationId, $transferenciaOperationIds, true)) {
|
|
||||||
$cuit = $details['data']['buyer']['cuit'] ?? $details['buyer']['cuit'] ?? null;
|
|
||||||
|
|
||||||
if (! $cuit) {
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: CUIT not found for transfer.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$dni = substr($cuit, 2, -1);
|
|
||||||
|
|
||||||
$tenantCodes = $client->tenants()->pluck('codigo');
|
|
||||||
$eligiblePurchases = Purchase::query()
|
|
||||||
->whereIn('tenant_codigo', $tenantCodes)
|
|
||||||
->whereIn('status', [
|
|
||||||
Purchase::STATUS_CREATED,
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
|
||||||
Purchase::STATUS_IN_REVIEW,
|
|
||||||
])
|
|
||||||
->where('payment_method', 'transfer');
|
|
||||||
|
|
||||||
$purchases = (clone $eligiblePurchases)
|
|
||||||
->where('total', $amount)
|
|
||||||
->latest()
|
|
||||||
->get()
|
|
||||||
->filter(fn (Purchase $purchase): bool => $purchase->transfer_payer_dni !== null
|
|
||||||
&& $this->dniDistance->distance($dni, $purchase->transfer_payer_dni) === 0)
|
|
||||||
->values();
|
|
||||||
|
|
||||||
$compra = $purchases->count() === 1 ? $purchases->first() : null;
|
|
||||||
|
|
||||||
if (! $compra) {
|
|
||||||
$candidatePurchases = $this->findTransferCandidates(
|
|
||||||
$eligiblePurchases,
|
|
||||||
$dni,
|
|
||||||
$amount,
|
|
||||||
);
|
|
||||||
if ($candidatePurchases->isNotEmpty()) {
|
|
||||||
$payment = $this->storeTransferCandidates(
|
|
||||||
$paymentData,
|
|
||||||
$candidatePurchases,
|
|
||||||
$dni,
|
|
||||||
$amount,
|
|
||||||
);
|
|
||||||
|
|
||||||
Log::channel('telepagos')->info('Telepagos webhook: Transfer payment candidates found.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'telepagos_payment_id' => $payment->id,
|
|
||||||
'amount' => $amount,
|
|
||||||
'candidate_count' => $payment->candidates->count(),
|
|
||||||
'candidates' => $payment->candidates
|
|
||||||
->map(fn ($candidate): array => [
|
|
||||||
'purchase_id' => $candidate->compra_id,
|
|
||||||
'match_reason' => $candidate->match_reason,
|
|
||||||
'dni_distance' => $candidate->dni_distance,
|
|
||||||
'amount_difference' => $candidate->amount_difference,
|
|
||||||
'confidence' => $candidate->confidence,
|
|
||||||
])
|
|
||||||
->all(),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: Expected exactly one matching purchase.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'amount' => $amount,
|
|
||||||
'matches' => $purchases->count(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} elseif (in_array((int) $operationId, $qrOperationIds, true)) {
|
|
||||||
if (! $qrOrderId) {
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: qr_order_id not present in provider response.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$telepagosQr = TelepagosQr::where('qr_order_id', $qrOrderId)->first();
|
|
||||||
|
|
||||||
if (! $telepagosQr) {
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: QR not found in database.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'qr_order_id' => $qrOrderId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$compra = $telepagosQr->compra;
|
|
||||||
|
|
||||||
if (! $compra) {
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase not found for QR.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'qr_order_id' => $qrOrderId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! $client->tenants()->where('codigo', $compra->tenant_codigo)->exists()) {
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase does not belong to client.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'purchase_id' => $compra->id,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! in_array($compra->status, [
|
|
||||||
Purchase::STATUS_PENDING_PAYMENT,
|
|
||||||
], true)) {
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: Purchase is not awaiting payment confirmation.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'purchase_id' => $compra->id,
|
|
||||||
'purchase_status' => $compra->status,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$totalAmount = $this->normalizeAmount($compra->getTotalAmount());
|
|
||||||
|
|
||||||
if ($amount !== $totalAmount) {
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: Amount mismatch.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'purchase_id' => $compra->id,
|
|
||||||
'cashin_amount' => $amount,
|
|
||||||
'purchase_amount' => $totalAmount,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Log::channel('telepagos')->warning('Telepagos webhook: Unknown operation_id.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'operation_id' => $operationId,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$paymentData['compra_id'] = $compra->id;
|
|
||||||
|
|
||||||
DB::transaction(function () use ($compra, $paymentData) {
|
|
||||||
TelepagosPayment::create($paymentData);
|
|
||||||
$this->checkoutService->confirmPaidPurchase($compra);
|
|
||||||
});
|
|
||||||
|
|
||||||
Log::channel('telepagos')->info('Telepagos webhook processed successfully.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'purchase_id' => $compra->id,
|
|
||||||
'transaction_id' => $paymentData['transaction_id'],
|
|
||||||
]);
|
|
||||||
} catch (Exception $e) {
|
|
||||||
Log::channel('telepagos')->error('Telepagos webhook processing failed.', [
|
|
||||||
'client_code' => $client->code,
|
|
||||||
'cashin_id' => $cashinId,
|
|
||||||
'error' => $e->getMessage(),
|
|
||||||
]);
|
|
||||||
throw $e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function normalizeAmount(mixed $amount): string
|
|
||||||
{
|
|
||||||
return number_format((float) $amount, 2, '.', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<Purchase> $eligiblePurchases
|
|
||||||
* @return Collection<int, Purchase>
|
|
||||||
*/
|
|
||||||
private function findTransferCandidates(Builder $eligiblePurchases, string $dni, string $amount): Collection
|
|
||||||
{
|
|
||||||
$tolerancePercentage = max(
|
|
||||||
0,
|
|
||||||
(float) config('purchase.transfer_candidate_amount_tolerance_percentage', 5),
|
|
||||||
);
|
|
||||||
$numericAmount = (float) $amount;
|
|
||||||
$tolerance = $numericAmount * ($tolerancePercentage / 100);
|
|
||||||
$minimumAmount = $this->normalizeAmount(max(0, $numericAmount - $tolerance));
|
|
||||||
$maximumAmount = $this->normalizeAmount($numericAmount + $tolerance);
|
|
||||||
|
|
||||||
return (clone $eligiblePurchases)
|
|
||||||
->whereBetween('total', [$minimumAmount, $maximumAmount])
|
|
||||||
->latest()
|
|
||||||
->get()
|
|
||||||
->filter(function (Purchase $purchase) use ($dni, $amount): bool {
|
|
||||||
if ($purchase->transfer_payer_dni === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
|
||||||
$distance = $this->dniDistance->distance(
|
|
||||||
$dni,
|
|
||||||
(string) $purchase->transfer_payer_dni,
|
|
||||||
);
|
|
||||||
|
|
||||||
return $distance === 0
|
|
||||||
|| ($purchaseAmount === $amount && $distance <= 2);
|
|
||||||
})
|
|
||||||
->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array<string, mixed> $paymentData
|
|
||||||
* @param Collection<int, Purchase> $candidatePurchases
|
|
||||||
*/
|
|
||||||
private function storeTransferCandidates(
|
|
||||||
array $paymentData,
|
|
||||||
Collection $candidatePurchases,
|
|
||||||
string $dni,
|
|
||||||
string $amount,
|
|
||||||
): TelepagosPayment {
|
|
||||||
return DB::transaction(function () use ($paymentData, $candidatePurchases, $dni, $amount): TelepagosPayment {
|
|
||||||
$payment = TelepagosPayment::create($paymentData);
|
|
||||||
|
|
||||||
$payment->candidates()->createMany(
|
|
||||||
$candidatePurchases
|
|
||||||
->map(function (Purchase $purchase) use ($dni, $amount): array {
|
|
||||||
$purchaseAmount = $this->normalizeAmount($purchase->total);
|
|
||||||
$dniDistance = $this->dniDistance->distance(
|
|
||||||
$dni,
|
|
||||||
(string) $purchase->transfer_payer_dni,
|
|
||||||
);
|
|
||||||
$dniMatches = $dniDistance === 0;
|
|
||||||
$amountMatches = $purchaseAmount === $amount;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'compra_id' => $purchase->id,
|
|
||||||
'dni_matches' => $dniMatches,
|
|
||||||
'dni_distance' => $dniDistance,
|
|
||||||
'payment_dni' => $dni,
|
|
||||||
'purchase_dni' => $purchase->transfer_payer_dni,
|
|
||||||
'amount_matches' => $amountMatches,
|
|
||||||
'payment_amount' => $amount,
|
|
||||||
'purchase_amount' => $purchaseAmount,
|
|
||||||
'amount_difference' => $this->normalizeAmount(
|
|
||||||
abs((float) $purchaseAmount - (float) $amount),
|
|
||||||
),
|
|
||||||
'match_reason' => $amountMatches
|
|
||||||
? ($dniMatches ? 'ambiguous_exact_match' : 'exact_amount_near_dni')
|
|
||||||
: 'exact_dni_near_amount',
|
|
||||||
'confidence' => $amountMatches
|
|
||||||
? ($dniMatches ? 'exact' : 'medium')
|
|
||||||
: 'high',
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->all(),
|
|
||||||
);
|
|
||||||
|
|
||||||
return $payment->load('candidates');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,18 +16,15 @@ Gestiona integraciones externas disponibles y su configuración por cliente. Un
|
|||||||
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
|
- `BaseIntegrationService`: resuelve el cliente desde el tenant operativo y carga exclusivamente la configuración del cliente.
|
||||||
- `MailService`: envío de correo usando la integración configurada.
|
- `MailService`: envío de correo usando la integración configurada.
|
||||||
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
- `TelepagosIntegrationService`: autenticación, caché de token, generación de QR y consulta de cobros.
|
||||||
- `TelepagosWebhookService`: procesa notificaciones recibidas desde Telepagos.
|
|
||||||
|
|
||||||
## Endpoints
|
## Endpoints
|
||||||
|
|
||||||
- CRUD global bajo `/integrations`.
|
|
||||||
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
- Consulta y configuración por cliente bajo `/clients/{client}/integrations`.
|
||||||
- `POST /webhooks/telepagos/{client}` para notificaciones del proveedor.
|
|
||||||
|
|
||||||
## Logging de Telepagos
|
## Logging de Telepagos
|
||||||
|
|
||||||
Los eventos de autenticación, QR, consultas de cuenta y procesamiento de webhooks se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
Los eventos de autenticación, QR y consultas de cuenta se escriben en el canal diario `telepagos`, separado del log general. Los archivos se generan en `storage/logs/telepagos/telepagos-YYYY-MM-DD.log`; el nivel y la retención se configuran con `TELEPAGOS_LOG_LEVEL` y `TELEPAGOS_LOG_DAYS`. Tokens y credenciales se eliminan del contexto antes de registrar respuestas del proveedor.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs; los webhooks deben validar su contrato antes de alterar una compra.
|
Se integra con `Client`, `Tenant` y con el checkout de `Purchase`. `Notification` utiliza `MailService`. El tenant conserva el contexto operativo y de branding, pero nunca es dueño de credenciales. Las credenciales no se exponen en respuestas ni logs.
|
||||||
|
|||||||
@@ -1,22 +1,10 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
use App\Domains\Integration\Controllers\ClientIntegrationController;
|
||||||
use App\Domains\Integration\Controllers\IntegrationController;
|
|
||||||
use App\Domains\Integration\Controllers\TelepagosWebhookController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::group(['prefix' => 'integrations'], function () {
|
|
||||||
Route::get('/', [IntegrationController::class, 'index']);
|
|
||||||
Route::post('/', [IntegrationController::class, 'store']);
|
|
||||||
Route::get('/{integration}', [IntegrationController::class, 'show']);
|
|
||||||
Route::put('/{integration}', [IntegrationController::class, 'update']);
|
|
||||||
Route::delete('/{integration}', [IntegrationController::class, 'destroy']);
|
|
||||||
});
|
|
||||||
|
|
||||||
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
Route::group(['prefix' => 'clients/{client}/integrations'], function () {
|
||||||
Route::get('/', [ClientIntegrationController::class, 'index']);
|
Route::get('/', [ClientIntegrationController::class, 'index']);
|
||||||
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
Route::get('/{integration_code}', [ClientIntegrationController::class, 'show']);
|
||||||
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
Route::put('/{integration_code}', [ClientIntegrationController::class, 'store']);
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::post('webhooks/telepagos/{client}', [TelepagosWebhookController::class, 'handle']);
|
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\MailTest\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\MailTest\Requests\SendTestMailRequest;
|
|
||||||
use App\Domains\MailTest\Services\MailTestService;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class MailTestController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected MailTestService $mailTestService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function __invoke(SendTestMailRequest $request, string $tenantCode): JsonResponse
|
|
||||||
{
|
|
||||||
$tenant = Tenant::query()
|
|
||||||
->where('codigo', $tenantCode)
|
|
||||||
->firstOrFail();
|
|
||||||
|
|
||||||
return response()->json(
|
|
||||||
$this->mailTestService->send(
|
|
||||||
$tenant,
|
|
||||||
$request->validated('to'),
|
|
||||||
$request->validated('subject'),
|
|
||||||
$request->validated('message'),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\MailTest\Mailables;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Bus\Queueable;
|
|
||||||
use Illuminate\Mail\Mailable;
|
|
||||||
use Illuminate\Mail\Mailables\Content;
|
|
||||||
use Illuminate\Mail\Mailables\Envelope;
|
|
||||||
use Illuminate\Queue\SerializesModels;
|
|
||||||
|
|
||||||
class TestMail extends Mailable
|
|
||||||
{
|
|
||||||
use Queueable, SerializesModels;
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
public readonly string $mailSubject,
|
|
||||||
public readonly string $mailMessage,
|
|
||||||
public readonly Tenant $tenant,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function envelope(): Envelope
|
|
||||||
{
|
|
||||||
return new Envelope(subject: $this->mailSubject);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function content(): Content
|
|
||||||
{
|
|
||||||
$this->tenant->loadMissing(['headerLogo', 'footerLogo']);
|
|
||||||
|
|
||||||
$branding = [
|
|
||||||
'name' => $this->tenant->nombre,
|
|
||||||
'primary_color' => $this->tenant->primary_color ?? '#6376f3',
|
|
||||||
'body_color' => '#334155',
|
|
||||||
'background_color' => '#f1f5f9',
|
|
||||||
'surface_color' => '#ffffff',
|
|
||||||
'header_bg_color' => $this->tenant->header_bg_color ?? '#ffffff',
|
|
||||||
'footer_bg_color' => $this->tenant->footer_bg_color ?? '#334155',
|
|
||||||
];
|
|
||||||
|
|
||||||
return new Content(
|
|
||||||
view: 'mail.test',
|
|
||||||
with: [
|
|
||||||
'tenant' => $this->tenant,
|
|
||||||
'branding' => $branding,
|
|
||||||
'headerLogoUrl' => $this->tenant->headerLogo?->getTemporaryUrl(1440),
|
|
||||||
'footerLogoUrl' => $this->tenant->footerLogo?->getTemporaryUrl(1440),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\MailTest\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class SendTestMailRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<int, string>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'to' => ['required', 'string', 'email', 'max:255'],
|
|
||||||
'subject' => ['nullable', 'string', 'max:255'],
|
|
||||||
'message' => ['nullable', 'string', 'max:5000'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\MailTest\Services;
|
|
||||||
|
|
||||||
use App\Domains\Integration\Services\MailService;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
|
|
||||||
class MailTestService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function send(Tenant $tenant, string $recipient, ?string $subject = null, ?string $message = null): array
|
|
||||||
{
|
|
||||||
$subject ??= 'Prueba de correo de Shopit';
|
|
||||||
$message ??= 'Este es un correo de prueba enviado desde Shopit.';
|
|
||||||
|
|
||||||
$mailService = (new MailService)->forTenant($tenant->codigo);
|
|
||||||
$mailService->send(
|
|
||||||
$recipient,
|
|
||||||
$subject,
|
|
||||||
'<h1 style="margin: 0 0 20px;">'.e($subject).'</h1>'
|
|
||||||
.'<p>'.nl2br(e($message)).'</p>',
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'code' => 'mail.test_sent',
|
|
||||||
'message' => __('api.mail.test_sent'),
|
|
||||||
'recipient' => $recipient,
|
|
||||||
'tenant_code' => $tenant->codigo,
|
|
||||||
'mailer' => $mailService->mailerName(),
|
|
||||||
'sent_at' => now()->toIso8601String(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Dominio MailTest
|
|
||||||
|
|
||||||
## Propósito
|
|
||||||
|
|
||||||
Ofrece una operación técnica para verificar la configuración de correo de un tenant sin ejecutar un flujo funcional real.
|
|
||||||
|
|
||||||
## Componentes
|
|
||||||
|
|
||||||
- `MailTestController`: endpoint invocable de envío.
|
|
||||||
- `SendTestMailRequest`: valida destinatario y contenido requerido.
|
|
||||||
- `MailTestService`: coordina el envío de prueba.
|
|
||||||
- `TestMail`: mailable utilizado para construir el mensaje.
|
|
||||||
|
|
||||||
## Endpoint
|
|
||||||
|
|
||||||
- `POST /{tenant_code}/mail-test/send`.
|
|
||||||
|
|
||||||
## Dependencias
|
|
||||||
|
|
||||||
Usa la configuración de correo del dominio `Integration` y resuelve el tenant indicado.
|
|
||||||
|
|
||||||
## Consideraciones
|
|
||||||
|
|
||||||
Es una herramienta de diagnóstico. Debe restringirse o deshabilitarse en entornos donde no corresponda exponer envíos de prueba, y nunca debe registrar credenciales.
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\MailTest\Controllers\MailTestController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::post('{tenant_code}/mail-test/send', MailTestController::class);
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Menu\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Menu\Models\Menu;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class MenuController extends Controller
|
|
||||||
{
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
$menues = Menu::all();
|
|
||||||
|
|
||||||
return response()->json($menues);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(Request $request): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'code' => 'required|string|unique:menues,code',
|
|
||||||
'label' => 'required|string|max:255',
|
|
||||||
'parent_menu_code' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::exists('menues', 'code'),
|
|
||||||
'different:code',
|
|
||||||
],
|
|
||||||
'content_type' => [
|
|
||||||
'sometimes',
|
|
||||||
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
|
|
||||||
],
|
|
||||||
'static_content_schema' => 'required_if:content_type,static|nullable|array',
|
|
||||||
'route' => 'required|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$menu = Menu::create($validated);
|
|
||||||
|
|
||||||
return response()->json($menu, 201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Menu $menu): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json($menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(Request $request, Menu $menu): JsonResponse
|
|
||||||
{
|
|
||||||
$validated = $request->validate([
|
|
||||||
'code' => 'sometimes|required|string|unique:menues,code,'.$menu->id,
|
|
||||||
'label' => 'sometimes|required|string|max:255',
|
|
||||||
'parent_menu_code' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
Rule::exists('menues', 'code'),
|
|
||||||
Rule::notIn([$menu->code]),
|
|
||||||
],
|
|
||||||
'content_type' => [
|
|
||||||
'sometimes',
|
|
||||||
Rule::in([Menu::CONTENT_TYPE_STATIC, Menu::CONTENT_TYPE_DYNAMIC]),
|
|
||||||
],
|
|
||||||
'static_content_schema' => 'required_if:content_type,static|nullable|array',
|
|
||||||
'route' => 'sometimes|required|string',
|
|
||||||
]);
|
|
||||||
|
|
||||||
$menu->update($validated);
|
|
||||||
|
|
||||||
return response()->json($menu);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Menu $menu): JsonResponse
|
|
||||||
{
|
|
||||||
$menu->delete();
|
|
||||||
|
|
||||||
return response()->json(null, 204);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Menu\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Menu\Models\Menu;
|
|
||||||
use App\Domains\Menu\Requests\StoreTenantMenuRequest;
|
|
||||||
use App\Domains\Menu\Services\TenantMenuService;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class TenantMenuController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(private readonly TenantMenuService $tenantMenuService) {}
|
|
||||||
|
|
||||||
public function store(
|
|
||||||
StoreTenantMenuRequest $request,
|
|
||||||
string $tenantCode,
|
|
||||||
string $menuCode,
|
|
||||||
): JsonResponse {
|
|
||||||
$tenant = Tenant::query()->where('codigo', $tenantCode)->firstOrFail();
|
|
||||||
$menu = Menu::query()->where('code', $menuCode)->firstOrFail();
|
|
||||||
|
|
||||||
$tenantMenu = $this->tenantMenuService->configure(
|
|
||||||
$tenant,
|
|
||||||
$menu,
|
|
||||||
$request->validated('static_content'),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json($tenantMenu);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Menu\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Menu\Models\Menu;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\ValidationException;
|
|
||||||
|
|
||||||
class StoreTenantMenuRequest extends FormRequest
|
|
||||||
{
|
|
||||||
private ?Menu $menuModel = null;
|
|
||||||
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
|
||||||
{
|
|
||||||
$this->menuModel = Menu::query()
|
|
||||||
->where('code', $this->route('menu_code'))
|
|
||||||
->first();
|
|
||||||
|
|
||||||
if (! $this->menuModel) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'menu_code' => __('api.menu.not_found'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
if ($this->menuModel?->content_type !== Menu::CONTENT_TYPE_STATIC) {
|
|
||||||
return [
|
|
||||||
'static_content' => ['nullable', 'array'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$rules = [
|
|
||||||
'static_content' => ['required', 'array'],
|
|
||||||
];
|
|
||||||
|
|
||||||
foreach ($this->menuModel->static_content_schema as $field => $rule) {
|
|
||||||
$rules["static_content.{$field}"] = $rule;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $rules;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Menu\Services;
|
|
||||||
|
|
||||||
use App\Domains\Menu\Models\Menu;
|
|
||||||
use App\Domains\Menu\Models\TenantMenu;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use Illuminate\Support\Facades\DB;
|
|
||||||
|
|
||||||
class TenantMenuService
|
|
||||||
{
|
|
||||||
public function configure(Tenant $tenant, Menu $menu, ?array $staticContent): TenantMenu
|
|
||||||
{
|
|
||||||
return DB::transaction(fn () => TenantMenu::query()->updateOrCreate(
|
|
||||||
[
|
|
||||||
'tenant_code' => $tenant->codigo,
|
|
||||||
'menu_code' => $menu->code,
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'static_content' => $menu->content_type === Menu::CONTENT_TYPE_STATIC
|
|
||||||
? $staticContent
|
|
||||||
: null,
|
|
||||||
],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,15 +10,6 @@ Define menús disponibles y permite configurar su contenido para cada tenant y r
|
|||||||
- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde.
|
- `TenantMenu`: configuración específica por tenant, incluyendo contenido estático cuando corresponde.
|
||||||
- `MenuRole`: asociación entre menú y rol autorizado.
|
- `MenuRole`: asociación entre menú y rol autorizado.
|
||||||
|
|
||||||
## Servicios
|
|
||||||
|
|
||||||
`TenantMenuService::configure()` crea o actualiza atómicamente la configuración de un menú para un tenant. Solo conserva `static_content` cuando el menú fue definido como contenido estático.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
- Recurso REST `/menues` mediante `MenuController`.
|
|
||||||
- `POST /{tenant_code}/menues/{menu_code}` para configurar un menú del tenant.
|
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|
||||||
Depende de `Tenant` y `Authorization`. Los códigos de menú y tenant forman la identidad lógica de la configuración; el contenido enviado debe respetar el tipo definido por `Menu`.
|
Depende de `Tenant` y `Authorization`. Los menús se configuran mediante seeders y relaciones internas; no se exponen endpoints públicos de administración.
|
||||||
|
|||||||
@@ -1,12 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\Menu\Controllers\MenuController;
|
|
||||||
use App\Domains\Menu\Controllers\TenantMenuController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::apiResource('menues', MenuController::class);
|
|
||||||
|
|
||||||
Route::post(
|
|
||||||
'{tenant_code}/menues/{menu_code}',
|
|
||||||
[TenantMenuController::class, 'store']
|
|
||||||
);
|
|
||||||
@@ -138,7 +138,8 @@ class NotificationMailService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<int, Ticket> $tickets */
|
/** @var Collection<int, Ticket> $tickets */
|
||||||
$tickets = $purchase->tickets()
|
$tickets = Ticket::query()
|
||||||
|
->where('source_purchase_id', $purchase->getKey())
|
||||||
->where('tenant_code', $purchase->tenant_codigo)
|
->where('tenant_code', $purchase->tenant_codigo)
|
||||||
->with(TicketPresentationResolver::RELATIONS)
|
->with(TicketPresentationResolver::RELATIONS)
|
||||||
->get();
|
->get();
|
||||||
|
|||||||
@@ -243,15 +243,6 @@ class PurchaseController extends Controller
|
|||||||
], 400);
|
], 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function complete(Request $request, Tenant $tenant, Purchase $compra, CheckoutService $checkoutService): PurchaseResource
|
|
||||||
{
|
|
||||||
$compra = $this->resolveScopedPurchase($tenant, $request->user()->id, $compra);
|
|
||||||
|
|
||||||
return PurchaseResource::make(
|
|
||||||
$checkoutService->completePurchase($compra)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function submitForReview(
|
public function submitForReview(
|
||||||
Request $request,
|
Request $request,
|
||||||
Tenant $tenant,
|
Tenant $tenant,
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasManyThrough;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
@@ -52,14 +51,6 @@ class Purchase extends Model
|
|||||||
|
|
||||||
public const STATUS_SUPERSEDED = 'superseded';
|
public const STATUS_SUPERSEDED = 'superseded';
|
||||||
|
|
||||||
public const ADMIN_STATUS_INCOMPLETE = 'incomplete';
|
|
||||||
|
|
||||||
public const ADMIN_STATUS_AWAITING_PAYMENT = 'awaiting_payment';
|
|
||||||
|
|
||||||
public const ADMIN_STATUS_CONFIRMED = 'confirmed';
|
|
||||||
|
|
||||||
public const ADMIN_STATUS_CANCELLED = 'cancelled';
|
|
||||||
|
|
||||||
/** @return list<string> */
|
/** @return list<string> */
|
||||||
public static function statuses(): array
|
public static function statuses(): array
|
||||||
{
|
{
|
||||||
@@ -75,68 +66,6 @@ class Purchase extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array{name: string, statuses: list<string>}>
|
|
||||||
*/
|
|
||||||
public static function adminStatuses(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
self::ADMIN_STATUS_INCOMPLETE => [
|
|
||||||
'name' => 'Por completar datos',
|
|
||||||
'statuses' => [self::STATUS_CREATED],
|
|
||||||
],
|
|
||||||
self::ADMIN_STATUS_AWAITING_PAYMENT => [
|
|
||||||
'name' => 'Esperando pago',
|
|
||||||
'statuses' => [self::STATUS_PENDING_PAYMENT, self::STATUS_IN_REVIEW],
|
|
||||||
],
|
|
||||||
self::ADMIN_STATUS_CONFIRMED => [
|
|
||||||
'name' => 'Confirmado',
|
|
||||||
'statuses' => [self::STATUS_PAID],
|
|
||||||
],
|
|
||||||
self::ADMIN_STATUS_CANCELLED => [
|
|
||||||
'name' => 'Anulado',
|
|
||||||
'statuses' => [
|
|
||||||
self::STATUS_CANCELLED,
|
|
||||||
self::STATUS_REJECTED,
|
|
||||||
self::STATUS_EXPIRED,
|
|
||||||
self::STATUS_SUPERSEDED,
|
|
||||||
],
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
public static function adminStatusCodes(): array
|
|
||||||
{
|
|
||||||
return array_keys(self::adminStatuses());
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
public static function realStatusesForAdminStatus(string $adminStatus): array
|
|
||||||
{
|
|
||||||
return self::adminStatuses()[$adminStatus]['statuses'] ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function adminStatusFor(string $realStatus): ?string
|
|
||||||
{
|
|
||||||
foreach (self::adminStatuses() as $adminStatus => $definition) {
|
|
||||||
if (in_array($realStatus, $definition['statuses'], true)) {
|
|
||||||
return $adminStatus;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static function adminStatusNameFor(string $realStatus): ?string
|
|
||||||
{
|
|
||||||
$adminStatus = self::adminStatusFor($realStatus);
|
|
||||||
|
|
||||||
return $adminStatus === null
|
|
||||||
? null
|
|
||||||
: self::adminStatuses()[$adminStatus]['name'];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected $table = 'compras';
|
protected $table = 'compras';
|
||||||
|
|
||||||
/** @var array<int, string> */
|
/** @var array<int, string> */
|
||||||
@@ -186,15 +115,12 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
return $this->hasMany(PurchaseItem::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasManyThrough<Ticket, PurchaseItem, $this> */
|
/**
|
||||||
public function tickets(): HasManyThrough
|
* @return HasMany<Ticket, $this>
|
||||||
|
*/
|
||||||
|
public function tickets(): HasMany
|
||||||
{
|
{
|
||||||
return $this->hasManyThrough(
|
return $this->hasMany(Ticket::class, 'source_purchase_id');
|
||||||
Ticket::class,
|
|
||||||
PurchaseItem::class,
|
|
||||||
'compra_id',
|
|
||||||
'source_purchase_item_id',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<StockReservation, $this> */
|
/** @return BelongsTo<StockReservation, $this> */
|
||||||
@@ -219,12 +145,6 @@ class Purchase extends Model
|
|||||||
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
return $this->hasMany(TelepagosPayment::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
|
||||||
public function telepagosPaymentCandidates(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TelepagosPaymentCandidate::class, 'compra_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function getTotalAmount(): float
|
public function getTotalAmount(): float
|
||||||
{
|
{
|
||||||
if ($this->total !== null) {
|
if ($this->total !== null) {
|
||||||
|
|||||||
@@ -5,12 +5,10 @@ namespace App\Domains\Purchase\Models;
|
|||||||
use App\Domains\Attachable\Models\Attachment;
|
use App\Domains\Attachable\Models\Attachment;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'compra_id',
|
'compra_id',
|
||||||
@@ -58,12 +56,6 @@ class PurchaseItem extends Model
|
|||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<Ticket, $this> */
|
|
||||||
public function tickets(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(Ticket::class, 'source_purchase_item_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return BelongsTo<Attachment, $this> */
|
/** @return BelongsTo<Attachment, $this> */
|
||||||
public function imageAttachment(): BelongsTo
|
public function imageAttachment(): BelongsTo
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|||||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
use Illuminate\Database\Eloquent\Model;
|
use Illuminate\Database\Eloquent\Model;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
|
||||||
|
|
||||||
#[Fillable([
|
#[Fillable([
|
||||||
'compra_id',
|
'compra_id',
|
||||||
|
'matched_purchase_ids',
|
||||||
'cuit_buyer',
|
'cuit_buyer',
|
||||||
'cvu_buyer',
|
'cvu_buyer',
|
||||||
'amount',
|
'amount',
|
||||||
@@ -30,6 +30,7 @@ class TelepagosPayment extends Model
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'compra_id' => 'integer',
|
'compra_id' => 'integer',
|
||||||
|
'matched_purchase_ids' => 'array',
|
||||||
'amount' => 'decimal:2',
|
'amount' => 'decimal:2',
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -41,10 +42,4 @@ class TelepagosPayment extends Model
|
|||||||
{
|
{
|
||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
return $this->belongsTo(Purchase::class, 'compra_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return HasMany<TelepagosPaymentCandidate, $this> */
|
|
||||||
public function candidates(): HasMany
|
|
||||||
{
|
|
||||||
return $this->hasMany(TelepagosPaymentCandidate::class, 'telepagos_payment_id');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Models;
|
|
||||||
|
|
||||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
|
||||||
use Illuminate\Database\Eloquent\Model;
|
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
|
||||||
|
|
||||||
#[Fillable([
|
|
||||||
'telepagos_payment_id',
|
|
||||||
'compra_id',
|
|
||||||
'dni_matches',
|
|
||||||
'dni_distance',
|
|
||||||
'payment_dni',
|
|
||||||
'purchase_dni',
|
|
||||||
'amount_matches',
|
|
||||||
'payment_amount',
|
|
||||||
'purchase_amount',
|
|
||||||
'amount_difference',
|
|
||||||
'match_reason',
|
|
||||||
'confidence',
|
|
||||||
])]
|
|
||||||
class TelepagosPaymentCandidate extends Model
|
|
||||||
{
|
|
||||||
protected $table = 'telepagos_payment_candidates';
|
|
||||||
|
|
||||||
protected function casts(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'telepagos_payment_id' => 'integer',
|
|
||||||
'compra_id' => 'integer',
|
|
||||||
'dni_matches' => 'boolean',
|
|
||||||
'dni_distance' => 'integer',
|
|
||||||
'amount_matches' => 'boolean',
|
|
||||||
'payment_amount' => 'decimal:2',
|
|
||||||
'purchase_amount' => 'decimal:2',
|
|
||||||
'amount_difference' => 'decimal:2',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
public function payment(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(TelepagosPayment::class, 'telepagos_payment_id');
|
|
||||||
}
|
|
||||||
|
|
||||||
public function purchase(): BelongsTo
|
|
||||||
{
|
|
||||||
return $this->belongsTo(Purchase::class, 'compra_id');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ namespace App\Domains\Purchase\Resources;
|
|||||||
|
|
||||||
use App\Domains\Purchase\Models\Purchase;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\PurchaseItem;
|
||||||
use App\Domains\Purchase\Models\TelepagosPaymentCandidate;
|
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
use Illuminate\Http\Resources\Json\JsonResource;
|
||||||
|
|
||||||
@@ -26,7 +25,6 @@ class PurchaseResource extends JsonResource
|
|||||||
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
$ticketsCount = array_key_exists('tickets_count', $this->resource->getAttributes())
|
||||||
? (int) $this->resource->getAttribute('tickets_count')
|
? (int) $this->resource->getAttribute('tickets_count')
|
||||||
: null;
|
: null;
|
||||||
$paymentVerification = $this->resolvePaymentVerification();
|
|
||||||
|
|
||||||
$subtotal = $items->isNotEmpty()
|
$subtotal = $items->isNotEmpty()
|
||||||
? $items->reduce(
|
? $items->reduce(
|
||||||
@@ -66,7 +64,6 @@ class PurchaseResource extends JsonResource
|
|||||||
'items' => PurchaseItemResource::collection($items),
|
'items' => PurchaseItemResource::collection($items),
|
||||||
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
'tickets_count' => $this->when($ticketsCount !== null, $ticketsCount),
|
||||||
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
'has_generated_tickets' => $this->when($ticketsCount !== null, $ticketsCount > 0),
|
||||||
'payment_verification' => $this->when($paymentVerification !== null, $paymentVerification),
|
|
||||||
'subtotal' => $this->formatMoney($subtotal),
|
'subtotal' => $this->formatMoney($subtotal),
|
||||||
'total' => $this->formatMoney($total),
|
'total' => $this->formatMoney($total),
|
||||||
];
|
];
|
||||||
@@ -86,75 +83,4 @@ class PurchaseResource extends JsonResource
|
|||||||
{
|
{
|
||||||
return number_format((float) ($amount ?? 0), 2, '.', '');
|
return number_format((float) ($amount ?? 0), 2, '.', '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return array<string, mixed>|null */
|
|
||||||
private function resolvePaymentVerification(): ?array
|
|
||||||
{
|
|
||||||
if (
|
|
||||||
$this->status !== Purchase::STATUS_IN_REVIEW
|
|
||||||
|| $this->payment_method !== 'transfer'
|
|
||||||
|| ! $this->resource->relationLoaded('telepagosPaymentCandidates')
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$candidates = $this->resource
|
|
||||||
->getRelation('telepagosPaymentCandidates')
|
|
||||||
->sort(fn (TelepagosPaymentCandidate $left, TelepagosPaymentCandidate $right): int => $this->comparePaymentCandidates($left, $right))
|
|
||||||
->values();
|
|
||||||
/** @var TelepagosPaymentCandidate|null $primary */
|
|
||||||
$primary = $candidates->first();
|
|
||||||
|
|
||||||
return [
|
|
||||||
'status' => $primary === null ? 'pending' : 'candidate',
|
|
||||||
'candidate_count' => $candidates->count(),
|
|
||||||
'primary' => $primary === null ? null : [
|
|
||||||
'reason' => $primary->match_reason,
|
|
||||||
'dni_distance' => $primary->dni_distance,
|
|
||||||
'payment_amount' => $this->formatMoney($primary->payment_amount),
|
|
||||||
'purchase_amount' => $this->formatMoney($primary->purchase_amount),
|
|
||||||
'amount_difference' => $this->formatMoney($primary->amount_difference),
|
|
||||||
'confidence' => $primary->confidence,
|
|
||||||
'detected_at' => $primary->payment?->created_at?->toIso8601String(),
|
|
||||||
],
|
|
||||||
'reasons' => $candidates
|
|
||||||
->pluck('match_reason')
|
|
||||||
->unique()
|
|
||||||
->values()
|
|
||||||
->all(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function comparePaymentCandidates(
|
|
||||||
TelepagosPaymentCandidate $left,
|
|
||||||
TelepagosPaymentCandidate $right,
|
|
||||||
): int {
|
|
||||||
$reasonComparison = $this->paymentCandidateRank($left->match_reason)
|
|
||||||
<=> $this->paymentCandidateRank($right->match_reason);
|
|
||||||
|
|
||||||
if ($reasonComparison !== 0) {
|
|
||||||
return $reasonComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
$differenceComparison = (float) $left->amount_difference <=> (float) $right->amount_difference;
|
|
||||||
|
|
||||||
if ($differenceComparison !== 0) {
|
|
||||||
return $differenceComparison;
|
|
||||||
}
|
|
||||||
|
|
||||||
$leftTimestamp = $left->payment?->created_at?->getTimestamp() ?? 0;
|
|
||||||
$rightTimestamp = $right->payment?->created_at?->getTimestamp() ?? 0;
|
|
||||||
|
|
||||||
return ($rightTimestamp <=> $leftTimestamp) ?: ($right->id <=> $left->id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function paymentCandidateRank(string $reason): int
|
|
||||||
{
|
|
||||||
return match ($reason) {
|
|
||||||
'ambiguous_exact_match' => 0,
|
|
||||||
'exact_dni_near_amount' => 1,
|
|
||||||
'exact_amount_near_dni' => 2,
|
|
||||||
default => 3,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,33 +18,6 @@ class CompleteCheckoutService
|
|||||||
private readonly PurchaseStateGuard $purchaseState,
|
private readonly PurchaseStateGuard $purchaseState,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
public function complete(Purchase $purchase): Purchase
|
|
||||||
{
|
|
||||||
return DB::transaction(function () use ($purchase): Purchase {
|
|
||||||
$purchase = $this->lockPurchase($purchase);
|
|
||||||
$this->purchaseState->assertNotExpired($purchase);
|
|
||||||
|
|
||||||
if ($purchase->payment_method === null) {
|
|
||||||
throw ValidationException::withMessages([
|
|
||||||
'payment_method' => __('api.purchase.payment_method_required'),
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->isTerminal($purchase)) {
|
|
||||||
return $this->loadPurchase($purchase);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->purchaseState->lockCurrentCart($purchase);
|
|
||||||
|
|
||||||
$purchase->update([
|
|
||||||
'status' => Purchase::STATUS_PENDING_PAYMENT,
|
|
||||||
'total' => $purchase->calculateCurrentTotalAmount(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $this->loadPurchase($purchase);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public function submitForReview(Purchase $purchase): Purchase
|
public function submitForReview(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return DB::transaction(function () use ($purchase): Purchase {
|
return DB::transaction(function () use ($purchase): Purchase {
|
||||||
@@ -171,18 +144,6 @@ class CompleteCheckoutService
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private function isTerminal(Purchase $purchase): bool
|
|
||||||
{
|
|
||||||
return in_array($purchase->status, [
|
|
||||||
Purchase::STATUS_PAID,
|
|
||||||
Purchase::STATUS_IN_REVIEW,
|
|
||||||
Purchase::STATUS_CANCELLED,
|
|
||||||
Purchase::STATUS_REJECTED,
|
|
||||||
Purchase::STATUS_EXPIRED,
|
|
||||||
Purchase::STATUS_SUPERSEDED,
|
|
||||||
], true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function lockPurchase(Purchase $purchase): Purchase
|
private function lockPurchase(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
/** @var Purchase */
|
/** @var Purchase */
|
||||||
|
|||||||
@@ -8,15 +8,6 @@ class PurchaseResponseLoader
|
|||||||
{
|
{
|
||||||
public function load(Purchase $purchase): Purchase
|
public function load(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
$relations = ['tenant', 'items.imageAttachment', 'stockReservation'];
|
return $purchase->load(['tenant', 'items.imageAttachment', 'stockReservation']);
|
||||||
|
|
||||||
if (
|
|
||||||
$purchase->status === Purchase::STATUS_IN_REVIEW
|
|
||||||
&& $purchase->payment_method === 'transfer'
|
|
||||||
) {
|
|
||||||
$relations[] = 'telepagosPaymentCandidates.payment';
|
|
||||||
}
|
|
||||||
|
|
||||||
return $purchase->load($relations);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,11 +43,6 @@ class ReleaseCheckoutService
|
|||||||
): Purchase {
|
): Purchase {
|
||||||
$purchase = $this->lockPurchase($purchase);
|
$purchase = $this->lockPurchase($purchase);
|
||||||
|
|
||||||
if ($purchase->status === Purchase::STATUS_EXPIRED
|
|
||||||
&& $targetStatus === Purchase::STATUS_CANCELLED) {
|
|
||||||
return $this->loadPurchase($purchase);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($purchase->status === Purchase::STATUS_EXPIRED
|
if ($purchase->status === Purchase::STATUS_EXPIRED
|
||||||
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
&& $targetStatus !== Purchase::STATUS_EXPIRED) {
|
||||||
throw new PurchaseExpiredException;
|
throw new PurchaseExpiredException;
|
||||||
@@ -77,14 +72,6 @@ class ReleaseCheckoutService
|
|||||||
|
|
||||||
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
$cart = $purchase->cart()->withTrashed()->lockForUpdate()->first();
|
||||||
|
|
||||||
// Leaving checkout is allowed at the exact instant the reservation
|
|
||||||
// expires. Finish the expiration while holding the purchase lock so
|
|
||||||
// the request is idempotent with the scheduled expiration job.
|
|
||||||
if ($targetStatus === Purchase::STATUS_CANCELLED
|
|
||||||
&& $this->hasOverdueActiveReservation($purchase)) {
|
|
||||||
$targetStatus = Purchase::STATUS_EXPIRED;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($targetStatus === Purchase::STATUS_CANCELLED
|
if ($targetStatus === Purchase::STATUS_CANCELLED
|
||||||
&& $cart?->status === 'active'
|
&& $cart?->status === 'active'
|
||||||
&& in_array($purchase->status, [
|
&& in_array($purchase->status, [
|
||||||
|
|||||||
@@ -30,11 +30,6 @@ class CheckoutService
|
|||||||
return $this->starter->start($tenant, $userId, $purchaseData);
|
return $this->starter->start($tenant, $userId, $purchaseData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function completePurchase(Purchase $purchase): Purchase
|
|
||||||
{
|
|
||||||
return $this->completer->complete($purchase);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function submitForReview(Purchase $purchase): Purchase
|
public function submitForReview(Purchase $purchase): Purchase
|
||||||
{
|
{
|
||||||
return $this->completer->submitForReview($purchase);
|
return $this->completer->submitForReview($purchase);
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Purchase\Services;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Measures likely DNI typing errors using the optimal-string-alignment
|
|
||||||
* variant of the Damerau-Levenshtein distance.
|
|
||||||
*
|
|
||||||
* The returned value is the minimum number of single-character edits needed
|
|
||||||
* to transform one DNI into the other. Supported edits are insertion,
|
|
||||||
* deletion, substitution and transposition of two adjacent digits.
|
|
||||||
*/
|
|
||||||
class DniDistanceService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Calculate the edit distance between two normalized DNI strings.
|
|
||||||
*
|
|
||||||
* Each matrix cell [row][column] stores the minimum edits required to
|
|
||||||
* transform the first $row digits of $left into the first $column digits
|
|
||||||
* of $right. The bottom-right cell therefore contains the final distance.
|
|
||||||
*/
|
|
||||||
public function distance(string $left, string $right): int
|
|
||||||
{
|
|
||||||
$left = $this->normalize($left);
|
|
||||||
$right = $this->normalize($right);
|
|
||||||
$leftLength = strlen($left);
|
|
||||||
$rightLength = strlen($right);
|
|
||||||
$matrix = [];
|
|
||||||
|
|
||||||
// Transforming a prefix into an empty string requires deleting every digit.
|
|
||||||
for ($row = 0; $row <= $leftLength; $row++) {
|
|
||||||
$matrix[$row] = [$row];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transforming an empty string into a prefix requires inserting every digit.
|
|
||||||
for ($column = 0; $column <= $rightLength; $column++) {
|
|
||||||
$matrix[0][$column] = $column;
|
|
||||||
}
|
|
||||||
|
|
||||||
for ($row = 1; $row <= $leftLength; $row++) {
|
|
||||||
for ($column = 1; $column <= $rightLength; $column++) {
|
|
||||||
$substitutionCost = $left[$row - 1] === $right[$column - 1] ? 0 : 1;
|
|
||||||
$deletionDistance = $matrix[$row - 1][$column] + 1;
|
|
||||||
$insertionDistance = $matrix[$row][$column - 1] + 1;
|
|
||||||
$substitutionDistance = $matrix[$row - 1][$column - 1] + $substitutionCost;
|
|
||||||
|
|
||||||
// Keep the cheapest way to align the two prefixes at this position.
|
|
||||||
$matrix[$row][$column] = min(
|
|
||||||
$deletionDistance,
|
|
||||||
$insertionDistance,
|
|
||||||
$substitutionDistance,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Count two adjacent inverted digits as one edit instead of two substitutions.
|
|
||||||
if (
|
|
||||||
$row > 1
|
|
||||||
&& $column > 1
|
|
||||||
&& $left[$row - 1] === $right[$column - 2]
|
|
||||||
&& $left[$row - 2] === $right[$column - 1]
|
|
||||||
) {
|
|
||||||
$matrix[$row][$column] = min(
|
|
||||||
$matrix[$row][$column],
|
|
||||||
$matrix[$row - 2][$column - 2] + 1,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return $matrix[$leftLength][$rightLength];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep only digits and left-pad seven-digit DNIs so comparisons preserve
|
|
||||||
* the leading zero that is present when the DNI is extracted from a CUIT.
|
|
||||||
*/
|
|
||||||
public function normalize(string $dni): string
|
|
||||||
{
|
|
||||||
$digits = preg_replace('/\D+/', '', $dni) ?? '';
|
|
||||||
|
|
||||||
return str_pad($digits, 8, '0', STR_PAD_LEFT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,6 @@ Route::prefix('tenants/{tenant:codigo}')->middleware('auth:sanctum')->group(func
|
|||||||
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
Route::get('compras/{compra}', [PurchaseController::class, 'show']);
|
||||||
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
Route::patch('compras/{compra}/customer-data', [PurchaseController::class, 'updateCustomerData']);
|
||||||
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
Route::post('compras/{compra}/payment-intent', [PurchaseController::class, 'paymentIntent']);
|
||||||
Route::post('compras/{compra}/complete', [PurchaseController::class, 'complete']);
|
|
||||||
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
Route::post('compras/{compra}/review', [PurchaseController::class, 'submitForReview']);
|
||||||
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
Route::post('compras/{compra}/cancel', [PurchaseController::class, 'cancel']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
namespace App\Domains\Sale\Controllers\AdminApp;
|
namespace App\Domains\Sale\Controllers\AdminApp;
|
||||||
|
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleModificationIndexRequest;
|
|
||||||
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
|
use App\Domains\Sale\Requests\AdminAppSaleModificationPdfRequest;
|
||||||
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
|
use App\Domains\Sale\Requests\AdminAppSalePdfRequest;
|
||||||
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
use App\Domains\Sale\Resources\AdminApp\SaleDetailResource;
|
||||||
@@ -68,13 +67,11 @@ class SaleController extends Controller
|
|||||||
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
return new SaleResource($this->saleService->cancel($tenant, $sale));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function modifications(
|
public function modifications(Request $request): AnonymousResourceCollection
|
||||||
AdminAppSaleModificationIndexRequest $request,
|
{
|
||||||
): AnonymousResourceCollection {
|
|
||||||
return SaleModificationResource::collection(
|
return SaleModificationResource::collection(
|
||||||
$this->saleService->modifications(
|
$this->saleService->modifications(
|
||||||
$request->user()->tenant()->firstOrFail(),
|
$request->user()->tenant()->firstOrFail()
|
||||||
$request->validated(),
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -96,7 +93,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->salePdfService->downloadModifications(
|
return $this->salePdfService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
$this->saleService->modificationsForExport($tenant),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -119,7 +116,7 @@ class SaleController extends Controller
|
|||||||
|
|
||||||
return $this->saleExcelService->downloadModifications(
|
return $this->saleExcelService->downloadModifications(
|
||||||
$tenant,
|
$tenant,
|
||||||
$this->saleService->modificationsForExport($tenant, $request->validated()),
|
$this->saleService->modificationsForExport($tenant),
|
||||||
$request->validated('timezone'),
|
$request->validated('timezone'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class AdminAppSaleIndexRequest extends FormRequest
|
|||||||
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
||||||
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
|
||||||
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
||||||
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::adminStatusCodes())],
|
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
|
||||||
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
|
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
|
||||||
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
|
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
|
||||||
'page' => ['sometimes', 'integer', 'min:1'],
|
'page' => ['sometimes', 'integer', 'min:1'],
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Sale\Requests;
|
|
||||||
|
|
||||||
class AdminAppSaleModificationIndexRequest extends AdminAppSaleIndexRequest
|
|
||||||
{
|
|
||||||
/** @return array<string, list<string>> */
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
$rules = parent::rules();
|
|
||||||
|
|
||||||
unset($rules['sort_by'], $rules['sort_direction']);
|
|
||||||
|
|
||||||
return $rules;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -3,14 +3,19 @@
|
|||||||
namespace App\Domains\Sale\Requests;
|
namespace App\Domains\Sale\Requests;
|
||||||
|
|
||||||
use App\Domains\Shared\Rules\ValidTimezone;
|
use App\Domains\Shared\Rules\ValidTimezone;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class AdminAppSaleModificationPdfRequest extends AdminAppSaleModificationIndexRequest
|
class AdminAppSaleModificationPdfRequest extends FormRequest
|
||||||
{
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/** @return array<string, list<string>> */
|
/** @return array<string, list<string>> */
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
...parent::rules(),
|
|
||||||
'timezone' => ['required', 'string', new ValidTimezone],
|
'timezone' => ['required', 'string', new ValidTimezone],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,12 +23,6 @@ class SaleModificationResource extends JsonResource
|
|||||||
'attribute' => $this->attribute,
|
'attribute' => $this->attribute,
|
||||||
'old_value' => $this->old_value,
|
'old_value' => $this->old_value,
|
||||||
'new_value' => $this->new_value,
|
'new_value' => $this->new_value,
|
||||||
'admin_status' => is_string($this->new_value)
|
|
||||||
? Purchase::adminStatusFor($this->new_value)
|
|
||||||
: null,
|
|
||||||
'status_label' => is_string($this->new_value)
|
|
||||||
? Purchase::adminStatusNameFor($this->new_value)
|
|
||||||
: null,
|
|
||||||
'changed_at' => $this->changed_at->utc()->toIso8601String(),
|
'changed_at' => $this->changed_at->utc()->toIso8601String(),
|
||||||
'date' => $this->changed_at->format('Y-m-d'),
|
'date' => $this->changed_at->format('Y-m-d'),
|
||||||
'time' => $this->changed_at->format('H:i:s'),
|
'time' => $this->changed_at->format('H:i:s'),
|
||||||
@@ -37,8 +31,6 @@ class SaleModificationResource extends JsonResource
|
|||||||
'id' => $sale->id,
|
'id' => $sale->id,
|
||||||
'customer_name' => $sale->nombre_apellido,
|
'customer_name' => $sale->nombre_apellido,
|
||||||
'status' => $sale->status,
|
'status' => $sale->status,
|
||||||
'admin_status' => Purchase::adminStatusFor($sale->status),
|
|
||||||
'status_label' => Purchase::adminStatusNameFor($sale->status),
|
|
||||||
] : null,
|
] : null,
|
||||||
'modified_by' => $user ? [
|
'modified_by' => $user ? [
|
||||||
'id' => $user->id,
|
'id' => $user->id,
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ class SaleResource extends JsonResource
|
|||||||
'customer_name' => $this->nombre_apellido,
|
'customer_name' => $this->nombre_apellido,
|
||||||
'quantity' => (int) ($this->quantity ?? 0),
|
'quantity' => (int) ($this->quantity ?? 0),
|
||||||
'status' => $this->status,
|
'status' => $this->status,
|
||||||
'admin_status' => Purchase::adminStatusFor($this->status),
|
|
||||||
'status_label' => Purchase::adminStatusNameFor($this->status),
|
|
||||||
'total' => number_format((float) $this->total, 2, '.', ''),
|
'total' => number_format((float) $this->total, 2, '.', ''),
|
||||||
'tickets_count' => $ticketsCount,
|
'tickets_count' => $ticketsCount,
|
||||||
'has_generated_tickets' => $ticketsCount > 0,
|
'has_generated_tickets' => $ticketsCount > 0,
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ class AdminAppSaleExcelService
|
|||||||
|
|
||||||
private function saleStatus(string $status): string
|
private function saleStatus(string $status): string
|
||||||
{
|
{
|
||||||
return Purchase::adminStatusNameFor($status) ?? $status;
|
return match ($status) {
|
||||||
|
Purchase::STATUS_PAID => 'Confirmado',
|
||||||
|
Purchase::STATUS_CREATED => 'Por completar datos',
|
||||||
|
Purchase::STATUS_PENDING_PAYMENT, Purchase::STATUS_IN_REVIEW => 'Esperando pago',
|
||||||
|
default => 'Anulado',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,24 +92,18 @@ class AdminAppSaleService
|
|||||||
return $this->salesQuery($tenant, $filters)->get();
|
return $this->salesQuery($tenant, $filters)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return LengthAwarePaginator<ValueChange> */
|
||||||
* @param array<string, mixed> $filters
|
public function modifications(Tenant $tenant): LengthAwarePaginator
|
||||||
* @return LengthAwarePaginator<ValueChange>
|
|
||||||
*/
|
|
||||||
public function modifications(Tenant $tenant, array $filters = []): LengthAwarePaginator
|
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant, $filters)
|
return $this->modificationsQuery($tenant)
|
||||||
->paginateFromRequest()
|
->paginateFromRequest()
|
||||||
->withQueryString();
|
->withQueryString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return Collection<int, ValueChange> */
|
||||||
* @param array<string, mixed> $filters
|
public function modificationsForExport(Tenant $tenant): Collection
|
||||||
* @return Collection<int, ValueChange>
|
|
||||||
*/
|
|
||||||
public function modificationsForExport(Tenant $tenant, array $filters = []): Collection
|
|
||||||
{
|
{
|
||||||
return $this->modificationsQuery($tenant, $filters)->get();
|
return $this->modificationsQuery($tenant)->get();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed> $filters */
|
/** @param array<string, mixed> $filters */
|
||||||
@@ -132,6 +126,7 @@ class AdminAppSaleService
|
|||||||
|
|
||||||
return Purchase::query()
|
return Purchase::query()
|
||||||
->where('tenant_codigo', $tenant->codigo)
|
->where('tenant_codigo', $tenant->codigo)
|
||||||
|
->where('status', '!=', Purchase::STATUS_SUPERSEDED)
|
||||||
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
||||||
$term = trim($search);
|
$term = trim($search);
|
||||||
|
|
||||||
@@ -149,10 +144,12 @@ class AdminAppSaleService
|
|||||||
)
|
)
|
||||||
->when(
|
->when(
|
||||||
$filters['status'] ?? null,
|
$filters['status'] ?? null,
|
||||||
fn (Builder $query, string $status): Builder => $query->whereIn(
|
fn (Builder $query, string $status): Builder => $status === Purchase::STATUS_PENDING_PAYMENT
|
||||||
'status',
|
? $query->whereIn('status', [
|
||||||
Purchase::realStatusesForAdminStatus($status),
|
Purchase::STATUS_PENDING_PAYMENT,
|
||||||
)
|
Purchase::STATUS_IN_REVIEW,
|
||||||
|
])
|
||||||
|
: $query->where('status', $status)
|
||||||
)
|
)
|
||||||
->select('compras.*')
|
->select('compras.*')
|
||||||
->selectRaw(
|
->selectRaw(
|
||||||
@@ -165,50 +162,12 @@ class AdminAppSaleService
|
|||||||
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** @return Builder<ValueChange> */
|
||||||
* @param array<string, mixed> $filters
|
protected function modificationsQuery(Tenant $tenant): Builder
|
||||||
* @return Builder<ValueChange>
|
|
||||||
*/
|
|
||||||
protected function modificationsQuery(Tenant $tenant, array $filters): Builder
|
|
||||||
{
|
{
|
||||||
return ValueChange::query()
|
return ValueChange::query()
|
||||||
->where('tenant_code', $tenant->codigo)
|
->where('tenant_code', $tenant->codigo)
|
||||||
->where('trackable_type', (new Purchase)->getMorphClass())
|
->where('trackable_type', (new Purchase)->getMorphClass())
|
||||||
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
|
|
||||||
$term = trim($search);
|
|
||||||
|
|
||||||
$query->where(function (Builder $query) use ($term): void {
|
|
||||||
$query
|
|
||||||
->where('trackable_id', 'like', "%{$term}%")
|
|
||||||
->orWhereHasMorph(
|
|
||||||
'trackable',
|
|
||||||
[Purchase::class],
|
|
||||||
function (Builder $sales) use ($term): void {
|
|
||||||
$sales
|
|
||||||
->where('nombre_apellido', 'like', "%{$term}%")
|
|
||||||
->orWhere('created_at', 'like', "%{$term}%");
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->when(
|
|
||||||
$filters['id'] ?? null,
|
|
||||||
fn (Builder $query, int $id): Builder => $query->where('trackable_id', $id)
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
$filters['sale_date'] ?? null,
|
|
||||||
fn (Builder $query, string $date): Builder => $query->whereHasMorph(
|
|
||||||
'trackable',
|
|
||||||
[Purchase::class],
|
|
||||||
fn (Builder $sales): Builder => $sales->whereDate('created_at', $date),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
->when(
|
|
||||||
$filters['status'] ?? null,
|
|
||||||
fn (Builder $query, string $status): Builder => $query
|
|
||||||
->where('attribute', 'status')
|
|
||||||
->whereIn('new_value', Purchase::realStatusesForAdminStatus($status))
|
|
||||||
)
|
|
||||||
->with(['trackable', 'user'])
|
->with(['trackable', 'user'])
|
||||||
->orderByDesc('changed_at')
|
->orderByDesc('changed_at')
|
||||||
->orderByDesc('id');
|
->orderByDesc('id');
|
||||||
|
|||||||
@@ -9,8 +9,7 @@ Provee consultas administrativas y exportaciones de ventas confirmadas, además
|
|||||||
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
- `AdminAppSaleService`: pagina ventas, calcula totales y obtiene colecciones para exportación; también consulta modificaciones.
|
||||||
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
- `AdminAppSalePdfService`: genera descargas PDF de ventas y de cambios.
|
||||||
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
|
- `AdminAppSaleExcelService`: genera descargas Excel de ventas y de cambios.
|
||||||
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación de ventas.
|
- `AdminAppSaleIndexRequest`: valida filtros del listado y la exportación.
|
||||||
- `AdminAppSaleModificationIndexRequest`: valida los filtros compartidos por el historial y sus exportaciones.
|
|
||||||
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
- `SaleResource` y `SaleModificationResource`: representan ventas e historial para AdminApp.
|
||||||
- `SaleController`: entrada HTTP del panel.
|
- `SaleController`: entrada HTTP del panel.
|
||||||
|
|
||||||
@@ -28,5 +27,3 @@ Consume compras de `Purchase`, datos del tenant y entradas de `Logging`. No es d
|
|||||||
## Consideraciones
|
## Consideraciones
|
||||||
|
|
||||||
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
La consulta paginada y la colección de exportación deben aplicar los mismos filtros para evitar diferencias entre pantalla, PDF y Excel.
|
||||||
|
|
||||||
El historial comparte con ventas los filtros de búsqueda, ID, fecha de venta y estado. En el historial, el estado se evalúa sobre `ValueChange.new_value`: representa el resultado de esa modificación y no el estado actual de la venta.
|
|
||||||
|
|||||||
@@ -6,5 +6,8 @@ use Illuminate\Support\Facades\Route;
|
|||||||
Route::prefix('v1/adminapp/tenant')
|
Route::prefix('v1/adminapp/tenant')
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::apiResource('staff', AdminAppStaffController::class)->except('show');
|
Route::get('staff', [AdminAppStaffController::class, 'index']);
|
||||||
|
Route::post('staff', [AdminAppStaffController::class, 'store']);
|
||||||
|
Route::put('staff/{staff}', [AdminAppStaffController::class, 'update']);
|
||||||
|
Route::delete('staff/{staff}', [AdminAppStaffController::class, 'destroy']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\StorageTest\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Attachable\Services\AttachmentService;
|
|
||||||
use App\Domains\StorageTest\Requests\GenerateS3TemporaryUrlRequest;
|
|
||||||
use App\Domains\StorageTest\Requests\StoreS3TestFileRequest;
|
|
||||||
use App\Domains\StorageTest\Services\S3TestService;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
|
|
||||||
class S3TestController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected AttachmentService $attachmentService,
|
|
||||||
protected S3TestService $s3TestService,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreS3TestFileRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$attachment = $this->attachmentService->store(
|
|
||||||
$request->file('file') ?? (string) $request->validated('file_base64'),
|
|
||||||
$request->validated('path'),
|
|
||||||
);
|
|
||||||
|
|
||||||
$temporaryUrl = $this->s3TestService->generateTemporaryUrl(
|
|
||||||
$attachment->path,
|
|
||||||
(int) $request->validated('expires_in_minutes', 10),
|
|
||||||
);
|
|
||||||
|
|
||||||
return response()->json(
|
|
||||||
[
|
|
||||||
'id' => $attachment->id,
|
|
||||||
'key' => $attachment->key,
|
|
||||||
'path' => $attachment->path,
|
|
||||||
'filename' => $attachment->filename,
|
|
||||||
'type' => $attachment->type->value,
|
|
||||||
'mime_type' => $attachment->mime_type,
|
|
||||||
'extension' => $attachment->extension,
|
|
||||||
'size' => $attachment->size,
|
|
||||||
'temporary_url' => $temporaryUrl['temporary_url'],
|
|
||||||
'temporary_url_expires_at' => $temporaryUrl['temporary_url_expires_at'],
|
|
||||||
],
|
|
||||||
201,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function temporaryUrl(GenerateS3TemporaryUrlRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
return response()->json(
|
|
||||||
$this->s3TestService->generateTemporaryUrl(
|
|
||||||
$request->validated('path'),
|
|
||||||
(int) $request->validated('expires_in_minutes', 10),
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\StorageTest\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class GenerateS3TemporaryUrlRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<int, string>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'path' => ['required', 'string', 'max:2048'],
|
|
||||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\StorageTest\Requests;
|
|
||||||
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
|
|
||||||
class StoreS3TestFileRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, array<int, string>>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'file' => ['nullable', 'file', 'max:10240', 'required_without:file_base64'],
|
|
||||||
'file_base64' => ['nullable', 'string', 'required_without:file'],
|
|
||||||
'path' => ['required', 'string', 'max:2048'],
|
|
||||||
'expires_in_minutes' => ['nullable', 'integer', 'min:1', 'max:1440'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\StorageTest\Services;
|
|
||||||
|
|
||||||
use Illuminate\Http\UploadedFile;
|
|
||||||
use Illuminate\Support\Facades\Log;
|
|
||||||
use Illuminate\Support\Facades\Storage;
|
|
||||||
use Illuminate\Support\Str;
|
|
||||||
use RuntimeException;
|
|
||||||
|
|
||||||
class S3TestService
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, int|string|null>
|
|
||||||
*/
|
|
||||||
public function storeTestFile(
|
|
||||||
UploadedFile $file,
|
|
||||||
?string $directory = null,
|
|
||||||
int $expiresInMinutes = 10,
|
|
||||||
): array {
|
|
||||||
$directory = $this->normalizeDirectory($directory);
|
|
||||||
$disk = Storage::disk('s3');
|
|
||||||
$path = $disk->putFile($directory, $file);
|
|
||||||
|
|
||||||
if (! is_string($path) || $path === '') {
|
|
||||||
Log::error('S3 upload returned an empty path.', [
|
|
||||||
'disk' => 's3',
|
|
||||||
'directory' => $directory,
|
|
||||||
'original_name' => $file->getClientOriginalName(),
|
|
||||||
'mime_type' => $file->getClientMimeType(),
|
|
||||||
'size' => $file->getSize(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
throw new RuntimeException('No se pudo subir el archivo al disco s3.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'disk' => 's3',
|
|
||||||
'directory' => $directory,
|
|
||||||
'key' => $path,
|
|
||||||
'path' => $path,
|
|
||||||
'filename' => basename($path),
|
|
||||||
'original_name' => $file->getClientOriginalName(),
|
|
||||||
'mime_type' => $file->getClientMimeType(),
|
|
||||||
'extension' => $file->extension(),
|
|
||||||
'size' => $file->getSize(),
|
|
||||||
'temporary_url' => $disk->temporaryUrl($path, now()->addMinutes($expiresInMinutes)),
|
|
||||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, string>
|
|
||||||
*/
|
|
||||||
public function generateTemporaryUrl(string $path, int $expiresInMinutes = 10): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'disk' => 's3',
|
|
||||||
'key' => $path,
|
|
||||||
'path' => $path,
|
|
||||||
'temporary_url' => $this->temporaryUrlForPath($path, $expiresInMinutes),
|
|
||||||
'temporary_url_expires_at' => now()->addMinutes($expiresInMinutes)->toIso8601String(),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function temporaryUrlForPath(string $path, int $expiresInMinutes): string
|
|
||||||
{
|
|
||||||
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes($expiresInMinutes));
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function normalizeDirectory(?string $directory): string
|
|
||||||
{
|
|
||||||
$directory = trim((string) $directory, '/');
|
|
||||||
|
|
||||||
if ($directory !== '') {
|
|
||||||
return $directory;
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'testing/attachments/'.now()->format('Y/m/d').'/'.Str::uuid();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
# Dominio StorageTest
|
|
||||||
|
|
||||||
## Propósito
|
|
||||||
|
|
||||||
Expone operaciones técnicas para comprobar la escritura en S3 y la generación de URL temporales.
|
|
||||||
|
|
||||||
## Componentes
|
|
||||||
|
|
||||||
- `S3TestController`: recibe solicitudes de carga y URL temporal.
|
|
||||||
- `S3TestService`: almacena un archivo de prueba y genera el enlace firmado.
|
|
||||||
- `StoreS3TestFileRequest`: valida la carga.
|
|
||||||
- `GenerateS3TemporaryUrlRequest`: valida ruta y tiempo de expiración.
|
|
||||||
|
|
||||||
## Endpoints
|
|
||||||
|
|
||||||
Bajo `/storage-test/s3`:
|
|
||||||
|
|
||||||
- `POST /upload`.
|
|
||||||
- `GET /temporary-url`.
|
|
||||||
|
|
||||||
## Consideraciones
|
|
||||||
|
|
||||||
Es infraestructura de diagnóstico, no una API funcional de archivos. Debe restringirse por entorno o autorización. Para adjuntos de negocio se debe usar el dominio `Attachable`.
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\StorageTest\Controllers\S3TestController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::prefix('storage-test/s3')->group(function (): void {
|
|
||||||
Route::post('upload', [S3TestController::class, 'store']);
|
|
||||||
Route::get('temporary-url', [S3TestController::class, 'temporaryUrl']);
|
|
||||||
});
|
|
||||||
@@ -5,7 +5,6 @@ namespace App\Domains\Tenant\Controllers\AdminApp;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtraRequest;
|
use App\Domains\Tenant\Requests\AdminApp\UpdateWebsiteExtraRequest;
|
||||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtraResource;
|
|
||||||
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
|
use App\Domains\Tenant\Resources\AdminApp\WebsiteExtrasResource;
|
||||||
use App\Domains\Tenant\Services\TenantInformationService;
|
use App\Domains\Tenant\Services\TenantInformationService;
|
||||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
use App\Domains\Tenant\Services\WebsiteExtraService;
|
||||||
@@ -26,20 +25,6 @@ class WebsiteExtraController extends Controller
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function showExtra(Request $request, string $websiteExtraCode): WebsiteExtraResource
|
|
||||||
{
|
|
||||||
$tenant = $this->loadTenant($request->user());
|
|
||||||
$definition = $this->websiteExtraService->definitionForTenant($tenant, $websiteExtraCode);
|
|
||||||
$websiteExtra = $tenant->websiteExtras
|
|
||||||
->firstWhere('website_type_extra_id', $definition->id);
|
|
||||||
|
|
||||||
if (! $websiteExtra) {
|
|
||||||
abort(404);
|
|
||||||
}
|
|
||||||
|
|
||||||
return WebsiteExtraResource::make($websiteExtra);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(
|
public function update(
|
||||||
UpdateWebsiteExtraRequest $request,
|
UpdateWebsiteExtraRequest $request,
|
||||||
string $websiteExtraCode
|
string $websiteExtraCode
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Controllers;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Tenant\Requests\StoreTenantRequest;
|
|
||||||
use App\Domains\Tenant\Requests\UpdateTenantRequest;
|
|
||||||
use App\Domains\Tenant\Resources\TenantResource;
|
|
||||||
use App\Domains\Tenant\Services\TenantInformationService;
|
|
||||||
use App\Domains\Tenant\Services\TenantService;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\JsonResponse;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
|
|
||||||
class TenantController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
protected TenantService $tenantService,
|
|
||||||
protected TenantInformationService $tenantInformationService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function index(): JsonResponse
|
|
||||||
{
|
|
||||||
$tenants = Tenant::query()
|
|
||||||
->latest()
|
|
||||||
->paginateFromRequest();
|
|
||||||
|
|
||||||
$this->tenantInformationService->loadMany($tenants->getCollection());
|
|
||||||
|
|
||||||
return TenantResource::collection($tenants)->response();
|
|
||||||
}
|
|
||||||
|
|
||||||
public function store(StoreTenantRequest $request): JsonResponse
|
|
||||||
{
|
|
||||||
$tenant = $this->tenantService->create($request->validated());
|
|
||||||
|
|
||||||
return TenantResource::make(
|
|
||||||
$this->tenantInformationService->load($tenant)
|
|
||||||
)->response()->setStatusCode(201);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function show(Tenant $tenant): TenantResource
|
|
||||||
{
|
|
||||||
return TenantResource::make(
|
|
||||||
$this->tenantInformationService->load($tenant)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function update(UpdateTenantRequest $request, Tenant $tenant): TenantResource
|
|
||||||
{
|
|
||||||
$tenant = $this->tenantService->update($tenant, $request->validated());
|
|
||||||
|
|
||||||
return TenantResource::make(
|
|
||||||
$this->tenantInformationService->load($tenant)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function destroy(Tenant $tenant): Response
|
|
||||||
{
|
|
||||||
$tenant->delete();
|
|
||||||
|
|
||||||
return response()->noContent();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
|
||||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
|
||||||
use App\Domains\Tenant\Services\WebsiteExtraService;
|
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class StoreTenantRequest extends FormRequest
|
|
||||||
{
|
|
||||||
protected bool $hasInvalidDomain = false;
|
|
||||||
|
|
||||||
protected bool $hasInvalidBasePath = false;
|
|
||||||
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
|
||||||
{
|
|
||||||
$rawDomain = $this->input('dominio');
|
|
||||||
$hasExplicitBasePath = $this->has('base_path');
|
|
||||||
$rawBasePath = $hasExplicitBasePath
|
|
||||||
? $this->input('base_path')
|
|
||||||
: TenantDomainNormalizer::pathFromDomain($rawDomain);
|
|
||||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
|
||||||
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
|
|
||||||
|
|
||||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
|
||||||
&& $normalizedDomain === null;
|
|
||||||
$this->hasInvalidBasePath = ($hasExplicitBasePath && ! is_string($rawBasePath))
|
|
||||||
|| $normalizedBasePath === null;
|
|
||||||
|
|
||||||
$this->merge([
|
|
||||||
'dominio' => $normalizedDomain,
|
|
||||||
'base_path' => $normalizedBasePath,
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
$logoRule = ['required', new ImageOrBase64Rule];
|
|
||||||
|
|
||||||
return array_merge([
|
|
||||||
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
|
|
||||||
'codigo' => ['required', 'string', 'max:255', Rule::unique('tenants', 'codigo')],
|
|
||||||
'nombre' => ['required', 'string', 'max:255'],
|
|
||||||
'dominio' => [
|
|
||||||
'bail',
|
|
||||||
function (string $attribute, mixed $value, Closure $fail): void {
|
|
||||||
if ($this->hasInvalidDomain) {
|
|
||||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('tenants', 'dominio')
|
|
||||||
->where('base_path', $this->input('base_path')),
|
|
||||||
],
|
|
||||||
'base_path' => [
|
|
||||||
'bail',
|
|
||||||
function (string $attribute, mixed $value, Closure $fail): void {
|
|
||||||
if ($this->hasInvalidBasePath) {
|
|
||||||
$fail("The {$attribute} field must contain a valid URL path.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('tenants', 'base_path')
|
|
||||||
->where('dominio', $this->input('dominio')),
|
|
||||||
],
|
|
||||||
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'phone' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'primary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'secondary_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'danger_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'success_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'header_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'footer_bg_color' => ['required', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'header_logo' => $logoRule,
|
|
||||||
'footer_logo' => $logoRule,
|
|
||||||
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
|
||||||
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
|
||||||
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
|
||||||
'social_media' => ['sometimes', 'array'],
|
|
||||||
'social_media.*.code' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'distinct',
|
|
||||||
Rule::exists('social_media', 'code'),
|
|
||||||
],
|
|
||||||
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
|
||||||
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
|
||||||
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
|
||||||
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
|
||||||
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
|
||||||
'display_categories' => ['sometimes', 'boolean'],
|
|
||||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
|
||||||
'display_cart' => ['sometimes', 'boolean'],
|
|
||||||
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
|
|
||||||
'checkout_editing_policy' => [
|
|
||||||
'sometimes',
|
|
||||||
Rule::in([CartEditingPolicy::Disabled->value]),
|
|
||||||
],
|
|
||||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
|
||||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
|
||||||
'website_type_code' => [
|
|
||||||
'required_with:extras',
|
|
||||||
'sometimes',
|
|
||||||
'string',
|
|
||||||
Rule::exists('website_type', 'codigo'),
|
|
||||||
],
|
|
||||||
], app(WebsiteExtraService::class)->requestRules($this->input('website_type_code')));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,143 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Enums\GroupLayout;
|
|
||||||
use App\Domains\Catalog\Enums\ProductLayout;
|
|
||||||
use App\Domains\Shared\Rules\ImageOrBase64Rule;
|
|
||||||
use App\Domains\Tenant\Enums\CartEditingPolicy;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Tenant\Support\TenantDomainNormalizer;
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class UpdateTenantRequest extends FormRequest
|
|
||||||
{
|
|
||||||
protected bool $hasInvalidDomain = false;
|
|
||||||
|
|
||||||
protected bool $hasInvalidBasePath = false;
|
|
||||||
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected function prepareForValidation(): void
|
|
||||||
{
|
|
||||||
if ($this->has('dominio')) {
|
|
||||||
$rawDomain = $this->input('dominio');
|
|
||||||
$normalizedDomain = TenantDomainNormalizer::normalize($rawDomain);
|
|
||||||
$embeddedBasePath = TenantDomainNormalizer::pathFromDomain($rawDomain);
|
|
||||||
|
|
||||||
$this->hasInvalidDomain = TenantDomainNormalizer::hasValue($rawDomain)
|
|
||||||
&& ($normalizedDomain === null || $embeddedBasePath === null);
|
|
||||||
|
|
||||||
$this->merge([
|
|
||||||
'dominio' => $normalizedDomain,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (! $this->has('base_path') && $embeddedBasePath !== null && $embeddedBasePath !== '/') {
|
|
||||||
$this->merge(['base_path' => $embeddedBasePath]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($this->has('base_path')) {
|
|
||||||
$rawBasePath = $this->input('base_path');
|
|
||||||
$normalizedBasePath = TenantDomainNormalizer::normalizePath($rawBasePath);
|
|
||||||
|
|
||||||
$this->hasInvalidBasePath = ! is_string($rawBasePath)
|
|
||||||
|| $normalizedBasePath === null;
|
|
||||||
|
|
||||||
$this->merge(['base_path' => $normalizedBasePath]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
/** @var Tenant|null $tenant */
|
|
||||||
$tenant = $this->route('tenant');
|
|
||||||
$domain = $this->input('dominio', $tenant?->dominio);
|
|
||||||
$basePath = $this->input('base_path', $tenant?->base_path ?? '/');
|
|
||||||
|
|
||||||
$logoRule = ['nullable', new ImageOrBase64Rule];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'client_id' => ['sometimes', 'integer', Rule::exists('clients', 'id')],
|
|
||||||
'codigo' => [
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('tenants', 'codigo')->ignore($tenant?->id),
|
|
||||||
],
|
|
||||||
'nombre' => ['nullable', 'string', 'max:255'],
|
|
||||||
'dominio' => [
|
|
||||||
'bail',
|
|
||||||
function (string $attribute, mixed $value, Closure $fail): void {
|
|
||||||
if ($this->hasInvalidDomain) {
|
|
||||||
$fail("The {$attribute} field must contain a valid domain or URL.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('tenants', 'dominio')
|
|
||||||
->where('base_path', $basePath)
|
|
||||||
->ignore($tenant?->id),
|
|
||||||
],
|
|
||||||
'base_path' => [
|
|
||||||
'bail',
|
|
||||||
function (string $attribute, mixed $value, Closure $fail): void {
|
|
||||||
if ($this->hasInvalidBasePath) {
|
|
||||||
$fail("The {$attribute} field must contain a valid URL path.");
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'nullable',
|
|
||||||
'string',
|
|
||||||
'max:255',
|
|
||||||
Rule::unique('tenants', 'base_path')
|
|
||||||
->where('dominio', $domain)
|
|
||||||
->ignore($tenant?->id),
|
|
||||||
],
|
|
||||||
'site_title' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'address' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'phone' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'primary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'secondary_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'danger_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'success_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'header_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'footer_bg_color' => ['nullable', 'string', 'regex:/^#([a-fA-F0-9]{3,4}|[a-fA-F0-9]{6}|[a-fA-F0-9]{8})$/'],
|
|
||||||
'header_logo' => $logoRule,
|
|
||||||
'footer_logo' => $logoRule,
|
|
||||||
'favicon' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
|
||||||
'header_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
|
||||||
'footer_bg_image' => ['sometimes', 'nullable', new ImageOrBase64Rule],
|
|
||||||
'social_media' => ['sometimes', 'array'],
|
|
||||||
'social_media.*.code' => [
|
|
||||||
'required',
|
|
||||||
'string',
|
|
||||||
'distinct',
|
|
||||||
Rule::exists('social_media', 'code'),
|
|
||||||
],
|
|
||||||
'social_media.*.url' => ['required', 'url', 'max:2048'],
|
|
||||||
'social_media.*.orden' => ['sometimes', 'integer', 'min:0', 'distinct'],
|
|
||||||
'search_product_layout' => ['sometimes', Rule::enum(ProductLayout::class)],
|
|
||||||
'search_group_layout' => ['sometimes', Rule::enum(GroupLayout::class)],
|
|
||||||
'search_items_per_page' => ['sometimes', 'integer', 'min:4', 'max:48'],
|
|
||||||
'display_categories' => ['sometimes', 'boolean'],
|
|
||||||
'display_seach_bar' => ['sometimes', 'boolean'],
|
|
||||||
'display_cart' => ['sometimes', 'boolean'],
|
|
||||||
'cart_editing_policy' => ['sometimes', Rule::enum(CartEditingPolicy::class)],
|
|
||||||
'checkout_editing_policy' => [
|
|
||||||
'sometimes',
|
|
||||||
Rule::in([CartEditingPolicy::Disabled->value]),
|
|
||||||
],
|
|
||||||
'display_cart_item_images' => ['sometimes', 'boolean'],
|
|
||||||
'scanner_category_validation_enabled' => ['sometimes', 'boolean'],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Tenant\Resources\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Attachable\Models\Attachment;
|
|
||||||
use App\Domains\Attachable\Models\AttachmentCrop;
|
|
||||||
use App\Domains\Tenant\Models\WebsiteExtra;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\JsonResource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @mixin WebsiteExtra
|
|
||||||
*/
|
|
||||||
class WebsiteExtraResource extends JsonResource
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* @return array<string, mixed>
|
|
||||||
*/
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'codigo' => $this->websiteTypeExtra->codigo,
|
|
||||||
'nombre' => $this->websiteTypeExtra->nombre,
|
|
||||||
'descripcion' => $this->websiteTypeExtra->descripcion,
|
|
||||||
'is_required' => $this->websiteTypeExtra->is_required,
|
|
||||||
'is_enabled' => $this->is_enabled,
|
|
||||||
'request_rules' => $this->websiteTypeExtra->config_schema['request_rules'] ?? [],
|
|
||||||
'config' => $this->formatConfig(
|
|
||||||
$this->resolvedConfig(),
|
|
||||||
fn (Attachment $attachment): string => $attachment->key
|
|
||||||
),
|
|
||||||
'resolved_config' => $this->formatConfig(
|
|
||||||
$this->resolvedAdminConfig(),
|
|
||||||
fn (Attachment $attachment): string => $attachment->getTemporaryUrl(1440)
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolvedAdminConfig(): mixed
|
|
||||||
{
|
|
||||||
$config = $this->resolvedConfig();
|
|
||||||
|
|
||||||
if (
|
|
||||||
$this->websiteTypeExtra->codigo !== 'heroConfig'
|
|
||||||
|| ! is_array($config)
|
|
||||||
|| ! ($config['background_image_id'] ?? null) instanceof Attachment
|
|
||||||
) {
|
|
||||||
return $config;
|
|
||||||
}
|
|
||||||
|
|
||||||
$attachment = $config['background_image_id'];
|
|
||||||
$fullRange = ['start_percentage' => 0.0, 'end_percentage' => 100.0];
|
|
||||||
$crops = $attachment->cropVariants->keyBy('variant');
|
|
||||||
$config['background_image_id'] = [
|
|
||||||
'url' => $attachment->getTemporaryUrl(1440),
|
|
||||||
'crops' => collect(AttachmentCrop::VARIANTS)->mapWithKeys(
|
|
||||||
function (string $variant) use ($crops, $fullRange): array {
|
|
||||||
$crop = $crops->get($variant);
|
|
||||||
|
|
||||||
return [$variant => [
|
|
||||||
'crop_horizontal' => $crop?->crop_horizontal ?? $fullRange,
|
|
||||||
'crop_vertical' => $crop?->crop_vertical ?? $fullRange,
|
|
||||||
]];
|
|
||||||
}
|
|
||||||
)->all(),
|
|
||||||
];
|
|
||||||
|
|
||||||
return $config;
|
|
||||||
}
|
|
||||||
|
|
||||||
private function formatConfig(mixed $value, callable $formatAttachment): mixed
|
|
||||||
{
|
|
||||||
if ($value instanceof Attachment) {
|
|
||||||
return $formatAttachment($value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (! is_array($value)) {
|
|
||||||
return $value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return array_map(
|
|
||||||
fn (mixed $item): mixed => $this->formatConfig($item, $formatAttachment),
|
|
||||||
$value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ Route::prefix('v1/adminapp/tenant')
|
|||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
||||||
->group(function (): void {
|
->group(function (): void {
|
||||||
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
|
Route::get('website-extras', [WebsiteExtraController::class, 'show']);
|
||||||
Route::get('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'showExtra']);
|
|
||||||
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
|
Route::put('website-extras/{websiteExtraCode}', [WebsiteExtraController::class, 'update']);
|
||||||
Route::patch('website-extras/{websiteExtraCode}/toggle', [WebsiteExtraController::class, 'toggle']);
|
Route::patch('website-extras/{websiteExtraCode}/toggle', [WebsiteExtraController::class, 'toggle']);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,3 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
use App\Domains\Tenant\Controllers\TenantController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::apiResource('tenants', TenantController::class);
|
|
||||||
|
|
||||||
require __DIR__.'/adminapp.php';
|
require __DIR__.'/adminapp.php';
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Controllers\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Ticket\Requests\AdminAppTicketExportRequest;
|
|
||||||
use App\Domains\Ticket\Requests\AdminAppTicketIndexRequest;
|
|
||||||
use App\Domains\Ticket\Resources\AdminApp\AdminAppTicketCollection;
|
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketExcelService;
|
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketPdfService;
|
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketService;
|
|
||||||
use App\Http\Controllers\Controller;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
||||||
|
|
||||||
class TicketController extends Controller
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AdminAppTicketService $ticketService,
|
|
||||||
private readonly AdminAppTicketPdfService $ticketPdfService,
|
|
||||||
private readonly AdminAppTicketExcelService $ticketExcelService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
public function index(AdminAppTicketIndexRequest $request): AdminAppTicketCollection
|
|
||||||
{
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
|
|
||||||
return new AdminAppTicketCollection(
|
|
||||||
$this->ticketService->search($tenant, $request->validated())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function downloadPdf(AdminAppTicketExportRequest $request): Response
|
|
||||||
{
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
|
|
||||||
return $this->ticketPdfService->download(
|
|
||||||
$tenant,
|
|
||||||
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
|
||||||
$request->validated('timezone'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function downloadExcel(AdminAppTicketExportRequest $request): StreamedResponse
|
|
||||||
{
|
|
||||||
$tenant = $request->user()->tenant()->firstOrFail();
|
|
||||||
|
|
||||||
return $this->ticketExcelService->download(
|
|
||||||
$tenant,
|
|
||||||
$this->ticketService->ticketsForExport($tenant, $request->validated()),
|
|
||||||
$request->validated('timezone'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,7 +42,7 @@ class GenerateTicketsForPaidPurchase
|
|||||||
$user,
|
$user,
|
||||||
$purchaseItem->cantidad,
|
$purchaseItem->cantidad,
|
||||||
$purchaseItem->source_variant_id,
|
$purchaseItem->source_variant_id,
|
||||||
$purchaseItem->getKey(),
|
$purchase->getKey(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ namespace App\Domains\Ticket\Models;
|
|||||||
use App\Domains\Auth\Models\User;
|
use App\Domains\Auth\Models\User;
|
||||||
use App\Domains\Catalog\Models\CatalogItem;
|
use App\Domains\Catalog\Models\CatalogItem;
|
||||||
use App\Domains\Catalog\Models\Variant;
|
use App\Domains\Catalog\Models\Variant;
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
use App\Domains\Purchase\Models\Purchase;
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
use App\Domains\Tenant\Models\Tenant;
|
||||||
use App\Domains\Ticket\Services\ResolvedTicketValidity;
|
use App\Domains\Ticket\Services\ResolvedTicketValidity;
|
||||||
use App\Domains\Ticket\Services\ResolvedValidityGroup;
|
use App\Domains\Ticket\Services\ResolvedValidityGroup;
|
||||||
@@ -21,7 +21,7 @@ use Illuminate\Support\Collection;
|
|||||||
#[Fillable([
|
#[Fillable([
|
||||||
'tenant_code',
|
'tenant_code',
|
||||||
'ticket',
|
'ticket',
|
||||||
'source_purchase_item_id',
|
'source_purchase_id',
|
||||||
'source_catalog_item_id',
|
'source_catalog_item_id',
|
||||||
'source_variant_id',
|
'source_variant_id',
|
||||||
'used_at',
|
'used_at',
|
||||||
@@ -56,7 +56,7 @@ class Ticket extends Model
|
|||||||
return [
|
return [
|
||||||
'source_catalog_item_id' => 'integer',
|
'source_catalog_item_id' => 'integer',
|
||||||
'source_variant_id' => 'integer',
|
'source_variant_id' => 'integer',
|
||||||
'source_purchase_item_id' => 'integer',
|
'source_purchase_id' => 'integer',
|
||||||
'used_at' => 'datetime',
|
'used_at' => 'datetime',
|
||||||
'scanner_user_id' => 'integer',
|
'scanner_user_id' => 'integer',
|
||||||
'user_id' => 'integer',
|
'user_id' => 'integer',
|
||||||
@@ -81,10 +81,10 @@ class Ticket extends Model
|
|||||||
return $this->belongsTo(User::class, 'scanner_user_id');
|
return $this->belongsTo(User::class, 'scanner_user_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<PurchaseItem, $this> */
|
/** @return BelongsTo<Purchase, $this> */
|
||||||
public function sourcePurchaseItem(): BelongsTo
|
public function sourcePurchase(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(PurchaseItem::class, 'source_purchase_item_id');
|
return $this->belongsTo(Purchase::class, 'source_purchase_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @return BelongsTo<CatalogItem, $this> */
|
/** @return BelongsTo<CatalogItem, $this> */
|
||||||
|
|||||||
@@ -1,17 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Shared\Rules\ValidTimezone;
|
|
||||||
|
|
||||||
class AdminAppTicketExportRequest extends AdminAppTicketIndexRequest
|
|
||||||
{
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
...parent::rules(),
|
|
||||||
'timezone' => ['required', 'string', new ValidTimezone],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Requests;
|
|
||||||
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketColumnService;
|
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
|
||||||
use Illuminate\Validation\Rule;
|
|
||||||
|
|
||||||
class AdminAppTicketIndexRequest extends FormRequest
|
|
||||||
{
|
|
||||||
public function authorize(): bool
|
|
||||||
{
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, list<string>> */
|
|
||||||
public function rules(): array
|
|
||||||
{
|
|
||||||
$tenant = $this->user()?->tenant()->first();
|
|
||||||
$sortableKeys = $tenant === null
|
|
||||||
? []
|
|
||||||
: app(AdminAppTicketColumnService::class)->sortableKeys($tenant);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'category' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'product' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'type' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
|
|
||||||
'size' => ['sometimes', 'nullable', 'string', 'max:255'],
|
|
||||||
'status' => [
|
|
||||||
'sometimes',
|
|
||||||
'nullable',
|
|
||||||
Rule::in([
|
|
||||||
Ticket::STATUS_ACTIVE,
|
|
||||||
Ticket::STATUS_USED,
|
|
||||||
Ticket::STATUS_EXPIRED,
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
'page' => ['sometimes', 'integer', 'min:1'],
|
|
||||||
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
|
|
||||||
'sort_by' => ['sometimes', 'nullable', 'string', Rule::in($sortableKeys)],
|
|
||||||
'sort_direction' => ['sometimes', 'nullable', 'string', Rule::in(['asc', 'desc'])],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Resources\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketResult;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Illuminate\Http\Resources\Json\ResourceCollection;
|
|
||||||
|
|
||||||
class AdminAppTicketCollection extends ResourceCollection
|
|
||||||
{
|
|
||||||
/** @var class-string<AdminAppTicketResource> */
|
|
||||||
public $collects = AdminAppTicketResource::class;
|
|
||||||
|
|
||||||
private readonly int $scannedTickets;
|
|
||||||
|
|
||||||
private readonly int $totalTickets;
|
|
||||||
|
|
||||||
public function __construct(AdminAppTicketResult $result)
|
|
||||||
{
|
|
||||||
parent::__construct($result->tickets);
|
|
||||||
|
|
||||||
$this->scannedTickets = $result->scannedTickets;
|
|
||||||
$this->totalTickets = $result->totalTickets;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{scanned_tickets: int, total_tickets: int} */
|
|
||||||
public function with(Request $request): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'scanned_tickets' => $this->scannedTickets,
|
|
||||||
'total_tickets' => $this->totalTickets,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Resources\AdminApp;
|
|
||||||
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use App\Domains\Ticket\Resources\TicketResource;
|
|
||||||
use App\Domains\Ticket\Services\AdminAppTicketRowService;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
/** @mixin Ticket */
|
|
||||||
class AdminAppTicketResource extends TicketResource
|
|
||||||
{
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function toArray(Request $request): array
|
|
||||||
{
|
|
||||||
$rowService = app(AdminAppTicketRowService::class);
|
|
||||||
$details = $rowService->details($this->resource);
|
|
||||||
|
|
||||||
return [
|
|
||||||
...parent::toArray($request),
|
|
||||||
...$details,
|
|
||||||
'values' => $rowService->values($this->resource, $details),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,103 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Services;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
|
|
||||||
class AdminAppTicketColumnService
|
|
||||||
{
|
|
||||||
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
|
||||||
|
|
||||||
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
|
|
||||||
public function columns(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
$keys = $tenant->codigo === self::FIESTA_FUTBOL_INFANTIL
|
|
||||||
? ['order_number', 'category', 'product', 'type', 'date', 'size', 'amount', 'client', 'id', 'status', 'scanned_by']
|
|
||||||
: ['order_number', 'product', 'amount', 'client', 'id', 'status', 'scanned_by'];
|
|
||||||
|
|
||||||
$columns = array_map(fn (string $key): array => $this->definitions()[$key], $keys);
|
|
||||||
|
|
||||||
if ($tenant->codigo === self::FIESTA_FUTBOL_INFANTIL) {
|
|
||||||
$columns = array_map(function (array $column): array {
|
|
||||||
if (in_array($column['key'], ['product', 'type', 'date', 'size'], true)) {
|
|
||||||
$column['sortable'] = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $column;
|
|
||||||
}, $columns);
|
|
||||||
} else {
|
|
||||||
$widths = [
|
|
||||||
'order_number' => '11%',
|
|
||||||
'product' => '15%',
|
|
||||||
'amount' => '10%',
|
|
||||||
'client' => '15%',
|
|
||||||
'id' => '8%',
|
|
||||||
'status' => '8%',
|
|
||||||
'scanned_by' => '11%',
|
|
||||||
];
|
|
||||||
$columns = array_map(function (array $column) use ($widths): array {
|
|
||||||
$column['width'] = $widths[$column['key']];
|
|
||||||
|
|
||||||
return $column;
|
|
||||||
}, $columns);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $columns;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string}> */
|
|
||||||
public function publicColumns(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
return array_map(function (array $column): array {
|
|
||||||
unset($column['excel_width']);
|
|
||||||
|
|
||||||
return $column;
|
|
||||||
}, $this->columns($tenant));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<string> */
|
|
||||||
public function sortableKeys(Tenant $tenant): array
|
|
||||||
{
|
|
||||||
return array_values(array_map(
|
|
||||||
fn (array $column): string => $column['sort_param'],
|
|
||||||
array_filter($this->columns($tenant), fn (array $column): bool => $column['sortable']),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array<string, array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int}> */
|
|
||||||
private function definitions(): array
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
'order_number' => $this->column('order_number', 'N° de orden', 'order_number', '10.5%', 14),
|
|
||||||
'category' => $this->column('category', 'Categoría', 'text', '11%', 18),
|
|
||||||
'product' => $this->column('product', 'Producto', 'text', '11%', 22),
|
|
||||||
'type' => $this->column('type', 'Tipo', 'text', '8%', 18),
|
|
||||||
'date' => $this->column('date', 'Fecha', 'text', '7%', 14),
|
|
||||||
'size' => $this->column('size', 'Talle', 'text', '6%', 12),
|
|
||||||
'amount' => $this->column('amount', 'Importe', 'currency', '8%', 15),
|
|
||||||
'client' => $this->column('client', 'Cliente', 'text', '11%', 30),
|
|
||||||
'id' => $this->column('id', 'ID', 'text', '6%', 12),
|
|
||||||
'status' => $this->column('status', 'Estado', 'status', '7%', 13),
|
|
||||||
'scanned_by' => $this->column('scanned_by', 'Escaneado por', 'text', '8%', 28),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return array{key: string, label: string, type: string, sortable: bool, sort_param: string, width: string, excel_width: int} */
|
|
||||||
private function column(
|
|
||||||
string $key,
|
|
||||||
string $label,
|
|
||||||
string $type,
|
|
||||||
string $width,
|
|
||||||
int $excelWidth,
|
|
||||||
): array {
|
|
||||||
return [
|
|
||||||
'key' => $key,
|
|
||||||
'label' => $label,
|
|
||||||
'type' => $type,
|
|
||||||
'sortable' => true,
|
|
||||||
'sort_param' => $key,
|
|
||||||
'width' => $width,
|
|
||||||
'excel_width' => $excelWidth,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,105 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Services;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use Carbon\CarbonInterface;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Style\Fill;
|
|
||||||
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
|
|
||||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
|
||||||
|
|
||||||
class AdminAppTicketExcelService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AdminAppTicketReportService $reportService,
|
|
||||||
private readonly AdminAppTicketColumnService $columnService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @param Collection<int, Ticket> $tickets */
|
|
||||||
public function download(Tenant $tenant, Collection $tickets, string $timeZone): StreamedResponse
|
|
||||||
{
|
|
||||||
$generatedAt = now();
|
|
||||||
$rows = $this->reportService->rows($tickets);
|
|
||||||
$columns = $this->columnService->columns($tenant);
|
|
||||||
$spreadsheet = new Spreadsheet;
|
|
||||||
$spreadsheet->getProperties()
|
|
||||||
->setCreator('Shopit')
|
|
||||||
->setTitle('Listado de tickets')
|
|
||||||
->setSubject($tenant->nombre);
|
|
||||||
$sheet = $spreadsheet->getActiveSheet();
|
|
||||||
$sheet->setTitle('Tickets');
|
|
||||||
$sheet->fromArray([array_column($columns, 'label')], null, 'A1');
|
|
||||||
|
|
||||||
foreach ($rows as $index => $ticket) {
|
|
||||||
$row = $index + 2;
|
|
||||||
foreach ($columns as $columnIndex => $column) {
|
|
||||||
$coordinate = Coordinate::stringFromColumnIndex($columnIndex + 1).$row;
|
|
||||||
$value = $ticket[$column['key']] ?? null;
|
|
||||||
|
|
||||||
if ($column['type'] === 'currency' && $value !== null) {
|
|
||||||
$sheet->setCellValue($coordinate, (float) $value);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($column['type'] === 'date' && $value instanceof CarbonInterface) {
|
|
||||||
$sheet->setCellValue(
|
|
||||||
$coordinate,
|
|
||||||
Date::dateTimeToExcel($value->copy()->timezone($timeZone)),
|
|
||||||
);
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sheet->setCellValueExplicit(
|
|
||||||
$coordinate,
|
|
||||||
$this->reportService->displayValue($value, $column['type'], $timeZone),
|
|
||||||
DataType::TYPE_STRING,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$lastRow = max(2, $rows->count() + 1);
|
|
||||||
$lastColumn = Coordinate::stringFromColumnIndex(count($columns));
|
|
||||||
foreach ($columns as $columnIndex => $column) {
|
|
||||||
$letter = Coordinate::stringFromColumnIndex($columnIndex + 1);
|
|
||||||
if ($column['type'] === 'currency') {
|
|
||||||
$sheet->getStyle("{$letter}2:{$letter}{$lastRow}")
|
|
||||||
->getNumberFormat()->setFormatCode('$ #,##0.00');
|
|
||||||
}
|
|
||||||
if ($column['type'] === 'date') {
|
|
||||||
$sheet->getStyle("{$letter}2:{$letter}{$lastRow}")
|
|
||||||
->getNumberFormat()->setFormatCode('dd/mm/yyyy hh:mm');
|
|
||||||
}
|
|
||||||
$sheet->getColumnDimension($letter)->setWidth($column['excel_width']);
|
|
||||||
}
|
|
||||||
$sheet->getStyle("A1:{$lastColumn}1")->applyFromArray([
|
|
||||||
'font' => ['bold' => true, 'color' => ['rgb' => 'FFFFFF']],
|
|
||||||
'fill' => [
|
|
||||||
'fillType' => Fill::FILL_SOLID,
|
|
||||||
'startColor' => ['rgb' => '26382E'],
|
|
||||||
],
|
|
||||||
'alignment' => ['vertical' => Alignment::VERTICAL_CENTER],
|
|
||||||
]);
|
|
||||||
$sheet->getRowDimension(1)->setRowHeight(24);
|
|
||||||
$sheet->freezePane('A2');
|
|
||||||
$sheet->setAutoFilter("A1:{$lastColumn}{$lastRow}");
|
|
||||||
|
|
||||||
$filename = 'tickets_'.$tenant->codigo.'_'
|
|
||||||
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.xlsx';
|
|
||||||
|
|
||||||
return response()->streamDownload(function () use ($spreadsheet): void {
|
|
||||||
(new Xlsx($spreadsheet))->save('php://output');
|
|
||||||
$spreadsheet->disconnectWorksheets();
|
|
||||||
}, $filename, [
|
|
||||||
'Content-Type' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
||||||
]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Services;
|
|
||||||
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use Barryvdh\DomPDF\Facade\Pdf;
|
|
||||||
use Barryvdh\DomPDF\PDF as DomPdf;
|
|
||||||
use Illuminate\Http\Response;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
class AdminAppTicketPdfService
|
|
||||||
{
|
|
||||||
public function __construct(
|
|
||||||
private readonly AdminAppTicketReportService $reportService,
|
|
||||||
private readonly AdminAppTicketColumnService $columnService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/** @param Collection<int, Ticket> $tickets */
|
|
||||||
public function download(Tenant $tenant, Collection $tickets, string $timeZone): Response
|
|
||||||
{
|
|
||||||
$generatedAt = now();
|
|
||||||
$columns = $this->columnService->columns($tenant);
|
|
||||||
$rows = $this->reportService->rows($tickets);
|
|
||||||
$pdf = Pdf::loadView('pdf.adminapp.tickets', [
|
|
||||||
'tenant' => $tenant,
|
|
||||||
'columns' => $columns,
|
|
||||||
'tickets' => $this->reportService->displayRows($rows, $columns, $timeZone),
|
|
||||||
'generatedAt' => $generatedAt,
|
|
||||||
'timeZone' => $timeZone,
|
|
||||||
])->setPaper('a3', 'landscape');
|
|
||||||
|
|
||||||
$this->addPageNumbers($pdf);
|
|
||||||
|
|
||||||
return $pdf->download(
|
|
||||||
'tickets_'.$tenant->codigo.'_'
|
|
||||||
.$generatedAt->copy()->timezone($timeZone)->format('Ymd_His').'.pdf'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function addPageNumbers(DomPdf $pdf): void
|
|
||||||
{
|
|
||||||
$pdf->render();
|
|
||||||
$domPdf = $pdf->getDomPDF();
|
|
||||||
$font = $domPdf->getFontMetrics()->getFont('DejaVu Sans');
|
|
||||||
|
|
||||||
$domPdf->getCanvas()->page_text(
|
|
||||||
565,
|
|
||||||
805,
|
|
||||||
'Página {PAGE_NUM} de {PAGE_COUNT}',
|
|
||||||
$font,
|
|
||||||
7,
|
|
||||||
[0.48, 0.52, 0.49],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Services;
|
|
||||||
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
class AdminAppTicketReportService
|
|
||||||
{
|
|
||||||
public function __construct(private readonly AdminAppTicketRowService $rowService) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Ticket> $tickets
|
|
||||||
* @return Collection<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function rows(Collection $tickets): Collection
|
|
||||||
{
|
|
||||||
return $this->rowService->rows($tickets);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, array<string, mixed>> $rows
|
|
||||||
* @param list<array<string, mixed>> $columns
|
|
||||||
* @return Collection<int, array<string, string>>
|
|
||||||
*/
|
|
||||||
public function displayRows(Collection $rows, array $columns, string $timeZone): Collection
|
|
||||||
{
|
|
||||||
return $this->rowService->displayRows($rows, $columns, $timeZone);
|
|
||||||
}
|
|
||||||
|
|
||||||
public function displayValue(mixed $value, string $type, string $timeZone): string
|
|
||||||
{
|
|
||||||
return $this->rowService->displayValue($value, $type, $timeZone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Services;
|
|
||||||
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
|
|
||||||
final readonly class AdminAppTicketResult
|
|
||||||
{
|
|
||||||
/** @param LengthAwarePaginator<Ticket> $tickets */
|
|
||||||
public function __construct(
|
|
||||||
public LengthAwarePaginator $tickets,
|
|
||||||
public int $scannedTickets,
|
|
||||||
public int $totalTickets,
|
|
||||||
) {}
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Services;
|
|
||||||
|
|
||||||
use App\Domains\Catalog\Models\ItemAttribute;
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
class AdminAppTicketRowService
|
|
||||||
{
|
|
||||||
private const FIESTA_FUTBOL_INFANTIL = 'fiesta_futbol_infantil';
|
|
||||||
|
|
||||||
private const CATEGORY_PRESENTATIONS = [
|
|
||||||
'alojamientos' => ['category' => 'Camping', 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null],
|
|
||||||
'camping' => ['category' => null, 'product' => 'tipo_alojamiento', 'type' => null, 'date' => null, 'size' => null],
|
|
||||||
'entradas' => ['category' => null, 'product' => 'product', 'type' => null, 'date' => null, 'size' => null],
|
|
||||||
'comidas' => ['category' => 'Comida', 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null],
|
|
||||||
'comida' => ['category' => null, 'product' => 'horario', 'type' => 'servicio', 'date' => 'event_date', 'size' => null],
|
|
||||||
'merchandising' => ['category' => null, 'product' => 'product', 'type' => 'color', 'date' => null, 'size' => 'talle'],
|
|
||||||
];
|
|
||||||
|
|
||||||
/** @return array<string, mixed> */
|
|
||||||
public function details(Ticket $ticket): array
|
|
||||||
{
|
|
||||||
$purchaseItem = $ticket->sourcePurchaseItem;
|
|
||||||
|
|
||||||
return [
|
|
||||||
'source_purchase_item_id' => $ticket->source_purchase_item_id,
|
|
||||||
'order_number' => $purchaseItem?->compra_id,
|
|
||||||
'product' => $purchaseItem?->item_nombre
|
|
||||||
?? $ticket->sourceCatalogItem?->nombre
|
|
||||||
?? $ticket->name,
|
|
||||||
'amount' => $purchaseItem?->precio_unitario,
|
|
||||||
'client' => $purchaseItem?->purchase?->nombre_apellido ?? $ticket->user?->nombre_apellido,
|
|
||||||
'status' => $ticket->status,
|
|
||||||
'scanned_by' => $ticket->scannerUser?->nombre_apellido,
|
|
||||||
'variant_properties' => $this->variantProperties($ticket),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed>|null $details */
|
|
||||||
public function values(Ticket $ticket, ?array $details = null): array
|
|
||||||
{
|
|
||||||
$details ??= $this->details($ticket);
|
|
||||||
$presentation = $this->presentation($ticket, $details);
|
|
||||||
|
|
||||||
return [
|
|
||||||
'order_number' => $details['order_number'],
|
|
||||||
'category' => $presentation['category'],
|
|
||||||
'product' => $presentation['product'],
|
|
||||||
'type' => $presentation['type'],
|
|
||||||
'date' => $presentation['date'],
|
|
||||||
'size' => $presentation['size'],
|
|
||||||
'amount' => $details['amount'] === null ? null : (float) $details['amount'],
|
|
||||||
'client' => $details['client'] ?? 'Sin nombre',
|
|
||||||
'id' => $ticket->id,
|
|
||||||
'status' => $details['status'],
|
|
||||||
'scanned_by' => $details['scanned_by'] ?? '-',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Ticket> $tickets
|
|
||||||
* @return Collection<int, array<string, mixed>>
|
|
||||||
*/
|
|
||||||
public function rows(Collection $tickets): Collection
|
|
||||||
{
|
|
||||||
return $tickets->values()->map(fn (Ticket $ticket): array => $this->values($ticket));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, array<string, mixed>> $rows
|
|
||||||
* @param list<array<string, mixed>> $columns
|
|
||||||
* @return Collection<int, array<string, string>>
|
|
||||||
*/
|
|
||||||
public function displayRows(Collection $rows, array $columns, string $timeZone): Collection
|
|
||||||
{
|
|
||||||
return $rows->map(fn (array $row): array => collect($columns)
|
|
||||||
->mapWithKeys(fn (array $column): array => [
|
|
||||||
$column['key'] => $this->displayValue(
|
|
||||||
$row[$column['key']] ?? null,
|
|
||||||
$column['type'],
|
|
||||||
$timeZone,
|
|
||||||
),
|
|
||||||
])
|
|
||||||
->all());
|
|
||||||
}
|
|
||||||
|
|
||||||
public function displayValue(mixed $value, string $type, string $timeZone): string
|
|
||||||
{
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
return '-';
|
|
||||||
}
|
|
||||||
|
|
||||||
return match ($type) {
|
|
||||||
'order_number' => '#'.$value,
|
|
||||||
'currency' => '$'.number_format((float) $value, 2, ',', '.'),
|
|
||||||
'status' => match ((string) $value) {
|
|
||||||
Ticket::STATUS_USED => 'Usado',
|
|
||||||
Ticket::STATUS_EXPIRED => 'Vencido',
|
|
||||||
default => 'Activo',
|
|
||||||
},
|
|
||||||
default => (string) $value,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $details */
|
|
||||||
private function presentation(Ticket $ticket, array $details): array
|
|
||||||
{
|
|
||||||
$sourceCategory = trim((string) ($ticket->sourceCatalogItem?->category?->nombre ?? '')) ?: '-';
|
|
||||||
|
|
||||||
if ($ticket->tenant_code !== self::FIESTA_FUTBOL_INFANTIL) {
|
|
||||||
return [
|
|
||||||
'category' => $sourceCategory,
|
|
||||||
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
|
|
||||||
'type' => $this->allPropertyLabels($details) ?: '-',
|
|
||||||
'date' => '-',
|
|
||||||
'size' => '-',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
$configuration = self::CATEGORY_PRESENTATIONS[mb_strtolower($sourceCategory)] ?? null;
|
|
||||||
if ($configuration === null) {
|
|
||||||
return [
|
|
||||||
'category' => $sourceCategory,
|
|
||||||
'product' => (string) ($details['product'] ?: $ticket->name ?: '-'),
|
|
||||||
'type' => $this->allPropertyLabels($details) ?: '-',
|
|
||||||
'date' => '-',
|
|
||||||
'size' => '-',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [
|
|
||||||
'category' => $configuration['category'] ?? $sourceCategory,
|
|
||||||
'product' => $configuration['product'] === 'product'
|
|
||||||
? (string) ($details['product'] ?: $ticket->name ?: '-')
|
|
||||||
: ($this->propertyLabels($details, $configuration['product']) ?: '-'),
|
|
||||||
'type' => $configuration['type'] === null
|
|
||||||
? '-'
|
|
||||||
: ($this->propertyLabels($details, $configuration['type']) ?: '-'),
|
|
||||||
'date' => $configuration['date'] === null
|
|
||||||
? '-'
|
|
||||||
: ($this->propertyLabels($details, $configuration['date']) ?: '-'),
|
|
||||||
'size' => $configuration['size'] === null
|
|
||||||
? '-'
|
|
||||||
: ($this->propertyLabels($details, $configuration['size']) ?: '-'),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $details */
|
|
||||||
private function propertyLabels(array $details, string $code): string
|
|
||||||
{
|
|
||||||
$property = collect($details['variant_properties'] ?? [])->firstWhere('code', $code);
|
|
||||||
$labels = collect($property['values'] ?? [])->pluck('label')->filter();
|
|
||||||
|
|
||||||
if ($code === 'event_date') {
|
|
||||||
$labels = $labels->map(function (string $label): string {
|
|
||||||
[$day, $month] = array_pad(explode('/', $label), 2, null);
|
|
||||||
|
|
||||||
return $day !== null && $month !== null ? "{$day}/{$month}" : $label;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return $labels->implode(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param array<string, mixed> $details */
|
|
||||||
private function allPropertyLabels(array $details): string
|
|
||||||
{
|
|
||||||
return collect($details['variant_properties'] ?? [])
|
|
||||||
->flatMap(fn (array $property): array => $property['values'] ?? [])
|
|
||||||
->pluck('label')
|
|
||||||
->filter()
|
|
||||||
->implode(', ');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return list<array{code: string, label: string, values: list<array{value: string, label: string}>}> */
|
|
||||||
private function variantProperties(Ticket $ticket): array
|
|
||||||
{
|
|
||||||
$variant = $ticket->sourceVariant;
|
|
||||||
if ($variant === null) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
$itemAttributes = $variant->definitions
|
|
||||||
->map(fn ($definition) => $definition->itemAttribute)
|
|
||||||
->filter()
|
|
||||||
->merge($variant->catalogItem?->itemAttributes ?? collect())
|
|
||||||
->unique('id')
|
|
||||||
->values();
|
|
||||||
|
|
||||||
return $variant->selectionOptions($itemAttributes)
|
|
||||||
->map(function (array $selection, string $attributeCode) use ($itemAttributes): array {
|
|
||||||
$itemAttribute = $itemAttributes->first(
|
|
||||||
fn (ItemAttribute $itemAttribute): bool => $itemAttribute->attribute?->codigo
|
|
||||||
=== $attributeCode,
|
|
||||||
);
|
|
||||||
$values = array_is_list($selection) ? $selection : [$selection];
|
|
||||||
|
|
||||||
return [
|
|
||||||
'code' => $attributeCode,
|
|
||||||
'label' => $itemAttribute?->attribute?->nombre
|
|
||||||
?? ($attributeCode === 'event_date' ? 'Fecha' : $attributeCode),
|
|
||||||
'values' => array_values($values),
|
|
||||||
];
|
|
||||||
})
|
|
||||||
->values()
|
|
||||||
->all();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
namespace App\Domains\Ticket\Services;
|
|
||||||
|
|
||||||
use App\Domains\Auth\Models\User;
|
|
||||||
use App\Domains\Purchase\Models\PurchaseItem;
|
|
||||||
use App\Domains\Tenant\Models\Tenant;
|
|
||||||
use App\Domains\Ticket\Models\Ticket;
|
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
|
||||||
use Illuminate\Pagination\LengthAwarePaginator;
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
|
|
||||||
class AdminAppTicketService
|
|
||||||
{
|
|
||||||
private const RELATIONS = [
|
|
||||||
...TicketValidityResolver::RELATIONS,
|
|
||||||
...TicketPresentationResolver::RELATIONS,
|
|
||||||
'user',
|
|
||||||
'scannerUser',
|
|
||||||
'sourceCatalogItem.category',
|
|
||||||
'sourcePurchaseItem.purchase',
|
|
||||||
];
|
|
||||||
|
|
||||||
public function __construct(
|
|
||||||
private readonly AdminAppTicketColumnService $columnService,
|
|
||||||
private readonly AdminAppTicketRowService $rowService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int, sort_by?: string|null, sort_direction?: string|null} $filters
|
|
||||||
*/
|
|
||||||
public function search(Tenant $tenant, array $filters = []): AdminAppTicketResult
|
|
||||||
{
|
|
||||||
$query = $this->baseQuery($tenant, $filters);
|
|
||||||
$countQuery = clone $query;
|
|
||||||
|
|
||||||
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
|
||||||
|
|
||||||
if (($filters['sort_by'] ?? null) && ! $databaseSorted) {
|
|
||||||
$matchingTickets = (clone $query)
|
|
||||||
->with(self::RELATIONS)
|
|
||||||
->get();
|
|
||||||
$matchingTickets = $this->sortTickets($matchingTickets, $tenant, $filters);
|
|
||||||
$tickets = $this->paginate($matchingTickets, $filters);
|
|
||||||
$scannedTickets = $matchingTickets->whereNotNull('used_at')->count();
|
|
||||||
} else {
|
|
||||||
$tickets = (clone $query)
|
|
||||||
->with(self::RELATIONS)
|
|
||||||
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
|
|
||||||
->paginateFromRequest()
|
|
||||||
->withQueryString();
|
|
||||||
$scannedTickets = $countQuery->whereNotNull('used_at')->count();
|
|
||||||
}
|
|
||||||
|
|
||||||
return new AdminAppTicketResult(
|
|
||||||
tickets: $tickets,
|
|
||||||
scannedTickets: $scannedTickets,
|
|
||||||
totalTickets: $tickets->total(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, sort_by?: string|null, sort_direction?: string|null} $filters
|
|
||||||
* @return Collection<int, Ticket>
|
|
||||||
*/
|
|
||||||
public function ticketsForExport(Tenant $tenant, array $filters = []): Collection
|
|
||||||
{
|
|
||||||
$query = $this->baseQuery($tenant, $filters);
|
|
||||||
$databaseSorted = $this->applyDatabaseSort($query, $tenant, $filters);
|
|
||||||
$tickets = $query
|
|
||||||
->with(self::RELATIONS)
|
|
||||||
->when(! $databaseSorted, fn (Builder $query): Builder => $query->orderByDesc('id'))
|
|
||||||
->get();
|
|
||||||
|
|
||||||
return $databaseSorted ? $tickets : $this->sortTickets($tickets, $tenant, $filters);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param array{q?: string|null, category?: string|null, product?: string|null, type?: string|null, date?: string|null, size?: string|null, status?: string|null, page?: int, per_page?: int} $filters
|
|
||||||
* @return Builder<Ticket>
|
|
||||||
*/
|
|
||||||
private function baseQuery(Tenant $tenant, array $filters): Builder
|
|
||||||
{
|
|
||||||
$search = trim((string) ($filters['q'] ?? ''));
|
|
||||||
|
|
||||||
$query = Ticket::query()
|
|
||||||
->where('tenant_code', $tenant->codigo)
|
|
||||||
->when($search !== '', function (Builder $query) use ($search): void {
|
|
||||||
$this->applySearchFilter($query, $search);
|
|
||||||
})
|
|
||||||
->when($filters['category'] ?? null, function (Builder $query, string $category): void {
|
|
||||||
$query->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery
|
|
||||||
->whereRaw('LOWER(nombre) = ?', [mb_strtolower(trim($category))]));
|
|
||||||
})
|
|
||||||
->when($filters['product'] ?? null, function (Builder $query, string $product) use ($filters): void {
|
|
||||||
$this->applyProductFilter($query, (string) ($filters['category'] ?? ''), $product);
|
|
||||||
})
|
|
||||||
->when($filters['type'] ?? null, function (Builder $query, string $type) use ($filters): void {
|
|
||||||
$this->applyTypeFilter($query, (string) ($filters['category'] ?? ''), $type);
|
|
||||||
})
|
|
||||||
->when($filters['date'] ?? null, function (Builder $query, string $date) use ($tenant): void {
|
|
||||||
if ($tenant->codigo === 'fiesta_futbol_infantil') {
|
|
||||||
$this->applyEventDateFilter($query, $date);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
|
|
||||||
->whereDate('created_at', $date));
|
|
||||||
})
|
|
||||||
->when($filters['size'] ?? null, function (Builder $query, string $size) use ($filters): void {
|
|
||||||
$this->applySizeFilter($query, (string) ($filters['category'] ?? ''), $size);
|
|
||||||
});
|
|
||||||
|
|
||||||
$this->applyStatusFilter($query, $filters['status'] ?? null);
|
|
||||||
|
|
||||||
return $query;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
|
||||||
private function applySearchFilter(Builder $query, string $search): void
|
|
||||||
{
|
|
||||||
$containsPattern = '%'.mb_strtolower($search).'%';
|
|
||||||
$amount = $this->searchAmount($search);
|
|
||||||
|
|
||||||
$query->where(function (Builder $searchQuery) use ($search, $containsPattern, $amount): void {
|
|
||||||
$searchQuery
|
|
||||||
->where(function (Builder $clientQuery) use ($containsPattern): void {
|
|
||||||
$clientQuery
|
|
||||||
->whereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
|
|
||||||
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]))
|
|
||||||
->orWhere(function (Builder $fallbackClientQuery) use ($containsPattern): void {
|
|
||||||
$fallbackClientQuery
|
|
||||||
->where(function (Builder $missingPurchaseClientQuery): void {
|
|
||||||
$missingPurchaseClientQuery
|
|
||||||
->whereDoesntHave('sourcePurchaseItem.purchase')
|
|
||||||
->orWhereHas('sourcePurchaseItem.purchase', fn (Builder $purchaseQuery): Builder => $purchaseQuery
|
|
||||||
->whereNull('nombre_apellido'));
|
|
||||||
})
|
|
||||||
->whereHas('user', fn (Builder $userQuery): Builder => $userQuery
|
|
||||||
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]));
|
|
||||||
});
|
|
||||||
})
|
|
||||||
->orWhereHas('scannerUser', fn (Builder $scannerQuery): Builder => $scannerQuery
|
|
||||||
->whereRaw('LOWER(nombre_apellido) LIKE ?', [$containsPattern]));
|
|
||||||
|
|
||||||
if (ctype_digit($search)) {
|
|
||||||
$searchQuery
|
|
||||||
->orWhere('tickets.id', (int) $search)
|
|
||||||
->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery
|
|
||||||
->where('compra_id', (int) $search));
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($amount !== null) {
|
|
||||||
$searchQuery->orWhereHas('sourcePurchaseItem', fn (Builder $purchaseItemQuery): Builder => $purchaseItemQuery
|
|
||||||
->where('precio_unitario', $amount));
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private function searchAmount(string $search): ?string
|
|
||||||
{
|
|
||||||
$value = preg_replace('/[\s$]/u', '', trim($search));
|
|
||||||
if ($value === null || $value === '') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preg_match('/^\d{1,3}(?:\.\d{3})+(?:,\d{1,2})?$/', $value) === 1) {
|
|
||||||
$value = str_replace(['.', ','], ['', '.'], $value);
|
|
||||||
} elseif (preg_match('/^\d{1,3}(?:,\d{3})+(?:\.\d{1,2})?$/', $value) === 1) {
|
|
||||||
$value = str_replace(',', '', $value);
|
|
||||||
} elseif (preg_match('/^\d+(?:[.,]\d{1,2})?$/', $value) === 1) {
|
|
||||||
$value = str_replace(',', '.', $value);
|
|
||||||
} else {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return number_format((float) $value, 2, '.', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
|
||||||
private function applyProductFilter(Builder $query, string $category, string $product): void
|
|
||||||
{
|
|
||||||
$category = $this->normalizedCategory($category);
|
|
||||||
|
|
||||||
if (in_array($category, ['alojamientos', 'camping'], true)) {
|
|
||||||
$this->whereVariantDefinition($query, 'tipo_alojamiento', $product);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (in_array($category, ['comidas', 'comida'], true)) {
|
|
||||||
$this->whereVariantDefinition($query, 'horario', $product);
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->whereHas('sourceCatalogItem', fn (Builder $itemQuery): Builder => $itemQuery
|
|
||||||
->where('slug', $product));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
|
||||||
private function applyTypeFilter(Builder $query, string $category, string $type): void
|
|
||||||
{
|
|
||||||
$attribute = match ($this->normalizedCategory($category)) {
|
|
||||||
'comidas', 'comida' => 'servicio',
|
|
||||||
'merchandising' => 'color',
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($attribute !== null) {
|
|
||||||
$this->whereVariantDefinition($query, $attribute, $type);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
|
||||||
private function applyEventDateFilter(Builder $query, string $date): void
|
|
||||||
{
|
|
||||||
$query
|
|
||||||
->whereHas('sourceCatalogItem.category', fn (Builder $categoryQuery): Builder => $categoryQuery
|
|
||||||
->whereRaw('LOWER(nombre) IN (?, ?)', ['comidas', 'comida']))
|
|
||||||
->whereHas('sourceVariant', function (Builder $variantQuery) use ($date): void {
|
|
||||||
$variantQuery->where(function (Builder $dateQuery) use ($date): void {
|
|
||||||
$dateQuery
|
|
||||||
->whereHas('eventDate', fn (Builder $eventDateQuery): Builder => $eventDateQuery
|
|
||||||
->whereDate('date', $date))
|
|
||||||
->orWhereHas('eventDates', fn (Builder $eventDateQuery): Builder => $eventDateQuery
|
|
||||||
->whereDate('date', $date));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
|
||||||
private function applySizeFilter(Builder $query, string $category, string $size): void
|
|
||||||
{
|
|
||||||
if ($this->normalizedCategory($category) === 'merchandising') {
|
|
||||||
$this->whereVariantDefinition($query, 'talle', $size);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
|
||||||
private function whereVariantDefinition(Builder $query, string $attribute, string $value): void
|
|
||||||
{
|
|
||||||
$query->whereHas('sourceVariant.definitions', fn (Builder $definitionQuery): Builder => $definitionQuery
|
|
||||||
->where('value', $value)
|
|
||||||
->whereHas('itemAttribute.attribute', fn (Builder $attributeQuery): Builder => $attributeQuery
|
|
||||||
->where('codigo', $attribute)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @param Builder<Ticket> $query */
|
|
||||||
private function applyStatusFilter(Builder $query, ?string $status): void
|
|
||||||
{
|
|
||||||
if ($status === null || $status === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($status === Ticket::STATUS_USED) {
|
|
||||||
$query->whereNotNull('used_at');
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
$matchingIds = (clone $query)
|
|
||||||
->whereNull('used_at')
|
|
||||||
->with(TicketValidityResolver::RELATIONS)
|
|
||||||
->get()
|
|
||||||
->filter(fn (Ticket $ticket): bool => $ticket->status === $status)
|
|
||||||
->pluck('id');
|
|
||||||
|
|
||||||
$query->whereIn('tickets.id', $matchingIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private function normalizedCategory(string $category): string
|
|
||||||
{
|
|
||||||
return mb_strtolower(trim($category));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Builder<Ticket> $query
|
|
||||||
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
|
|
||||||
*/
|
|
||||||
private function applyDatabaseSort(Builder $query, Tenant $tenant, array $filters): bool
|
|
||||||
{
|
|
||||||
$sortBy = (string) ($filters['sort_by'] ?? '');
|
|
||||||
if ($sortBy === '') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? 'desc' : 'asc';
|
|
||||||
|
|
||||||
$sortExpression = match ($sortBy) {
|
|
||||||
'order_number' => $this->purchaseItemColumnQuery('compra_id'),
|
|
||||||
'id' => 'tickets.id',
|
|
||||||
'amount' => $this->purchaseItemColumnQuery('precio_unitario'),
|
|
||||||
'scanned_by' => User::query()
|
|
||||||
->select('nombre_apellido')
|
|
||||||
->whereColumn('users.id', 'tickets.scanner_user_id'),
|
|
||||||
'product' => $tenant->codigo === 'fiesta_futbol_infantil'
|
|
||||||
? null
|
|
||||||
: $this->purchaseItemColumnQuery('item_nombre'),
|
|
||||||
default => null,
|
|
||||||
};
|
|
||||||
|
|
||||||
if ($sortExpression === null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
$query->orderBy($sortExpression, $direction)->orderByDesc('tickets.id');
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @return Builder<PurchaseItem> */
|
|
||||||
private function purchaseItemColumnQuery(string $column): Builder
|
|
||||||
{
|
|
||||||
return PurchaseItem::query()
|
|
||||||
->select($column)
|
|
||||||
->whereColumn('compra_items.id', 'tickets.source_purchase_item_id')
|
|
||||||
->limit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Ticket> $tickets
|
|
||||||
* @param array{sort_by?: string|null, sort_direction?: string|null} $filters
|
|
||||||
* @return Collection<int, Ticket>
|
|
||||||
*/
|
|
||||||
private function sortTickets(Collection $tickets, Tenant $tenant, array $filters): Collection
|
|
||||||
{
|
|
||||||
$sortBy = (string) ($filters['sort_by'] ?? '');
|
|
||||||
if ($sortBy === '') {
|
|
||||||
return $tickets;
|
|
||||||
}
|
|
||||||
|
|
||||||
$column = collect($this->columnService->columns($tenant))
|
|
||||||
->firstWhere('sort_param', $sortBy);
|
|
||||||
if ($column === null) {
|
|
||||||
return $tickets;
|
|
||||||
}
|
|
||||||
|
|
||||||
$direction = ($filters['sort_direction'] ?? 'asc') === 'desc' ? -1 : 1;
|
|
||||||
$values = $tickets->mapWithKeys(fn (Ticket $ticket): array => [
|
|
||||||
$ticket->getKey() => $this->rowService->values($ticket)[$column['key']] ?? null,
|
|
||||||
]);
|
|
||||||
|
|
||||||
return $tickets->sort(function (Ticket $left, Ticket $right) use ($column, $direction, $values): int {
|
|
||||||
$leftValue = $values->get($left->getKey());
|
|
||||||
$rightValue = $values->get($right->getKey());
|
|
||||||
|
|
||||||
if ($leftValue === null || $leftValue === '') {
|
|
||||||
return $rightValue === null || $rightValue === '' ? $right->id <=> $left->id : 1;
|
|
||||||
}
|
|
||||||
if ($rightValue === null || $rightValue === '') {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
|
|
||||||
$comparison = $this->compareValues($leftValue, $rightValue, $column['type']);
|
|
||||||
|
|
||||||
return $comparison === 0
|
|
||||||
? $right->id <=> $left->id
|
|
||||||
: $comparison * $direction;
|
|
||||||
})->values();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function compareValues(mixed $left, mixed $right, string $type): int
|
|
||||||
{
|
|
||||||
if (in_array($type, ['currency', 'order_number'], true)) {
|
|
||||||
return (float) $left <=> (float) $right;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($type === 'date') {
|
|
||||||
$leftTimestamp = $left instanceof \DateTimeInterface ? $left->getTimestamp() : strtotime((string) $left);
|
|
||||||
$rightTimestamp = $right instanceof \DateTimeInterface ? $right->getTimestamp() : strtotime((string) $right);
|
|
||||||
|
|
||||||
return $leftTimestamp <=> $rightTimestamp;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($type === 'status') {
|
|
||||||
$left = $this->rowService->displayValue($left, $type, 'UTC');
|
|
||||||
$right = $this->rowService->displayValue($right, $type, 'UTC');
|
|
||||||
}
|
|
||||||
|
|
||||||
return strnatcasecmp((string) $left, (string) $right);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param Collection<int, Ticket> $tickets
|
|
||||||
* @param array{page?: int, per_page?: int} $filters
|
|
||||||
* @return LengthAwarePaginator<Ticket>
|
|
||||||
*/
|
|
||||||
private function paginate(Collection $tickets, array $filters): LengthAwarePaginator
|
|
||||||
{
|
|
||||||
$page = (int) ($filters['page'] ?? 1);
|
|
||||||
$perPage = (int) ($filters['per_page'] ?? 15);
|
|
||||||
|
|
||||||
return (new LengthAwarePaginator(
|
|
||||||
$tickets->forPage($page, $perPage)->values(),
|
|
||||||
$tickets->count(),
|
|
||||||
$perPage,
|
|
||||||
$page,
|
|
||||||
['path' => request()->url()],
|
|
||||||
))->withQueryString();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -23,13 +23,13 @@ class TicketGeneratorService
|
|||||||
User $user,
|
User $user,
|
||||||
int $quantity = 1,
|
int $quantity = 1,
|
||||||
?int $sourceVariantId = null,
|
?int $sourceVariantId = null,
|
||||||
?int $sourcePurchaseItemId = null,
|
?int $sourcePurchaseId = null,
|
||||||
): Collection {
|
): Collection {
|
||||||
if ($quantity < 1) {
|
if ($quantity < 1) {
|
||||||
throw TicketGenerationException::invalidQuantity();
|
throw TicketGenerationException::invalidQuantity();
|
||||||
}
|
}
|
||||||
|
|
||||||
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseItemId): Collection {
|
return DB::transaction(function () use ($catalogItem, $user, $quantity, $sourceVariantId, $sourcePurchaseId): Collection {
|
||||||
$targets = $this->resolveTargets(
|
$targets = $this->resolveTargets(
|
||||||
$catalogItem,
|
$catalogItem,
|
||||||
$quantity,
|
$quantity,
|
||||||
@@ -37,7 +37,7 @@ class TicketGeneratorService
|
|||||||
);
|
);
|
||||||
|
|
||||||
return $targets->map(function (array $target) use (
|
return $targets->map(function (array $target) use (
|
||||||
$sourcePurchaseItemId,
|
$sourcePurchaseId,
|
||||||
$user,
|
$user,
|
||||||
): Ticket {
|
): Ticket {
|
||||||
$item = $target['catalog_item'];
|
$item = $target['catalog_item'];
|
||||||
@@ -45,7 +45,7 @@ class TicketGeneratorService
|
|||||||
$ticket = Ticket::query()->create([
|
$ticket = Ticket::query()->create([
|
||||||
'tenant_code' => $item->tenant_code,
|
'tenant_code' => $item->tenant_code,
|
||||||
'ticket' => (string) Str::uuid(),
|
'ticket' => (string) Str::uuid(),
|
||||||
'source_purchase_item_id' => $sourcePurchaseItemId,
|
'source_purchase_id' => $sourcePurchaseId,
|
||||||
'source_catalog_item_id' => $item->getKey(),
|
'source_catalog_item_id' => $item->getKey(),
|
||||||
'source_variant_id' => $variant?->getKey(),
|
'source_variant_id' => $variant?->getKey(),
|
||||||
'used_at' => null,
|
'used_at' => null,
|
||||||
|
|||||||
@@ -6,8 +6,7 @@ Genera, valida, consulta y exporta entradas asociadas a compras pagadas de produ
|
|||||||
|
|
||||||
## Modelo
|
## Modelo
|
||||||
|
|
||||||
- `Ticket`: pertenece a tenant y usuario, y conserva referencias al ítem de compra que lo generó, producto,
|
- `Ticket`: pertenece a tenant y usuario, y conserva referencias a compra, producto, variante y usuario escáner.
|
||||||
variante y usuario escáner. La compra se obtiene a través de su ítem.
|
|
||||||
- El nombre y la descripción se calculan dinámicamente desde el producto y la variante; los tickets no
|
- El nombre y la descripción se calculan dinámicamente desde el producto y la variante; los tickets no
|
||||||
persisten una copia de esos textos.
|
persisten una copia de esos textos.
|
||||||
- `ValidityTime`: define ventanas absolutas o relativas de vigencia para fechas de evento y opciones de atributos.
|
- `ValidityTime`: define ventanas absolutas o relativas de vigencia para fechas de evento y opciones de atributos.
|
||||||
@@ -31,12 +30,6 @@ Bajo `/tenants/{tenant:codigo}`, protegidos por `auth:sanctum`:
|
|||||||
- `GET /tickets`.
|
- `GET /tickets`.
|
||||||
- `POST /tickets/pdf`.
|
- `POST /tickets/pdf`.
|
||||||
|
|
||||||
Bajo `/v1/adminapp/tenant`, protegido por `auth:sanctum`, `adminapp.tenant` y el menú
|
|
||||||
`adminapp.tickets`:
|
|
||||||
|
|
||||||
- `GET /tickets`, paginado y con búsqueda opcional mediante `q`. La respuesta incluye
|
|
||||||
`scanned_tickets` y `total_tickets` para el tenant autenticado.
|
|
||||||
|
|
||||||
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
|
`TicketPdfService` genera la descarga y `TicketResource`/`ValidityTimeResource` definen las respuestas.
|
||||||
|
|
||||||
## Dependencias y reglas
|
## Dependencias y reglas
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Domains\Ticket\Controllers\AdminApp\TicketController;
|
|
||||||
use Illuminate\Support\Facades\Route;
|
|
||||||
|
|
||||||
Route::prefix('v1/adminapp/tenant')
|
|
||||||
->middleware(['auth:sanctum', 'adminapp.tenant'])
|
|
||||||
->group(function (): void {
|
|
||||||
Route::get('tickets', [TicketController::class, 'index'])
|
|
||||||
->middleware('tenant.menu:adminapp.tickets')
|
|
||||||
->name('adminapp.tickets.index');
|
|
||||||
Route::get('tickets/pdf', [TicketController::class, 'downloadPdf'])
|
|
||||||
->middleware('tenant.menu:adminapp.tickets')
|
|
||||||
->name('adminapp.tickets.pdf');
|
|
||||||
Route::get('tickets/excel', [TicketController::class, 'downloadExcel'])
|
|
||||||
->middleware('tenant.menu:adminapp.tickets')
|
|
||||||
->name('adminapp.tickets.excel');
|
|
||||||
});
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user