Add report exports

This commit is contained in:
Constantin Graf
2024-10-16 13:29:54 +02:00
committed by Constantin Graf
parent e54df74d5d
commit 64535ceea6
12 changed files with 1452 additions and 17 deletions

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use Maatwebsite\Excel\Excel;
enum ExportFormat: string
{
case CSV = 'csv';
case PDF = 'pdf';
case XLSX = 'xlsx';
case ODS = 'ods';
public function getFileExtension(): string
{
return match ($this) {
self::CSV => 'csv',
self::PDF => 'pdf',
self::XLSX => 'xlsx',
self::ODS => 'ods',
};
}
public function getExportPackageType(): string
{
return match ($this) {
self::CSV => Excel::CSV,
self::PDF => Excel::MPDF,
self::XLSX => Excel::XLSX,
self::ODS => Excel::ODS,
};
}
}

View File

@@ -4,10 +4,12 @@ declare(strict_types=1);
namespace App\Http\Controllers\Api\V1;
use App\Enums\ExportFormat;
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
use App\Exceptions\Api\TimeEntryStillRunningApiException;
use App\Http\Requests\V1\TimeEntry\TimeEntryAggregateRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryDestroyMultipleRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexExportRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryIndexRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryStoreRequest;
use App\Http\Requests\V1\TimeEntry\TimeEntryUpdateMultipleRequest;
@@ -21,15 +23,20 @@ use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Service\ReportExport\TimeEntriesDetailedCsvExport;
use App\Service\ReportExport\TimeEntriesDetailedExport;
use App\Service\TimeEntryAggregationService;
use App\Service\TimeEntryFilter;
use App\Service\TimezoneService;
use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Maatwebsite\Excel\Facades\Excel;
class TimeEntryController extends Controller
{
@@ -63,21 +70,7 @@ class TimeEntryController extends Controller
$this->checkPermission($organization, 'time-entries:view:all');
}
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$totalCount = $timeEntriesQuery->count();
@@ -128,6 +121,76 @@ class TimeEntryController extends Controller
]);
}
/**
* @return Builder<TimeEntry>
*/
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
{
$timeEntriesQuery = TimeEntry::query()
->whereBelongsTo($organization, 'organization')
->orderBy('start', 'desc');
$filter = new TimeEntryFilter($timeEntriesQuery);
$filter->addStartFilter($request->input('start'));
$filter->addEndFilter($request->input('end'));
$filter->addActiveFilter($request->input('active'));
$filter->addMemberIdFilter($member);
$filter->addMemberIdsFilter($request->input('member_ids'));
$filter->addProjectIdsFilter($request->input('project_ids'));
$filter->addTagIdsFilter($request->input('tag_ids'));
$filter->addTaskIdsFilter($request->input('task_ids'));
$filter->addClientIdsFilter($request->input('client_ids'));
$filter->addBillableFilter($request->input('billable'));
return $filter->get();
}
/**
* @throws AuthorizationException
*/
public function indexExport(Organization $organization, TimeEntryIndexExportRequest $request): JsonResponse
{
/** @var Member|null $member */
$member = $request->has('member_id') ? Member::query()->findOrFail($request->input('member_id')) : null;
if ($member !== null && $member->user_id === Auth::id()) {
$this->checkPermission($organization, 'time-entries:view:own');
} else {
$this->checkPermission($organization, 'time-entries:view:all');
}
$timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member);
$timeEntriesQuery->with([
'task',
'project' => [
'client',
],
'user',
'tagsRelation',
]);
$format = $request->getFormatValue();
$filename = 'time-entries-export-'.now()->format('Y-m-d_H-i-s').'.'.$format->getFileExtension();
$path = 'exports/'.$filename;
if ($format === ExportFormat::CSV) {
$export = new TimeEntriesDetailedCsvExport(config('filesystems.private'), $filename, $timeEntriesQuery, 1000);
$export->export();
} else {
Excel::store(
new TimeEntriesDetailedExport($timeEntriesQuery),
$path,
config('filesystems.private'),
$format->getExportPackageType(),
[
'visibility' => 'private',
]
);
}
return response()->json([
'download_url' => Storage::disk(config('filesystems.private'))
->temporaryUrl($path, now()->addMinutes(5)),
]);
}
/**
* Get aggregated time entries in organization
*

View File

@@ -0,0 +1,143 @@
<?php
declare(strict_types=1);
namespace App\Http\Requests\V1\TimeEntry;
use App\Enums\ExportFormat;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Validation\Rule;
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
/**
* @property Organization $organization
*/
class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
{
/**
* Get the validation rules that apply to the request.
*
* @return array<string, array<string|ValidationRule|\Illuminate\Contracts\Validation\Rule>>
*/
public function rules(): array
{
return [
'format' => [
'required',
'string',
Rule::enum(ExportFormat::class),
],
// Filter by member ID
'member_id' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by multiple member IDs, member IDs are OR combined, but AND combined with the member_id parameter
'member_ids' => [
'array',
'min:1',
],
'member_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Member::class, null, function (Builder $builder): Builder {
/** @var Builder<Member> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by project IDs, project IDs are OR combined
'project_ids' => [
'array',
'min:1',
],
'project_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Project::class, null, function (Builder $builder): Builder {
/** @var Builder<Project> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by tag IDs, tag IDs are AND combined
'tag_ids' => [
'array',
'min:1',
],
'tag_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Tag::class, null, function (Builder $builder): Builder {
/** @var Builder<Tag> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter by task IDs, task IDs are OR combined
'task_ids' => [
'array',
'min:1',
],
'task_ids.*' => [
'string',
'uuid',
new ExistsEloquent(Task::class, null, function (Builder $builder): Builder {
/** @var Builder<Task> $builder */
return $builder->whereBelongsTo($this->organization, 'organization');
}),
],
// Filter only time entries that have a start date after the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'start' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
'before:end',
],
// Filter only time entries that have a start date before the given timestamp in UTC (example: 2021-01-01T00:00:00Z)
'end' => [
'nullable',
'string',
'date_format:Y-m-d\TH:i:s\Z',
],
// Filter by active status (active means has no end date, is still running)
'active' => [
'string',
'in:true,false',
],
// Filter by billable status
'billable' => [
'string',
'in:true,false',
],
// Limit the number of returned time entries (default: 150)
'limit' => [
'integer',
'min:1',
'max:500',
],
// Filter makes sure that only time entries of a whole date are returned
'only_full_dates' => [
'string',
'in:true,false',
],
];
}
public function getOnlyFullDates(): bool
{
return $this->input('only_full_dates', 'false') === 'true';
}
public function getFormatValue(): ExportFormat
{
return ExportFormat::from($this->validated('format'));
}
}

View File

@@ -12,6 +12,8 @@ use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\HasManyJson;
/**
* @property string $id
@@ -30,6 +32,7 @@ class Tag extends Model implements AuditableContract
/** @use HasFactory<TagFactory> */
use HasFactory;
use HasJsonRelationships;
use HasUuids;
/**
@@ -48,4 +51,14 @@ class Tag extends Model implements AuditableContract
{
return $this->belongsTo(Organization::class, 'organization_id');
}
/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return HasManyJson<TimeEntry, $this>
*/
public function timeEntries(): HasManyJson
{
return $this->hasManyJson(TimeEntry::class, 'tags');
}
}

View File

@@ -10,6 +10,7 @@ use App\Service\BillableRateService;
use Carbon\CarbonInterval;
use Database\Factories\TimeEntryFactory;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
@@ -17,6 +18,8 @@ use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Carbon;
use Korridor\LaravelComputedAttributes\ComputedAttributes;
use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
use Staudenmeir\EloquentJsonRelations\HasJsonRelationships;
use Staudenmeir\EloquentJsonRelations\Relations\BelongsToJson;
/**
* @property string $id
@@ -42,6 +45,7 @@ use OwenIt\Auditing\Contracts\Auditable as AuditableContract;
* @property-read Client|null $client
* @property string|null $task_id
* @property-read Task|null $task
* @property-read Collection<Tag> $tagsRelation
*
* @method Builder<TimeEntry> hasTag(Tag $tag)
* @method static TimeEntryFactory factory()
@@ -50,10 +54,11 @@ class TimeEntry extends Model implements AuditableContract
{
use ComputedAttributes;
use CustomAuditable;
/** @use HasFactory<TimeEntryFactory> */
use HasFactory;
use HasJsonRelationships;
use HasUuids;
/**
@@ -197,4 +202,14 @@ class TimeEntry extends Model implements AuditableContract
{
return $this->belongsTo(Client::class, 'client_id');
}
/**
* Warning: This relation based on a JSON column. Please make sure that there are no performance issues, before using it.
*
* @return BelongsToJson<Tag, $this>
*/
public function tagsRelation(): BelongsToJson
{
return $this->belongsToJson(Tag::class, 'tags');
}
}

View File

@@ -0,0 +1,99 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Storage;
use League\Csv\Writer;
/**
* @template T of Model
*/
abstract class CsvExport
{
private string $disk;
private string $filename;
private int $chunk;
/**
* @var string[]
*/
public const array HEADER = [];
/**
* @var Builder<T>
*/
private Builder $builder;
/**
* @param Builder<T> $builder
*/
public function __construct(string $disk, string $filename, Builder $builder, int $chunk)
{
$this->disk = $disk;
$this->filename = $filename;
$this->chunk = $chunk;
$this->builder = $builder;
}
/**
* @param T $model
* @return array<string, string|Carbon|null>
*/
abstract public function mapRow(Model $model): array;
public function export(): void
{
$writer = Writer::createFromPath(Storage::disk($this->disk)->path($this->filename), 'w+');
$writer->insertOne(static::HEADER);
$this->builder->chunk($this->chunk, function ($models) use ($writer): void {
foreach ($models as $model) {
$data = $this->mapRow($model);
$row = $this->convertRow($data);
$this->validateRow($row);
$writer->insertOne(array_values($row));
}
});
}
/**
* @param array<string, string|Carbon|null> $data
* @return array<string, string>
*/
private function convertRow(array $data): array
{
$convertedRow = [];
foreach ($data as $key => $value) {
if ($value instanceof Carbon) {
$convertedRow[$key] = $value->toIso8601String();
} elseif ($value === null) {
$convertedRow[$key] = '';
} else {
$convertedRow[$key] = $value;
}
}
return $convertedRow;
}
/**
* @param array<string, string> $row
*
* @throws \Exception
*/
private function validateRow(array $row): void
{
if (array_keys($row) !== self::HEADER) {
throw new \Exception('Invalid row');
}
}
}

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Model;
/**
* @extends CsvExport<TimeEntry>
*/
class TimeEntriesDetailedCsvExport extends CsvExport
{
public const array HEADER = [
'id',
'user_id',
'project_id',
'task_id',
'start_time',
'end_time',
'duration',
'description',
'created_at',
'updated_at',
'deleted_at',
];
/**
* @param TimeEntry $model
*/
public function mapRow(Model $model): array
{
return [
'id' => $model->id,
'user_id' => $model->user_id,
'project_id' => $model->project_id,
'task_id' => $model->task_id,
'start_time' => $model->start,
'end_time' => $model->end,
'description' => $model->description,
'created_at' => $model->created_at,
'updated_at' => $model->updated_at,
];
}
}

View File

@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace App\Service\ReportExport;
use App\Models\TimeEntry;
use Illuminate\Database\Eloquent\Builder;
use Maatwebsite\Excel\Concerns\Exportable;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\WithCustomCsvSettings;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;
/**
* @implements WithMapping<TimeEntry>
*/
class TimeEntriesDetailedExport implements FromQuery, WithCustomCsvSettings, WithHeadings, WithMapping
{
use Exportable;
/**
* @var Builder<TimeEntry>
*/
private Builder $builder;
/**
* @param Builder<TimeEntry> $builder
*/
public function __construct(Builder $builder)
{
$this->builder = $builder;
}
/**
* @return Builder<TimeEntry>
*/
public function query(): Builder
{
return $this->builder;
}
/**
* @return array<string, string|bool>
*/
public function getCsvSettings(): array
{
return [
'delimiter' => ',',
'use_bom' => false,
'output_encoding' => 'ISO-8859-1',
];
}
/**
* @return string[]
*/
public function headings(): array
{
return [
'Description',
'Task',
'Project',
'Client',
'User',
'Start date',
'Start time',
'End date',
'End time',
'Duration',
'Duration (decimal)',
'Billable',
'Tags',
];
}
/**
* @param TimeEntry $model
* @return array<int, string|float|null>
*/
public function map($model): array
{
$duration = $model->getDuration();
return [
$model->description,
$model->task?->name,
$model->project?->name,
$model->project?->client?->name,
$model->user->name,
$model->start->format('Y-m-d'),
$model->start->format('H:i:s'),
$model->end?->format('Y-m-d'),
$model->end?->format('H:i:s'),
$duration !== null ? (int) floor($duration->totalHours).':'.$duration->format('%I:%S') : null,
$duration?->totalHours,
$model->billable ? 'Yes' : 'No',
$model->tagsRelation->pluck('name')->implode(', '),
];
}
}