3 Commits

29 changed files with 901 additions and 0 deletions

View File

@@ -3,6 +3,7 @@
namespace App\Domains\Event\Models;
use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -38,4 +39,10 @@ class Event extends Model
{
return $this->hasMany(CatalogItem::class);
}
/** @return HasMany<Purchase, $this> */
public function purchases(): HasMany
{
return $this->hasMany(Purchase::class);
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Forms\Controllers\AdminApp;
use App\Domains\Forms\Resources\SaleFormResource;
use App\Domains\Forms\Services\SaleFormService;
use App\Http\Controllers\Controller;
class SaleFormController extends Controller
{
public function __construct(protected SaleFormService $saleFormService) {}
public function __invoke(): SaleFormResource
{
return SaleFormResource::make($this->saleFormService->get());
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace App\Domains\Forms\Resources;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class SaleFormResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
return [
'statuses' => $this->resource['statuses'],
];
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Domains\Forms\Services;
use App\Domains\Purchase\Models\Purchase;
class SaleFormService
{
/** @return array{statuses: list<array{code: string, name: string}>} */
public function get(): array
{
$names = [
Purchase::STATUS_CREATED => 'Creada',
Purchase::STATUS_PENDING_PAYMENT => 'Esperando pago',
Purchase::STATUS_PAID => 'Confirmada',
Purchase::STATUS_CANCELLED => 'Cancelada',
Purchase::STATUS_REJECTED => 'Rechazada',
Purchase::STATUS_EXPIRED => 'Vencida',
];
return [
'statuses' => array_map(
fn (string $status): array => [
'code' => $status,
'name' => $names[$status],
],
Purchase::statuses(),
),
];
}
}

View File

@@ -1,10 +1,12 @@
<?php
use App\Domains\Forms\Controllers\AdminApp\EventFormController;
use App\Domains\Forms\Controllers\AdminApp\SaleFormController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/forms')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('event', EventFormController::class);
Route::get('sale', SaleFormController::class);
});

View File

@@ -11,6 +11,8 @@ use LogicException;
trait LogsValueChanges
{
abstract protected function valueChangeTenantCode(): string;
public static function bootLogsValueChanges(): void
{
static::updated(function (Model $model): void {
@@ -30,6 +32,7 @@ trait LogsValueChanges
foreach ($changedAttributes as $attribute) {
$model->valueChanges()->create([
'tenant_code' => $model->valueChangeTenantCode(),
'attribute' => $attribute,
'old_value' => $model->getRawOriginal($attribute),
'new_value' => $model->getAttributes()[$attribute] ?? null,

View File

@@ -4,12 +4,14 @@ namespace App\Domains\Logging\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
#[Fillable([
'tenant_code',
'trackable_type',
'trackable_id',
'attribute',
@@ -35,6 +37,12 @@ class ValueChange extends Model
return $this->belongsTo(User::class);
}
/** @return BelongsTo<Tenant, $this> */
public function tenant(): BelongsTo
{
return $this->belongsTo(Tenant::class, 'tenant_code', 'codigo');
}
protected function casts(): array
{
return [

View File

@@ -4,6 +4,7 @@ namespace App\Domains\Purchase\Models;
use App\Domains\Auth\Models\User;
use App\Domains\Cart\Models\Cart;
use App\Domains\Event\Models\Event;
use App\Domains\Logging\Models\Concerns\LogsValueChanges;
use App\Domains\Purchase\Events\PurchasePaid;
use App\Domains\Tenant\Models\Tenant;
@@ -19,6 +20,7 @@ use Illuminate\Support\Facades\DB;
#[Fillable([
'cart_id',
'tenant_codigo',
'event_id',
'user_id',
'status',
'payment_method',
@@ -46,6 +48,19 @@ class Purchase extends Model
public const STATUS_EXPIRED = 'expired';
/** @return list<string> */
public static function statuses(): array
{
return [
self::STATUS_CREATED,
self::STATUS_PENDING_PAYMENT,
self::STATUS_PAID,
self::STATUS_CANCELLED,
self::STATUS_REJECTED,
self::STATUS_EXPIRED,
];
}
protected $table = 'compras';
/** @var array<int, string> */
@@ -57,6 +72,7 @@ class Purchase extends Model
{
return [
'cart_id' => 'integer',
'event_id' => 'integer',
'user_id' => 'integer',
'expires_at' => 'datetime',
'total' => 'decimal:2',
@@ -71,6 +87,12 @@ class Purchase extends Model
return $this->belongsTo(Tenant::class, 'tenant_codigo', 'codigo');
}
/** @return BelongsTo<Event, $this> */
public function event(): BelongsTo
{
return $this->belongsTo(Event::class);
}
/**
* @return BelongsTo<User, $this>
*/
@@ -137,6 +159,11 @@ class Purchase extends Model
return (float) $this->items()->sum('total');
}
protected function valueChangeTenantCode(): string
{
return $this->tenant_codigo;
}
public function markAsPendingPayment(): void
{
$this->update([

View File

@@ -42,6 +42,7 @@ class PurchaseResource extends JsonResource
'id' => $this->id,
'cart_id' => $this->cart_id,
'tenant_codigo' => $this->tenant_codigo,
'event_id' => $this->event_id,
'user_id' => $this->user_id,
'created_at' => $this->created_at,
'status' => $this->status,

View File

@@ -26,6 +26,11 @@ class CheckoutService
public function startCheckout(Tenant $tenant, int $userId, array $purchaseData): Purchase
{
return DB::transaction(function () use ($tenant, $userId, $purchaseData): Purchase {
/** @var Tenant $tenant */
$tenant = Tenant::query()
->lockForUpdate()
->findOrFail($tenant->getKey());
$directItem = $purchaseData['direct_item'] ?? null;
$cartId = isset($purchaseData['cart_id']) ? (int) $purchaseData['cart_id'] : null;
unset($purchaseData['direct_item'], $purchaseData['cart_id']);
@@ -615,6 +620,7 @@ class CheckoutService
...$purchaseData,
'cart_id' => $cartId,
'tenant_codigo' => $tenant->codigo,
'event_id' => $tenant->active_event_id,
'user_id' => $userId,
'status' => Purchase::STATUS_CREATED,
'payment_method' => null,

View File

@@ -0,0 +1,61 @@
<?php
namespace App\Domains\Sale\Controllers\AdminApp;
use App\Domains\Sale\Requests\AdminAppSaleIndexRequest;
use App\Domains\Sale\Resources\AdminApp\SaleModificationResource;
use App\Domains\Sale\Resources\AdminApp\SaleResource;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Sale\Services\AdminAppSaleService;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Http\Response;
class SaleController extends Controller
{
public function __construct(
protected AdminAppSaleService $saleService,
protected AdminAppSalePdfService $salePdfService,
) {}
public function index(AdminAppSaleIndexRequest $request): AnonymousResourceCollection
{
$tenant = $request->user()->tenant()->firstOrFail();
return SaleResource::collection(
$this->saleService->sales($tenant, $request->validated())
)->additional([
'confirmed_sales_total' => $this->saleService->confirmedSalesTotal($tenant),
]);
}
public function modifications(Request $request): AnonymousResourceCollection
{
return SaleModificationResource::collection(
$this->saleService->modifications(
$request->user()->tenant()->firstOrFail()
)
);
}
public function downloadPdf(AdminAppSaleIndexRequest $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadSales(
$tenant,
$this->saleService->salesForExport($tenant, $request->validated()),
);
}
public function downloadModificationsPdf(Request $request): Response
{
$tenant = $request->user()->tenant()->firstOrFail();
return $this->salePdfService->downloadModifications(
$tenant,
$this->saleService->modificationsForExport($tenant),
);
}
}

View File

@@ -0,0 +1,30 @@
<?php
namespace App\Domains\Sale\Requests;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class AdminAppSaleIndexRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
/** @return array<string, list<string>> */
public function rules(): array
{
return [
'q' => ['sometimes', 'nullable', 'string', 'max:255'],
'id' => ['sometimes', 'nullable', 'integer', 'min:1'],
'sale_date' => ['sometimes', 'nullable', 'date_format:Y-m-d'],
'status' => ['sometimes', 'nullable', 'string', Rule::in(Purchase::statuses())],
'sort_by' => ['sometimes', 'string', 'in:id,date,customer_name,quantity,status,total'],
'sort_direction' => ['sometimes', 'string', 'in:asc,desc'],
'page' => ['sometimes', 'integer', 'min:1'],
'per_page' => ['sometimes', 'integer', 'min:1', 'max:100'],
];
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin ValueChange */
class SaleModificationResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
/** @var Purchase|null $sale */
$sale = $this->whenLoaded('trackable');
$user = $this->whenLoaded('user');
return [
'id' => $this->id,
'sale_id' => $this->trackable_id,
'attribute' => $this->attribute,
'old_value' => $this->old_value,
'new_value' => $this->new_value,
'date' => $this->changed_at->format('Y-m-d'),
'time' => $this->changed_at->format('H:i:s'),
'actor_type' => $this->actor_type->value,
'sale' => $sale instanceof Purchase ? [
'id' => $sale->id,
'customer_name' => $sale->nombre_apellido,
'status' => $sale->status,
] : null,
'modified_by' => $user ? [
'id' => $user->id,
'name' => $user->nombre_apellido,
'email' => $user->email,
] : null,
];
}
}

View File

@@ -0,0 +1,28 @@
<?php
namespace App\Domains\Sale\Resources\AdminApp;
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
/** @mixin Purchase */
class SaleResource extends JsonResource
{
/** @return array<string, mixed> */
public function toArray(Request $request): array
{
$ticketsCount = (int) ($this->tickets_count ?? 0);
return [
'id' => $this->id,
'created_at' => $this->created_at,
'customer_name' => $this->nombre_apellido,
'quantity' => (int) ($this->quantity ?? 0),
'status' => $this->status,
'total' => number_format((float) $this->total, 2, '.', ''),
'tickets_count' => $ticketsCount,
'has_generated_tickets' => $ticketsCount > 0,
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Barryvdh\DomPDF\Facade\Pdf;
use Barryvdh\DomPDF\PDF as DomPdf;
use Illuminate\Http\Response;
use Illuminate\Support\Collection;
class AdminAppSalePdfService
{
/** @param Collection<int, Purchase> $sales */
public function downloadSales(Tenant $tenant, Collection $sales): Response
{
$pdf = Pdf::loadView('pdf.adminapp.sales', [
'tenant' => $tenant,
'sales' => $sales,
'generatedAt' => now(),
'confirmedSalesTotal' => number_format(
(float) $sales->where('status', Purchase::STATUS_PAID)->sum('total'),
2,
'.',
'',
),
])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'ventas_'.$tenant->codigo.'_'.now()->format('Ymd_His').'.pdf'
);
}
/** @param Collection<int, ValueChange> $modifications */
public function downloadModifications(Tenant $tenant, Collection $modifications): Response
{
$pdf = Pdf::loadView('pdf.adminapp.sale-modifications', [
'tenant' => $tenant,
'modifications' => $modifications,
'generatedAt' => now(),
])->setPaper('a4', 'landscape');
$this->addPageNumbers($pdf);
return $pdf->download(
'historial_modificaciones_'.$tenant->codigo.'_'.now()->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(
385,
575,
'Página {PAGE_NUM} de {PAGE_COUNT}',
$font,
7,
[0.48, 0.52, 0.49],
);
}
}

View File

@@ -0,0 +1,120 @@
<?php
namespace App\Domains\Sale\Services;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class AdminAppSaleService
{
public function confirmedSalesTotal(Tenant $tenant): string
{
$total = Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->where('status', Purchase::STATUS_PAID)
->sum('total');
return number_format((float) $total, 2, '.', '');
}
/**
* @param array{
* q?: string|null,
* id?: int|null,
* sale_date?: string|null,
* status?: string|null,
* sort_by?: string,
* sort_direction?: string
* } $filters
* @return LengthAwarePaginator<Purchase>
*/
public function sales(Tenant $tenant, array $filters = []): LengthAwarePaginator
{
return $this->salesQuery($tenant, $filters)
->paginateFromRequest()
->withQueryString();
}
/**
* @param array<string, mixed> $filters
* @return Collection<int, Purchase>
*/
public function salesForExport(Tenant $tenant, array $filters = []): Collection
{
return $this->salesQuery($tenant, $filters)->get();
}
/** @return LengthAwarePaginator<ValueChange> */
public function modifications(Tenant $tenant): LengthAwarePaginator
{
return $this->modificationsQuery($tenant)
->paginateFromRequest()
->withQueryString();
}
/** @return Collection<int, ValueChange> */
public function modificationsForExport(Tenant $tenant): Collection
{
return $this->modificationsQuery($tenant)->get();
}
/** @param array<string, mixed> $filters */
protected function salesQuery(Tenant $tenant, array $filters): Builder
{
$sortColumns = [
'id' => 'id',
'date' => 'created_at',
'customer_name' => 'nombre_apellido',
'quantity' => 'quantity',
'status' => 'status',
'total' => 'total',
];
$requestedSort = $filters['sort_by'] ?? 'date';
$sortBy = array_key_exists($requestedSort, $sortColumns) ? $requestedSort : 'date';
$requestedDirection = $filters['sort_direction'] ?? 'desc';
$sortDirection = in_array($requestedDirection, ['asc', 'desc'], true)
? $requestedDirection
: 'desc';
return Purchase::query()
->where('tenant_codigo', $tenant->codigo)
->when($filters['q'] ?? null, function (Builder $query, string $search): void {
$term = trim($search);
$query->where(function (Builder $query) use ($term): void {
$query
->where('id', 'like', "%{$term}%")
->orWhere('nombre_apellido', 'like', "%{$term}%")
->orWhere('created_at', 'like', "%{$term}%");
});
})
->when($filters['id'] ?? null, fn (Builder $query, int $id): Builder => $query->whereKey($id))
->when(
$filters['sale_date'] ?? null,
fn (Builder $query, string $date): Builder => $query->whereDate('created_at', $date)
)
->when(
$filters['status'] ?? null,
fn (Builder $query, string $status): Builder => $query->where('status', $status)
)
->withSum('items as quantity', 'cantidad')
->withCount('tickets')
->orderBy($sortColumns[$sortBy], $sortDirection)
->when($sortBy !== 'id', fn (Builder $query): Builder => $query->orderByDesc('id'));
}
/** @return Builder<ValueChange> */
protected function modificationsQuery(Tenant $tenant): Builder
{
return ValueChange::query()
->where('tenant_code', $tenant->codigo)
->where('trackable_type', (new Purchase)->getMorphClass())
->with(['trackable', 'user'])
->orderByDesc('changed_at')
->orderByDesc('id');
}
}

View File

@@ -0,0 +1,13 @@
<?php
use App\Domains\Sale\Controllers\AdminApp\SaleController;
use Illuminate\Support\Facades\Route;
Route::prefix('v1/adminapp/tenant')
->middleware(['auth:sanctum', 'adminapp.tenant'])
->group(function (): void {
Route::get('sales', [SaleController::class, 'index']);
Route::get('sales/pdf', [SaleController::class, 'downloadPdf']);
Route::get('sales/modifications', [SaleController::class, 'modifications']);
Route::get('sales/modifications/pdf', [SaleController::class, 'downloadModificationsPdf']);
});

View File

@@ -0,0 +1,3 @@
<?php
require __DIR__.'/adminapp.php';

View File

@@ -0,0 +1,50 @@
<?php
use App\Domains\Purchase\Models\Purchase;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('value_changes', function (Blueprint $table): void {
$table->string('tenant_code')->nullable()->after('id');
$table->foreign('tenant_code')
->references('codigo')
->on('tenants')
->cascadeOnUpdate()
->nullOnDelete();
$table->index(['tenant_code', 'changed_at']);
});
DB::table('value_changes')
->where('trackable_type', (new Purchase)->getMorphClass())
->whereNull('tenant_code')
->orderBy('id')
->chunkById(500, function ($changes): void {
foreach ($changes as $change) {
$tenantCode = DB::table('compras')
->where('id', $change->trackable_id)
->value('tenant_codigo');
if ($tenantCode !== null) {
DB::table('value_changes')
->where('id', $change->id)
->update(['tenant_code' => $tenantCode]);
}
}
});
}
public function down(): void
{
Schema::table('value_changes', function (Blueprint $table): void {
$table->dropForeign(['tenant_code']);
$table->dropIndex(['tenant_code', 'changed_at']);
$table->dropColumn('tenant_code');
});
}
};

View File

@@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->foreignId('event_id')
->nullable()
->after('tenant_codigo')
->constrained('events')
->nullOnDelete();
});
}
public function down(): void
{
Schema::table('compras', function (Blueprint $table): void {
$table->dropConstrainedForeignId('event_id');
});
}
};

View File

@@ -0,0 +1,63 @@
<!doctype html>
<html lang="es">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
@page { margin: 28px 30px 58px; }
body { color: #17211b; font-family: DejaVu Sans, sans-serif; font-size: 8px; margin: 0; }
h1 { font-size: 21px; margin: 0 0 3px; }
.subtitle { color: #66736b; margin: 0 0 18px; }
table { border-collapse: collapse; table-layout: fixed; width: 100%; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
th { background: #26382e; color: #fff; font-size: 7px; letter-spacing: .35px; padding: 7px 5px; text-align: left; text-transform: uppercase; }
td { border-bottom: 1px solid #dfe7e2; overflow-wrap: break-word; padding: 7px 5px; vertical-align: top; }
tbody tr:nth-child(even) { background: #f7f9f8; }
.date { width: 10%; }
.time { width: 7%; }
.sale { width: 8%; }
.customer { width: 17%; }
.attribute { width: 10%; }
.value { width: 16%; }
.actor { width: 16%; }
.empty { color: #66736b; padding: 24px; text-align: center; }
</style>
</head>
<body>
<h1>Historial de modificaciones de ventas</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
<table>
<thead>
<tr>
<th class="date">Fecha</th>
<th class="time">Hora</th>
<th class="sale">Venta</th>
<th class="customer">Cliente</th>
<th class="attribute">Campo</th>
<th class="value">Valor anterior</th>
<th class="value">Valor nuevo</th>
<th class="actor">Modificado por</th>
</tr>
</thead>
<tbody>
@forelse ($modifications as $modification)
@php($sale = $modification->trackable)
<tr>
<td>{{ $modification->changed_at->format('d/m/Y') }}</td>
<td>{{ $modification->changed_at->format('H:i:s') }}</td>
<td>#{{ $modification->trackable_id }}</td>
<td>{{ $sale?->nombre_apellido ?: 'Sin nombre' }}</td>
<td>{{ $modification->attribute }}</td>
<td>{{ $modification->old_value ?? '-' }}</td>
<td>{{ $modification->new_value ?? '-' }}</td>
<td>{{ $modification->user?->nombre_apellido ?? 'Sistema' }}</td>
</tr>
@empty
<tr><td class="empty" colspan="8">Todavía no hay modificaciones registradas.</td></tr>
@endforelse
</tbody>
</table>
</body>
</html>

View File

@@ -0,0 +1,65 @@
<!doctype html>
<html lang="es">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8">
<style>
@page { margin: 28px 34px 58px; }
body { color: #17211b; font-family: DejaVu Sans, sans-serif; font-size: 9px; margin: 0; }
h1 { font-size: 21px; margin: 0 0 3px; }
.subtitle { color: #66736b; margin: 0 0 18px; }
.summary { background: #eef5f1; border-left: 4px solid #198754; margin-bottom: 16px; padding: 9px 12px; }
.summary strong { font-size: 14px; }
table { border-collapse: collapse; width: 100%; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
th { background: #26382e; color: #fff; font-size: 8px; letter-spacing: .4px; padding: 7px 6px; text-align: left; text-transform: uppercase; }
td { border-bottom: 1px solid #dfe7e2; padding: 7px 6px; vertical-align: top; }
tbody tr:nth-child(even) { background: #f7f9f8; }
.number { text-align: right; }
.center { text-align: center; }
.empty { color: #66736b; padding: 24px; text-align: center; }
</style>
</head>
<body>
<h1>Historial de ventas</h1>
<p class="subtitle">{{ $tenant->nombre }} · Generado el {{ $generatedAt->format('d/m/Y H:i') }}</p>
<div class="summary">
Total de ventas confirmadas en este reporte: <strong>${{ number_format((float) $confirmedSalesTotal, 2, ',', '.') }}</strong>
</div>
<table>
<thead>
<tr>
<th>ID</th>
<th>Fecha</th>
<th>Cliente</th>
<th class="center">Cantidad</th>
<th>Estado</th>
<th class="number">Importe</th>
<th class="center">Tickets</th>
</tr>
</thead>
<tbody>
@forelse ($sales as $sale)
<tr>
<td>#{{ $sale->id }}</td>
<td>{{ $sale->created_at?->format('d/m/Y H:i') ?? '-' }}</td>
<td>{{ $sale->nombre_apellido ?: 'Sin nombre' }}</td>
<td class="center">{{ (int) ($sale->quantity ?? 0) }}</td>
<td>{{ match ($sale->status) {
'paid' => 'Confirmado',
'created', 'pending_payment' => 'Esperando pago',
default => 'Anulado',
} }}</td>
<td class="number">${{ number_format((float) $sale->total, 2, ',', '.') }}</td>
<td class="center">{{ (int) ($sale->tickets_count ?? 0) }}</td>
</tr>
@empty
<tr><td class="empty" colspan="7">No hay ventas para los criterios seleccionados.</td></tr>
@endforelse
</tbody>
</table>
</body>
</html>

View File

@@ -6,6 +6,7 @@ require __DIR__.'/../app/Domains/Cart/routes/api.php';
require __DIR__.'/../app/Domains/StorageTest/routes/api.php';
require __DIR__.'/../app/Domains/MailTest/routes/api.php';
require __DIR__.'/../app/Domains/Purchase/routes/api.php';
require __DIR__.'/../app/Domains/Sale/routes/api.php';
require __DIR__.'/../app/Domains/Tenant/routes/api.php';
require __DIR__.'/../app/Domains/Integration/routes/api.php';
require __DIR__.'/../app/Domains/Menu/routes/api.php';

View File

@@ -0,0 +1,67 @@
<?php
namespace Tests\Feature\Forms;
use App\Domains\Auth\Models\User;
use App\Domains\Authorization\Enums\RoleCode;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Tenant\Models\Tenant;
use App\Domains\Tenant\Models\WebsiteType;
use Database\Seeders\AuthorizationSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Laravel\Sanctum\Sanctum;
use Tests\TestCase;
class AdminAppSaleFormControllerTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
$this->seed(AuthorizationSeeder::class);
WebsiteType::query()->create([
'codigo' => 'onticket',
'nombre' => 'OnTicket',
]);
}
public function test_authentication_is_required(): void
{
$this->getJson('/api/v1/adminapp/forms/sale')->assertUnauthorized();
}
public function test_an_adminapp_user_can_get_the_sale_form(): void
{
$tenant = Tenant::query()->create([
'codigo' => 'acme',
'nombre' => 'Acme',
'dominio' => 'acme.test',
'website_type_code' => 'onticket',
]);
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::AdminApp->value,
'tenant_codigo' => $tenant->codigo,
]));
$this->getJson('/api/v1/adminapp/forms/sale')
->assertOk()
->assertJsonCount(count(Purchase::statuses()), 'data.statuses')
->assertJsonPath('data.statuses.0.code', Purchase::STATUS_CREATED)
->assertJsonPath('data.statuses.0.name', 'Creada')
->assertJsonPath('data.statuses.1.code', Purchase::STATUS_PENDING_PAYMENT)
->assertJsonPath('data.statuses.2.code', Purchase::STATUS_PAID)
->assertJsonPath('data.statuses.5.code', Purchase::STATUS_EXPIRED);
}
public function test_a_customer_cannot_get_the_sale_form(): void
{
Sanctum::actingAs(User::factory()->create([
'rol_codigo' => RoleCode::User->value,
'tenant_codigo' => null,
]));
$this->getJson('/api/v1/adminapp/forms/sale')->assertForbidden();
}
}

View File

@@ -30,6 +30,13 @@ class LogsValueChangesTest extends TestCase
$table->id();
});
Schema::create('tenants', function (Blueprint $table): void {
$table->id();
$table->string('codigo')->unique();
});
Schema::getConnection()->table('tenants')->insert(['codigo' => 'test']);
Schema::create('logging_test_products', function (Blueprint $table): void {
$table->id();
$table->string('name');
@@ -40,12 +47,15 @@ class LogsValueChangesTest extends TestCase
Schema::create('compras', function (Blueprint $table): void {
$table->id();
$table->string('tenant_codigo');
$table->string('status')->default(Purchase::STATUS_CREATED);
$table->timestamps();
});
$migration = require database_path('migrations/2026_08_03_000200_create_value_changes_table.php');
$migration->up();
$tenantMigration = require database_path('migrations/2026_08_04_000000_add_tenant_code_to_value_changes_table.php');
$tenantMigration->up();
}
public function test_it_creates_one_system_record_per_configured_change(): void
@@ -64,6 +74,7 @@ class LogsValueChangesTest extends TestCase
$this->assertDatabaseCount('value_changes', 2);
$this->assertDatabaseHas('value_changes', [
'tenant_code' => 'test',
'attribute' => 'name',
'old_value' => 'Original',
'new_value' => 'Updated',
@@ -114,6 +125,7 @@ class LogsValueChangesTest extends TestCase
public function test_purchase_logs_its_status_changes(): void
{
$purchase = Purchase::query()->create([
'tenant_codigo' => 'test',
'status' => Purchase::STATUS_CREATED,
]);
@@ -122,6 +134,7 @@ class LogsValueChangesTest extends TestCase
]);
$this->assertDatabaseHas('value_changes', [
'tenant_code' => 'test',
'trackable_type' => $purchase->getMorphClass(),
'trackable_id' => $purchase->id,
'attribute' => 'status',
@@ -145,4 +158,9 @@ class LoggingTestProduct extends Model
'name',
'price',
];
protected function valueChangeTenantCode(): string
{
return 'test';
}
}

View File

@@ -11,6 +11,7 @@ use App\Domains\Catalog\Models\CatalogItem;
use App\Domains\Catalog\Models\Category;
use App\Domains\Catalog\Models\Inventory;
use App\Domains\Catalog\Models\Variant;
use App\Domains\Event\Models\Event;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Purchase\Services\CheckoutService;
use App\Domains\Tenant\Models\Tenant;
@@ -33,6 +34,12 @@ class StorePurchaseTest extends TestCase
public function test_it_creates_an_independent_purchase_snapshot_from_cart(): void
{
$tenant = $this->createTenant('sonder', 'Sonder', 'sonder.com.ar');
$event = Event::query()->create([
'tenant_code' => $tenant->codigo,
'name' => 'Sonder Fest',
'address' => 'Test address',
]);
$tenant->update(['active_event_id' => $event->id]);
$user = User::factory()->create([
'email' => 'buyer@example.com',
]);
@@ -85,6 +92,7 @@ class StorePurchaseTest extends TestCase
$response->assertJsonPath('data.nombre_apellido', null);
$response->assertJsonPath('data.email', null);
$response->assertJsonPath('data.tenant_codigo', 'sonder');
$response->assertJsonPath('data.event_id', $event->id);
$response->assertJsonPath('data.status', Purchase::STATUS_CREATED);
$response->assertJsonPath('data.items_source', 'purchase');
$response->assertJsonCount(1, 'data.items');
@@ -97,6 +105,7 @@ class StorePurchaseTest extends TestCase
'id' => $purchaseId,
'cart_id' => $cartId,
'tenant_codigo' => 'sonder',
'event_id' => $event->id,
'user_id' => $user->id,
'dni' => null,
'telefono' => null,

View File

@@ -0,0 +1,25 @@
<?php
namespace Tests\Unit\Forms;
use App\Domains\Forms\Services\SaleFormService;
use App\Domains\Purchase\Models\Purchase;
use PHPUnit\Framework\TestCase;
class SaleFormServiceTest extends TestCase
{
public function test_it_returns_every_purchase_status_as_a_form_option(): void
{
$form = (new SaleFormService)->get();
$this->assertSame(Purchase::statuses(), array_column($form['statuses'], 'code'));
$this->assertSame([
'Creada',
'Esperando pago',
'Confirmada',
'Cancelada',
'Rechazada',
'Vencida',
], array_column($form['statuses'], 'name'));
}
}

View File

@@ -5,6 +5,7 @@ namespace Tests\Unit\Logging;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Enums\ValueChangeActorType;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Tenant\Models\Tenant;
use Illuminate\Database\Eloquent\Relations\MorphTo;
use Tests\TestCase;
@@ -14,6 +15,7 @@ class ValueChangeTest extends TestCase
{
$valueChange = new ValueChange;
$valueChange->setRawAttributes([
'tenant_code' => 'test',
'trackable_id' => '10',
'attribute' => 'status',
'old_value' => 'pending',
@@ -25,6 +27,7 @@ class ValueChangeTest extends TestCase
$this->assertSame('value_changes', $valueChange->getTable());
$this->assertFalse($valueChange->usesTimestamps());
$this->assertSame('test', $valueChange->tenant_code);
$this->assertSame(10, $valueChange->trackable_id);
$this->assertSame('status', $valueChange->attribute);
$this->assertSame('pending', $valueChange->old_value);
@@ -34,5 +37,6 @@ class ValueChangeTest extends TestCase
$this->assertSame(20, $valueChange->user_id);
$this->assertInstanceOf(MorphTo::class, $valueChange->trackable());
$this->assertInstanceOf(User::class, $valueChange->user()->getRelated());
$this->assertInstanceOf(Tenant::class, $valueChange->tenant()->getRelated());
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Tests\Unit\Sale;
use App\Domains\Auth\Models\User;
use App\Domains\Logging\Models\ValueChange;
use App\Domains\Purchase\Models\Purchase;
use App\Domains\Sale\Services\AdminAppSalePdfService;
use App\Domains\Tenant\Models\Tenant;
use Barryvdh\DomPDF\ServiceProvider;
use Tests\TestCase;
class AdminAppSalePdfServiceTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
$this->app->register(ServiceProvider::class);
}
public function test_it_downloads_the_sales_report_as_a_pdf(): void
{
$response = app(AdminAppSalePdfService::class)->downloadSales(
$this->tenant(),
collect([$this->sale()]),
);
$this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString(
'attachment; filename=ventas_acme_',
(string) $response->headers->get('content-disposition'),
);
$this->assertStringStartsWith('%PDF', $response->getContent());
}
public function test_it_downloads_the_modification_history_as_a_pdf(): void
{
$sale = $this->sale();
$admin = (new User)->forceFill([
'id' => 10,
'nombre_apellido' => 'Admin Test',
'email' => 'admin@example.test',
]);
$modification = (new ValueChange)->forceFill([
'id' => 1,
'trackable_id' => $sale->id,
'attribute' => 'status',
'old_value' => Purchase::STATUS_PENDING_PAYMENT,
'new_value' => Purchase::STATUS_PAID,
'changed_at' => now(),
'actor_type' => 'user',
]);
$modification->setRelation('trackable', $sale);
$modification->setRelation('user', $admin);
$response = app(AdminAppSalePdfService::class)->downloadModifications(
$this->tenant(),
collect([$modification]),
);
$this->assertSame('application/pdf', $response->headers->get('content-type'));
$this->assertStringContainsString(
'attachment; filename=historial_modificaciones_acme_',
(string) $response->headers->get('content-disposition'),
);
$this->assertStringStartsWith('%PDF', $response->getContent());
}
private function tenant(): Tenant
{
return (new Tenant)->forceFill([
'codigo' => 'acme',
'nombre' => 'Acme Eventos',
]);
}
private function sale(): Purchase
{
return (new Purchase)->forceFill([
'id' => 15,
'created_at' => now(),
'nombre_apellido' => 'Cliente Test',
'quantity' => 2,
'status' => Purchase::STATUS_PAID,
'total' => '25000.00',
'tickets_count' => 2,
]);
}
}