diff --git a/app/Enums/TimeEntryRoundingType.php b/app/Enums/TimeEntryRoundingType.php new file mode 100644 index 00000000..7300f8ac --- /dev/null +++ b/app/Enums/TimeEntryRoundingType.php @@ -0,0 +1,16 @@ +properties->start, $report->properties->end, - true + true, + $report->properties->roundingType, + $report->properties->roundingMinutes, ); $historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $timeEntriesQuery->clone(), @@ -84,7 +86,9 @@ class ReportController extends Controller true, $report->properties->start, $report->properties->end, - true + true, + $report->properties->roundingType, + $report->properties->roundingMinutes, ); return new DetailedWithDataReportResource($report, $data, $historyData); diff --git a/app/Http/Controllers/Api/V1/TimeEntryController.php b/app/Http/Controllers/Api/V1/TimeEntryController.php index 1e69d928..a2bb23ac 100644 --- a/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -33,6 +33,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport; use App\Service\ReportExport\TimeEntriesReportExport; use App\Service\TimeEntryAggregationService; use App\Service\TimeEntryFilter; +use App\Service\TimeEntryService; use App\Service\TimezoneService; use Gotenberg\Exceptions\GotenbergApiErrored; use Gotenberg\Exceptions\NoOutputFileInResponse; @@ -47,6 +48,7 @@ use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Support\Collection; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Blade; +use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Maatwebsite\Excel\Facades\Excel; @@ -140,8 +142,15 @@ class TimeEntryController extends Controller */ private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder { + $select = TimeEntry::SELECT_COLUMNS; + if ($request->getRoundingType() !== null && $request->getRoundingMinutes() !== null) { + $select = array_diff($select, ['start', 'end']); + $select[] = DB::raw(app(TimeEntryService::class)->getStartSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as start'); + $select[] = DB::raw(app(TimeEntryService::class)->getEndSelectRawForRounding($request->getRoundingType(), $request->getRoundingMinutes()).' as end'); + } $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($organization, 'organization') + ->select($select) ->orderBy('start', 'desc'); $filter = new TimeEntryFilter($timeEntriesQuery); @@ -183,6 +192,8 @@ class TimeEntryController extends Controller $user = $this->user(); $timezone = $user->timezone; $showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates; + $roundingType = $request->getRoundingType(); + $roundingMinutes = $request->getRoundingMinutes(); $timeEntriesQuery = $this->getTimeEntriesQuery($organization, $request, $member); $timeEntriesQuery->with([ @@ -207,8 +218,9 @@ class TimeEntryController extends Controller if ($viewFile === false) { throw new \LogicException('View file not found'); } + $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( - $timeEntriesQuery->clone()->reorder()->withOnly([]), + $timeEntriesAggregateQuery, null, null, $user->timezone, @@ -216,7 +228,9 @@ class TimeEntryController extends Controller false, null, null, - $showBillableRate + $showBillableRate, + $roundingType, + $roundingMinutes, ); $html = Blade::render($viewFile, [ 'timeEntries' => $timeEntriesQuery->get(), @@ -324,6 +338,8 @@ class TimeEntryController extends Controller $group1Type = $request->getGroup(); $group2Type = $request->getSubGroup(); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); + $roundingType = $request->getRoundingType(); + $roundingMinutes = $request->getRoundingMinutes(); $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries( $timeEntriesAggregateQuery, @@ -334,7 +350,9 @@ class TimeEntryController extends Controller $request->getFillGapsInTimeGroups(), $request->getStart(), $request->getEnd(), - $showBillableRate + $showBillableRate, + $roundingType, + $roundingMinutes ); return [ @@ -373,6 +391,8 @@ class TimeEntryController extends Controller $group = $request->getGroup(); $subGroup = $request->getSubGroup(); $timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member); + $roundingType = $request->getRoundingType(); + $roundingMinutes = $request->getRoundingMinutes(); $aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions( $timeEntriesAggregateQuery->clone(), @@ -383,7 +403,9 @@ class TimeEntryController extends Controller false, $request->getStart(), $request->getEnd(), - $showBillableRate + $showBillableRate, + $roundingType, + $roundingMinutes ); $dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries( $timeEntriesAggregateQuery->clone(), @@ -394,7 +416,9 @@ class TimeEntryController extends Controller true, $request->getStart(), $request->getEnd(), - $showBillableRate + $showBillableRate, + $roundingType, + $roundingMinutes ); $currency = $organization->currency; $timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user()); @@ -477,7 +501,7 @@ class TimeEntryController extends Controller /** * @return Builder */ - private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest $request, ?Member $member): Builder + private function getTimeEntriesAggregateQuery(Organization $organization, TimeEntryAggregateRequest|TimeEntryAggregateExportRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder { $timeEntriesQuery = TimeEntry::query() ->whereBelongsTo($organization, 'organization'); diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php index 7c33242b..35f84519 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateExportRequest.php @@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry; use App\Enums\ExportFormat; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\TimeEntryRoundingType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Client; use App\Models\Member; @@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest 'string', 'in:true,false', ], + // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. + 'rounding_type' => [ + 'nullable', + 'string', + Rule::enum(TimeEntryRoundingType::class), + ], + // Defines the length of the interval that the time entry rounding rounds to. + 'rounding_minutes' => [ + 'nullable', + 'numeric', + 'integer', + ], ]; } @@ -211,4 +224,22 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest { return ExportFormat::from($this->validated('format')); } + + public function getRoundingType(): ?TimeEntryRoundingType + { + if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { + return null; + } + + return TimeEntryRoundingType::from($this->validated('rounding_type')); + } + + public function getRoundingMinutes(): ?int + { + if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) { + return null; + } + + return (int) $this->validated('rounding_minutes'); + } } diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php index 45db56cb..39c9270e 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryAggregateRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryRoundingType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Client; use App\Models\Member; @@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest 'string', 'in:true,false', ], + // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. + 'rounding_type' => [ + 'nullable', + 'string', + Rule::enum(TimeEntryRoundingType::class), + ], + // Defines the length of the interval that the time entry rounding rounds to. + 'rounding_minutes' => [ + 'nullable', + 'numeric', + 'integer', + ], ]; } @@ -173,4 +186,22 @@ class TimeEntryAggregateRequest extends BaseFormRequest { return $this->input('end') !== null ? Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $this->input('end'), 'UTC') : null; } + + public function getRoundingType(): ?TimeEntryRoundingType + { + if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { + return null; + } + + return TimeEntryRoundingType::from($this->validated('rounding_type')); + } + + public function getRoundingMinutes(): ?int + { + if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) { + return null; + } + + return (int) $this->validated('rounding_minutes'); + } } diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php index 84c21562..6c3180b2 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexExportRequest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; use App\Enums\ExportFormat; +use App\Enums\TimeEntryRoundingType; use App\Models\Member; use App\Models\Organization; use App\Models\Project; @@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest 'string', 'in:true,false', ], + // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. + 'rounding_type' => [ + 'nullable', + 'string', + Rule::enum(TimeEntryRoundingType::class), + ], + // Defines the length of the interval that the time entry rounding rounds to. + 'rounding_minutes' => [ + 'nullable', + 'numeric', + 'integer', + ], ]; } @@ -170,4 +183,22 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest { return ExportFormat::from($this->validated('format')); } + + public function getRoundingType(): ?TimeEntryRoundingType + { + if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { + return null; + } + + return TimeEntryRoundingType::from($this->validated('rounding_type')); + } + + public function getRoundingMinutes(): ?int + { + if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) { + return null; + } + + return (int) $this->validated('rounding_minutes'); + } } diff --git a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php index 19203cbb..c6463d79 100644 --- a/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php +++ b/app/Http/Requests/V1/TimeEntry/TimeEntryIndexRequest.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace App\Http\Requests\V1\TimeEntry; +use App\Enums\TimeEntryRoundingType; use App\Http\Requests\V1\BaseFormRequest; use App\Models\Client; use App\Models\Member; @@ -11,8 +12,11 @@ use App\Models\Organization; use App\Models\Project; use App\Models\Tag; use App\Models\Task; +use Illuminate\Contracts\Validation\Rule as RuleContract; use Illuminate\Contracts\Validation\ValidationRule; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Foundation\Http\FormRequest; +use Illuminate\Validation\Rule; use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent; /** @@ -23,7 +27,7 @@ class TimeEntryIndexRequest extends BaseFormRequest /** * Get the validation rules that apply to the request. * - * @return array> + * @return array> */ public function rules(): array { @@ -136,6 +140,18 @@ class TimeEntryIndexRequest extends BaseFormRequest 'string', 'in:true,false', ], + // Rounding type defined where the end of each time entry should be rounded to. For example: nearest rounds the end to the nearest x minutes group. Rounding per time entry is activated if `rounding_type` and `rounding_minutes` is not null. + 'rounding_type' => [ + 'nullable', + 'string', + Rule::enum(TimeEntryRoundingType::class), + ], + // Defines the length of the interval that the time entry rounding rounds to. + 'rounding_minutes' => [ + 'nullable', + 'numeric', + 'integer', + ], ]; } @@ -153,4 +169,22 @@ class TimeEntryIndexRequest extends BaseFormRequest { return $this->has('offset') ? (int) $this->validated('offset', 0) : 0; } + + public function getRoundingType(): ?TimeEntryRoundingType + { + if (! $this->has('rounding_type') || $this->validated('rounding_type') === null) { + return null; + } + + return TimeEntryRoundingType::from($this->validated('rounding_type')); + } + + public function getRoundingMinutes(): ?int + { + if (! $this->has('rounding_minutes') || $this->validated('rounding_minutes') === null) { + return null; + } + + return (int) $this->validated('rounding_minutes'); + } } diff --git a/app/Models/TimeEntry.php b/app/Models/TimeEntry.php index de4576a9..6143111e 100644 --- a/app/Models/TimeEntry.php +++ b/app/Models/TimeEntry.php @@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract 'still_active_email_sent_at' => 'datetime', ]; + public const array SELECT_COLUMNS = [ + 'id', + 'description', + 'start', + 'end', + 'billable_rate', + 'billable', + 'user_id', + 'organization_id', + 'project_id', + 'task_id', + 'tags', + 'created_at', + 'updated_at', + 'member_id', + 'client_id', + 'is_imported', + 'still_active_email_sent_at', + ]; + /** * The attributes that are computed. (f.e. for performance reasons) * These attributes can be regenerated at any time. diff --git a/app/Service/Dto/ReportPropertiesDto.php b/app/Service/Dto/ReportPropertiesDto.php index 892f4eb1..ac056d0f 100644 --- a/app/Service/Dto/ReportPropertiesDto.php +++ b/app/Service/Dto/ReportPropertiesDto.php @@ -6,6 +6,7 @@ namespace App\Service\Dto; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; use Illuminate\Contracts\Database\Eloquent\Castable; use Illuminate\Contracts\Database\Eloquent\CastsAttributes; @@ -59,6 +60,10 @@ class ReportPropertiesDto implements Castable */ public ?Collection $taskIds = null; + public ?TimeEntryRoundingType $roundingType = null; + + public ?int $roundingMinutes = null; + /** * Get the caster class to use when casting from / to this cast target. * @@ -115,6 +120,10 @@ class ReportPropertiesDto implements Castable $dto->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup); $dto->weekStart = Weekday::from($data->weekStart); $dto->timezone = $data->timezone; + // Note: roundingType was added later so it is possible that the value is missing in persisted reports in the DB + $dto->roundingType = isset($data->roundingType) ? TimeEntryRoundingType::from($data->roundingType) : null; + // Note: roundingMinutes was added later so it is possible that the value is missing in persisted reports in the DB + $dto->roundingMinutes = isset($data->roundingMinutes) ? (int) $data->roundingMinutes : null; return $dto; } @@ -140,6 +149,8 @@ class ReportPropertiesDto implements Castable 'historyGroup' => $value->historyGroup->value, 'weekStart' => $value->weekStart->value, 'timezone' => $value->timezone, + 'roundingType' => $value->roundingType?->value, + 'roundingMinutes' => $value->roundingMinutes, ]; $jsonString = json_encode($data); diff --git a/app/Service/TimeEntryAggregationService.php b/app/Service/TimeEntryAggregationService.php index a662011a..a59becbd 100644 --- a/app/Service/TimeEntryAggregationService.php +++ b/app/Service/TimeEntryAggregationService.php @@ -6,6 +6,7 @@ namespace App\Service; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; use App\Models\Client; use App\Models\Project; @@ -41,7 +42,7 @@ class TimeEntryAggregationService * cost: int|null * } */ - public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array + public function getAggregatedTimeEntries(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array { $fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null; $group1Select = null; @@ -56,15 +57,14 @@ class TimeEntryAggregationService } } + $startRawSelect = app(TimeEntryService::class)->getStartSelectRawForRounding($roundingType, $roundingMinutes); + $endRawSelect = app(TimeEntryService::class)->getEndSelectRawForRounding($roundingType, $roundingMinutes); + $timeEntriesQuery->selectRaw( ($group1Select !== null ? $group1Select.' as group_1,' : ''). ($group2Select !== null ? $group2Select.' as group_2,' : ''). - ' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'. - ' round( - sum( - extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60) - ) - ) as cost' + ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'. + ' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost' ); if ($groupBy !== null) { $timeEntriesQuery->groupBy($groupBy); @@ -164,9 +164,9 @@ class TimeEntryAggregationService * cost: int|null * } */ - public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate): array + public function getAggregatedTimeEntriesWithDescriptions(Builder $timeEntriesQuery, ?TimeEntryAggregationType $group1Type, ?TimeEntryAggregationType $group2Type, string $timezone, Weekday $startOfWeek, bool $fillGapsInTimeGroups, ?Carbon $start, ?Carbon $end, bool $showBillableRate, ?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): array { - $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate); + $aggregatedTimeEntries = $this->getAggregatedTimeEntries($timeEntriesQuery, $group1Type, $group2Type, $timezone, $startOfWeek, $fillGapsInTimeGroups, $start, $end, $showBillableRate, $roundingType, $roundingMinutes); $keysGroup1 = []; $keysGroup2 = []; diff --git a/app/Service/TimeEntryService.php b/app/Service/TimeEntryService.php new file mode 100644 index 00000000..6cd97aa5 --- /dev/null +++ b/app/Service/TimeEntryService.php @@ -0,0 +1,42 @@ +toDateTimeString().'\')'; + } + if ($roundingMinutes < 1) { + throw new LogicException('Rounding minutes must be greater than 0'); + } + $end = 'coalesce("end", \''.Carbon::now()->toDateTimeString().'\')'; + if ($roundingType === TimeEntryRoundingType::Down) { + return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')'; + } elseif ($roundingType === TimeEntryRoundingType::Up) { + return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.$roundingMinutes.' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')'; + } elseif ($roundingType === TimeEntryRoundingType::Nearest) { + return 'date_bin(\''.$roundingMinutes.' minutes\', '.$end.' + interval \''.($roundingMinutes / 2).' minutes\', '.$this->getStartSelectRawForRounding($roundingType, $roundingMinutes).')'; + } + } +} diff --git a/database/factories/TimeEntryFactory.php b/database/factories/TimeEntryFactory.php index 72e10bf1..863df443 100644 --- a/database/factories/TimeEntryFactory.php +++ b/database/factories/TimeEntryFactory.php @@ -153,6 +153,16 @@ class TimeEntryFactory extends Factory }); } + public function endWithDuration(Carbon $end, int $durationInSeconds): self + { + return $this->state(function (array $attributes) use ($end, $durationInSeconds): array { + return [ + 'start' => $end->copy()->utc()->subSeconds($durationInSeconds), + 'end' => $end->copy()->utc(), + ]; + }); + } + public function start(Carbon $start): self { return $this->state(function (array $attributes) use ($start): array { diff --git a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php index 69742587..c1ba8a95 100644 --- a/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php +++ b/tests/Unit/Endpoint/Api/V1/TimeEntryEndpointTest.php @@ -8,6 +8,7 @@ use App\Enums\ExportFormat; use App\Enums\Role; use App\Enums\TimeEntryAggregationType; use App\Enums\TimeEntryAggregationTypeInterval; +use App\Enums\TimeEntryRoundingType; use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException; use App\Http\Controllers\Api\V1\TimeEntryController; use App\Jobs\RecalculateSpentTimeForProject; @@ -389,6 +390,141 @@ class TimeEntryEndpointTest extends ApiEndpointTestAbstract ); } + public function test_index_endpoint_can_round_up(): void + { + // Arrange + $this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04')); + $data = $this->createUserWithPermission([ + 'time-entries:view:own', + ]); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'), + 'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), + ]); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'), + 'end' => null, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'member_id' => $data->member->getKey(), + 'rounding_type' => TimeEntryRoundingType::Up, + 'rounding_minutes' => 6, + ])); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->has('meta') + ->where('meta.total', 2) + ->count('data', 2) + ->where('data.0.id', $timeEntry1->getKey()) + ->where('data.0.start', '2020-01-01T00:00:00Z') + ->where('data.0.end', '2020-01-01T00:06:00Z') + ->where('data.1.id', $timeEntry2->getKey()) + ->where('data.1.start', '2020-01-01T00:00:00Z') + ->where('data.1.end', '2020-01-01T00:18:00Z') + ); + } + + public function test_index_endpoint_can_round_down(): void + { + // Arrange + $this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:04')); + $data = $this->createUserWithPermission([ + 'time-entries:view:own', + ]); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'), + 'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), + ]); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'), + 'end' => null, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'member_id' => $data->member->getKey(), + 'rounding_type' => TimeEntryRoundingType::Down, + 'rounding_minutes' => 6, + ])); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->has('meta') + ->where('meta.total', 2) + ->count('data', 2) + ->where('data.0.id', $timeEntry1->getKey()) + ->where('data.0.start', '2020-01-01T00:00:00Z') + ->where('data.0.end', '2020-01-01T00:00:00Z') + ->where('data.1.id', $timeEntry2->getKey()) + ->where('data.1.start', '2020-01-01T00:00:00Z') + ->where('data.1.end', '2020-01-01T00:12:00Z') + ); + } + + public function test_index_endpoint_can_round_nearest(): void + { + // Arrange + $this->travelTo(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:15:00')); + $data = $this->createUserWithPermission([ + 'time-entries:view:own', + ]); + $timeEntry1 = TimeEntry::factory()->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:08'), + 'end' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:02:59'), + ]); + $timeEntry2 = TimeEntry::factory()->forOrganization($data->organization) + ->forMember($data->member) + ->create([ + 'start' => Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:07'), + 'end' => null, + ]); + Passport::actingAs($data->user); + + // Act + $response = $this->getJson(route('api.v1.time-entries.index', [ + $data->organization->getKey(), + 'member_id' => $data->member->getKey(), + 'rounding_type' => TimeEntryRoundingType::Nearest, + 'rounding_minutes' => 6, + ])); + + // Assert + $this->assertResponseCode($response, 200); + $response->assertJson(fn (AssertableJson $json) => $json + ->has('data') + ->has('meta') + ->where('meta.total', 2) + ->count('data', 2) + ->where('data.0.id', $timeEntry1->getKey()) + ->where('data.0.start', '2020-01-01T00:00:00Z') + ->where('data.0.end', '2020-01-01T00:00:00Z') + ->where('data.1.id', $timeEntry2->getKey()) + ->where('data.1.start', '2020-01-01T00:00:00Z') + ->where('data.1.end', '2020-01-01T00:18:00Z') + ); + } + public function test_index_endpoint_after_filter_returns_time_entries_after_date(): void { // Arrange diff --git a/tests/Unit/Service/TimeEntryAggregationServiceTest.php b/tests/Unit/Service/TimeEntryAggregationServiceTest.php index 2b6009a2..b46291b1 100644 --- a/tests/Unit/Service/TimeEntryAggregationServiceTest.php +++ b/tests/Unit/Service/TimeEntryAggregationServiceTest.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Tests\Unit\Service; use App\Enums\TimeEntryAggregationType; +use App\Enums\TimeEntryRoundingType; use App\Enums\Weekday; use App\Models\Client; use App\Models\Project; @@ -40,7 +41,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase false, null, null, - true + true, + null, + null ); // Assert @@ -87,7 +90,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase false, Carbon::now()->subDays(2)->utc(), Carbon::now()->subDay()->utc(), - true + true, + null, + null ); // Assert @@ -172,7 +177,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase false, Carbon::now()->subDays(2)->utc(), Carbon::now()->subDay()->utc(), - false + false, + null, + null ); // Assert @@ -238,7 +245,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase true, Carbon::now()->subDays(2)->utc(), Carbon::now()->subDay()->utc(), - true + true, + null, + null ); // Assert @@ -280,7 +289,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase true, Carbon::now()->subDays(2), Carbon::now()->subDay(), - true + true, + null, + null ); // Assert @@ -307,7 +318,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase true, Carbon::now()->subDays(2), Carbon::now()->subDay(), - true + true, + null, + null ); // Assert @@ -343,7 +356,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase false, null, null, - true + true, + null, + null ); // Assert @@ -408,6 +423,302 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase ], $result); } + public function test_aggregate_time_can_round_up_per_time_entry(): void + { + // Arrange + $client1 = Client::factory()->create(); + $client2 = Client::factory()->create(); + $project1 = Project::factory()->forClient($client1)->create(); + $project2 = Project::factory()->forClient($client2)->create(); + $project3 = Project::factory()->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450) + ->forProject($project1)->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449) + ->forProject($project1)->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 451) + ->forProject($project2)->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450) + ->forProject($project3) + ->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449) + ->create(); + $query = TimeEntry::query(); + + // Act + $result = $this->service->getAggregatedTimeEntries( + $query, + TimeEntryAggregationType::Client, + TimeEntryAggregationType::Project, + 'Europe/Vienna', + Weekday::Monday, + false, + null, + null, + true, + TimeEntryRoundingType::Up, + 15 + ); + + // Assert + $this->assertEqualsCanonicalizing([ + 'seconds' => 4500, + 'cost' => 0, + 'grouped_type' => 'client', + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 1800, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + [ + 'key' => $project3->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + [ + 'key' => $client1->getKey(), + 'seconds' => 1800, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => $project1->getKey(), + 'seconds' => 1800, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + [ + 'key' => $client2->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => $project2->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + ], + ], $result); + } + + public function test_aggregate_time_can_round_down_per_time_entry(): void + { + // Arrange + $client1 = Client::factory()->create(); + $client2 = Client::factory()->create(); + $project1 = Project::factory()->forClient($client1)->create(); + $project2 = Project::factory()->forClient($client2)->create(); + $project3 = Project::factory()->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 450) + ->forProject($project1)->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 449) + ->forProject($project1)->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 451) + ->forProject($project2)->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 900 + 450) + ->forProject($project3) + ->create(); + TimeEntry::factory()->endWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:01'), 900 + 449) + ->create(); + $query = TimeEntry::query(); + + // Act + $result = $this->service->getAggregatedTimeEntries( + $query, + TimeEntryAggregationType::Client, + TimeEntryAggregationType::Project, + 'Europe/Vienna', + Weekday::Monday, + false, + null, + null, + true, + TimeEntryRoundingType::Down, + 15 + ); + + // Assert + $this->assertEqualsCanonicalizing([ + 'seconds' => 1800, + 'cost' => 0, + 'grouped_type' => 'client', + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 1800, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + [ + 'key' => $project3->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + [ + 'key' => $client1->getKey(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => $project1->getKey(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + [ + 'key' => $client2->getKey(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => $project2->getKey(), + 'seconds' => 0, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + ], + ], $result); + } + + public function test_aggregate_time_can_round_to_nearest_per_time_entry(): void + { + // Arrange + $client1 = Client::factory()->create(); + $client2 = Client::factory()->create(); + $project1 = Project::factory()->forClient($client1)->create(); + $project2 = Project::factory()->forClient($client2)->create(); + $project3 = Project::factory()->create(); + TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 449) + ->forProject($project1)->create(); + TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450) + ->forProject($project1)->create(); + TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450) + ->forProject($project2)->create(); + TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450) + ->forProject($project3) + ->create(); + TimeEntry::factory()->startWithDuration(Carbon::createFromFormat('Y-m-d H:i:s', '2020-01-01 00:00:00'), 450) + ->create(); + $query = TimeEntry::query(); + + // Act + $result = $this->service->getAggregatedTimeEntries( + $query, + TimeEntryAggregationType::Client, + TimeEntryAggregationType::Project, + 'Europe/Vienna', + Weekday::Monday, + false, + null, + null, + true, + TimeEntryRoundingType::Nearest, + 15 + ); + + // Assert + $this->assertEqualsCanonicalizing([ + 'seconds' => 3600, + 'cost' => 0, + 'grouped_type' => 'client', + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 1800, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => null, + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + [ + 'key' => $project3->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + [ + 'key' => $client1->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => $project1->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + [ + 'key' => $client2->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => 'project', + 'grouped_data' => [ + [ + 'key' => $project2->getKey(), + 'seconds' => 900, + 'cost' => 0, + 'grouped_type' => null, + 'grouped_data' => null, + ], + ], + ], + ], + ], $result); + } + + // TODO: test with 1 minute + public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void { // Arrange @@ -432,7 +743,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase true, null, null, - true + true, + null, + null ); // Assert @@ -528,7 +841,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase false, null, null, - true + true, + null, + null, ); // Assert @@ -612,7 +927,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase false, null, null, - true + true, + null, + null, ); // Assert