feat(sales): scope sales actions totals history and exports by event

This commit is contained in:
2026-09-30 15:31:55 -03:00
parent 55c28f33ef
commit 655364bfec
6 changed files with 58 additions and 36 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ use App\Domains\Commerce\Sale\Controllers\AdminApp\SaleController;
use Illuminate\Support\Facades\Route; use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant') Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant']) ->middleware(['auth:sanctum', 'adminapp.tenant:event'])
->group(function (): void { ->group(function (): void {
Route::get('sales', [SaleController::class, 'index']); Route::get('sales', [SaleController::class, 'index']);
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']); Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);

View File

@@ -20,7 +20,7 @@ Route::prefix('v1/adminapp/forms')
DesfileEntryReservationFormController::class DesfileEntryReservationFormController::class
)->middleware('adminapp.tenant')->middleware('tenant.menu:adminapp.desfile.reservas') )->middleware('adminapp.tenant')->middleware('tenant.menu:adminapp.desfile.reservas')
->name('adminapp.forms.desfile.entry-reservation'); ->name('adminapp.forms.desfile.entry-reservation');
Route::get('sale', SaleFormController::class)->middleware('adminapp.tenant'); Route::get('sale', SaleFormController::class);
Route::get('staff', StaffFormController::class)->middleware('adminapp.tenant'); Route::get('staff', StaffFormController::class)->middleware('adminapp.tenant');
Route::get('tickets-filter', TicketFilterFormController::class)->middleware('adminapp.tenant') Route::get('tickets-filter', TicketFilterFormController::class)->middleware('adminapp.tenant')
->middleware('tenant.menu:adminapp.tickets') ->middleware('tenant.menu:adminapp.tickets')