mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-16 04:02:15 +01:00
Add rounding feature
This commit is contained in:
16
app/Enums/TimeEntryRoundingType.php
Normal file
16
app/Enums/TimeEntryRoundingType.php
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use Datomatic\LaravelEnumHelper\LaravelEnumHelper;
|
||||||
|
|
||||||
|
enum TimeEntryRoundingType: string
|
||||||
|
{
|
||||||
|
use LaravelEnumHelper;
|
||||||
|
|
||||||
|
case Up = 'up';
|
||||||
|
case Down = 'down';
|
||||||
|
case Nearest = 'nearest';
|
||||||
|
}
|
||||||
@@ -73,7 +73,9 @@ class ReportController extends Controller
|
|||||||
false,
|
false,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
true
|
true,
|
||||||
|
$report->properties->roundingType,
|
||||||
|
$report->properties->roundingMinutes,
|
||||||
);
|
);
|
||||||
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$historyData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesQuery->clone(),
|
$timeEntriesQuery->clone(),
|
||||||
@@ -84,7 +86,9 @@ class ReportController extends Controller
|
|||||||
true,
|
true,
|
||||||
$report->properties->start,
|
$report->properties->start,
|
||||||
$report->properties->end,
|
$report->properties->end,
|
||||||
true
|
true,
|
||||||
|
$report->properties->roundingType,
|
||||||
|
$report->properties->roundingMinutes,
|
||||||
);
|
);
|
||||||
|
|
||||||
return new DetailedWithDataReportResource($report, $data, $historyData);
|
return new DetailedWithDataReportResource($report, $data, $historyData);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ use App\Service\ReportExport\TimeEntriesDetailedExport;
|
|||||||
use App\Service\ReportExport\TimeEntriesReportExport;
|
use App\Service\ReportExport\TimeEntriesReportExport;
|
||||||
use App\Service\TimeEntryAggregationService;
|
use App\Service\TimeEntryAggregationService;
|
||||||
use App\Service\TimeEntryFilter;
|
use App\Service\TimeEntryFilter;
|
||||||
|
use App\Service\TimeEntryService;
|
||||||
use App\Service\TimezoneService;
|
use App\Service\TimezoneService;
|
||||||
use Gotenberg\Exceptions\GotenbergApiErrored;
|
use Gotenberg\Exceptions\GotenbergApiErrored;
|
||||||
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
use Gotenberg\Exceptions\NoOutputFileInResponse;
|
||||||
@@ -47,6 +48,7 @@ use Illuminate\Http\Resources\Json\JsonResource;
|
|||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Illuminate\Support\Facades\Auth;
|
use Illuminate\Support\Facades\Auth;
|
||||||
use Illuminate\Support\Facades\Blade;
|
use Illuminate\Support\Facades\Blade;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Log;
|
use Illuminate\Support\Facades\Log;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
use Maatwebsite\Excel\Facades\Excel;
|
use Maatwebsite\Excel\Facades\Excel;
|
||||||
@@ -140,8 +142,15 @@ class TimeEntryController extends Controller
|
|||||||
*/
|
*/
|
||||||
private function getTimeEntriesQuery(Organization $organization, TimeEntryIndexRequest|TimeEntryIndexExportRequest $request, ?Member $member): Builder
|
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()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization')
|
->whereBelongsTo($organization, 'organization')
|
||||||
|
->select($select)
|
||||||
->orderBy('start', 'desc');
|
->orderBy('start', 'desc');
|
||||||
|
|
||||||
$filter = new TimeEntryFilter($timeEntriesQuery);
|
$filter = new TimeEntryFilter($timeEntriesQuery);
|
||||||
@@ -183,6 +192,8 @@ class TimeEntryController extends Controller
|
|||||||
$user = $this->user();
|
$user = $this->user();
|
||||||
$timezone = $user->timezone;
|
$timezone = $user->timezone;
|
||||||
$showBillableRate = $this->member($organization)->role !== Role::Employee->value || $organization->employees_can_see_billable_rates;
|
$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 = $this->getTimeEntriesQuery($organization, $request, $member);
|
||||||
$timeEntriesQuery->with([
|
$timeEntriesQuery->with([
|
||||||
@@ -207,8 +218,9 @@ class TimeEntryController extends Controller
|
|||||||
if ($viewFile === false) {
|
if ($viewFile === false) {
|
||||||
throw new \LogicException('View file not found');
|
throw new \LogicException('View file not found');
|
||||||
}
|
}
|
||||||
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesQuery->clone()->reorder()->withOnly([]),
|
$timeEntriesAggregateQuery,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
$user->timezone,
|
$user->timezone,
|
||||||
@@ -216,7 +228,9 @@ class TimeEntryController extends Controller
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
$showBillableRate
|
$showBillableRate,
|
||||||
|
$roundingType,
|
||||||
|
$roundingMinutes,
|
||||||
);
|
);
|
||||||
$html = Blade::render($viewFile, [
|
$html = Blade::render($viewFile, [
|
||||||
'timeEntries' => $timeEntriesQuery->get(),
|
'timeEntries' => $timeEntriesQuery->get(),
|
||||||
@@ -324,6 +338,8 @@ class TimeEntryController extends Controller
|
|||||||
$group1Type = $request->getGroup();
|
$group1Type = $request->getGroup();
|
||||||
$group2Type = $request->getSubGroup();
|
$group2Type = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
|
$roundingType = $request->getRoundingType();
|
||||||
|
$roundingMinutes = $request->getRoundingMinutes();
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery,
|
$timeEntriesAggregateQuery,
|
||||||
@@ -334,7 +350,9 @@ class TimeEntryController extends Controller
|
|||||||
$request->getFillGapsInTimeGroups(),
|
$request->getFillGapsInTimeGroups(),
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate
|
$showBillableRate,
|
||||||
|
$roundingType,
|
||||||
|
$roundingMinutes
|
||||||
);
|
);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
@@ -373,6 +391,8 @@ class TimeEntryController extends Controller
|
|||||||
$group = $request->getGroup();
|
$group = $request->getGroup();
|
||||||
$subGroup = $request->getSubGroup();
|
$subGroup = $request->getSubGroup();
|
||||||
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
$timeEntriesAggregateQuery = $this->getTimeEntriesAggregateQuery($organization, $request, $member);
|
||||||
|
$roundingType = $request->getRoundingType();
|
||||||
|
$roundingMinutes = $request->getRoundingMinutes();
|
||||||
|
|
||||||
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
$aggregatedData = $timeEntryAggregationService->getAggregatedTimeEntriesWithDescriptions(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
@@ -383,7 +403,9 @@ class TimeEntryController extends Controller
|
|||||||
false,
|
false,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate
|
$showBillableRate,
|
||||||
|
$roundingType,
|
||||||
|
$roundingMinutes
|
||||||
);
|
);
|
||||||
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
$dataHistoryChart = $timeEntryAggregationService->getAggregatedTimeEntries(
|
||||||
$timeEntriesAggregateQuery->clone(),
|
$timeEntriesAggregateQuery->clone(),
|
||||||
@@ -394,7 +416,9 @@ class TimeEntryController extends Controller
|
|||||||
true,
|
true,
|
||||||
$request->getStart(),
|
$request->getStart(),
|
||||||
$request->getEnd(),
|
$request->getEnd(),
|
||||||
$showBillableRate
|
$showBillableRate,
|
||||||
|
$roundingType,
|
||||||
|
$roundingMinutes
|
||||||
);
|
);
|
||||||
$currency = $organization->currency;
|
$currency = $organization->currency;
|
||||||
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
$timezone = app(TimezoneService::class)->getTimezoneFromUser($this->user());
|
||||||
@@ -477,7 +501,7 @@ class TimeEntryController extends Controller
|
|||||||
/**
|
/**
|
||||||
* @return Builder<TimeEntry>
|
* @return Builder<TimeEntry>
|
||||||
*/
|
*/
|
||||||
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()
|
$timeEntriesQuery = TimeEntry::query()
|
||||||
->whereBelongsTo($organization, 'organization');
|
->whereBelongsTo($organization, 'organization');
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace App\Http\Requests\V1\TimeEntry;
|
|||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -164,6 +165,18 @@ class TimeEntryAggregateExportRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'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'));
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -146,6 +147,18 @@ class TimeEntryAggregateRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'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;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
use App\Enums\ExportFormat;
|
use App\Enums\ExportFormat;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
use App\Models\Organization;
|
use App\Models\Organization;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -133,6 +134,18 @@ class TimeEntryIndexExportRequest extends TimeEntryIndexRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'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'));
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace App\Http\Requests\V1\TimeEntry;
|
namespace App\Http\Requests\V1\TimeEntry;
|
||||||
|
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Http\Requests\V1\BaseFormRequest;
|
use App\Http\Requests\V1\BaseFormRequest;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Member;
|
use App\Models\Member;
|
||||||
@@ -11,8 +12,11 @@ use App\Models\Organization;
|
|||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
use App\Models\Tag;
|
use App\Models\Tag;
|
||||||
use App\Models\Task;
|
use App\Models\Task;
|
||||||
|
use Illuminate\Contracts\Validation\Rule as RuleContract;
|
||||||
use Illuminate\Contracts\Validation\ValidationRule;
|
use Illuminate\Contracts\Validation\ValidationRule;
|
||||||
use Illuminate\Database\Eloquent\Builder;
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
use Korridor\LaravelModelValidationRules\Rules\ExistsEloquent;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,7 +27,7 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
/**
|
/**
|
||||||
* Get the validation rules that apply to the request.
|
* Get the validation rules that apply to the request.
|
||||||
*
|
*
|
||||||
* @return array<string, array<string|ValidationRule>>
|
* @return array<string, array<string|ValidationRule|RuleContract>>
|
||||||
*/
|
*/
|
||||||
public function rules(): array
|
public function rules(): array
|
||||||
{
|
{
|
||||||
@@ -136,6 +140,18 @@ class TimeEntryIndexRequest extends BaseFormRequest
|
|||||||
'string',
|
'string',
|
||||||
'in:true,false',
|
'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;
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -77,6 +77,26 @@ class TimeEntry extends Model implements AuditableContract
|
|||||||
'still_active_email_sent_at' => 'datetime',
|
'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)
|
* The attributes that are computed. (f.e. for performance reasons)
|
||||||
* These attributes can be regenerated at any time.
|
* These attributes can be regenerated at any time.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Service\Dto;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use Illuminate\Contracts\Database\Eloquent\Castable;
|
use Illuminate\Contracts\Database\Eloquent\Castable;
|
||||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||||
@@ -59,6 +60,10 @@ class ReportPropertiesDto implements Castable
|
|||||||
*/
|
*/
|
||||||
public ?Collection $taskIds = null;
|
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.
|
* 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->historyGroup = TimeEntryAggregationTypeInterval::from($data->historyGroup);
|
||||||
$dto->weekStart = Weekday::from($data->weekStart);
|
$dto->weekStart = Weekday::from($data->weekStart);
|
||||||
$dto->timezone = $data->timezone;
|
$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;
|
return $dto;
|
||||||
}
|
}
|
||||||
@@ -140,6 +149,8 @@ class ReportPropertiesDto implements Castable
|
|||||||
'historyGroup' => $value->historyGroup->value,
|
'historyGroup' => $value->historyGroup->value,
|
||||||
'weekStart' => $value->weekStart->value,
|
'weekStart' => $value->weekStart->value,
|
||||||
'timezone' => $value->timezone,
|
'timezone' => $value->timezone,
|
||||||
|
'roundingType' => $value->roundingType?->value,
|
||||||
|
'roundingMinutes' => $value->roundingMinutes,
|
||||||
];
|
];
|
||||||
|
|
||||||
$jsonString = json_encode($data);
|
$jsonString = json_encode($data);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ namespace App\Service;
|
|||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -41,7 +42,7 @@ class TimeEntryAggregationService
|
|||||||
* cost: int|null
|
* 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;
|
$fillGapsInTimeGroupsIsPossible = $fillGapsInTimeGroups && $start !== null && $end !== null;
|
||||||
$group1Select = 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(
|
$timeEntriesQuery->selectRaw(
|
||||||
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
($group1Select !== null ? $group1Select.' as group_1,' : '').
|
||||||
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
($group2Select !== null ? $group2Select.' as group_2,' : '').
|
||||||
' round(sum(extract(epoch from (coalesce("end", now()) - start)))) as aggregate,'.
|
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')))) as aggregate,'.
|
||||||
' round(
|
' round(sum(extract(epoch from ('.$endRawSelect.' - '.$startRawSelect.')) * (coalesce(billable_rate, 0)::float/60/60))) as cost'
|
||||||
sum(
|
|
||||||
extract(epoch from (coalesce("end", now()) - start)) * (coalesce(billable_rate, 0)::float/60/60)
|
|
||||||
)
|
|
||||||
) as cost'
|
|
||||||
);
|
);
|
||||||
if ($groupBy !== null) {
|
if ($groupBy !== null) {
|
||||||
$timeEntriesQuery->groupBy($groupBy);
|
$timeEntriesQuery->groupBy($groupBy);
|
||||||
@@ -164,9 +164,9 @@ class TimeEntryAggregationService
|
|||||||
* cost: int|null
|
* 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 = [];
|
$keysGroup1 = [];
|
||||||
$keysGroup2 = [];
|
$keysGroup2 = [];
|
||||||
|
|||||||
42
app/Service/TimeEntryService.php
Normal file
42
app/Service/TimeEntryService.php
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Service;
|
||||||
|
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
|
use Illuminate\Support\Carbon;
|
||||||
|
use LogicException;
|
||||||
|
|
||||||
|
class TimeEntryService
|
||||||
|
{
|
||||||
|
public function getStartSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||||
|
{
|
||||||
|
if ($roundingType === null || $roundingMinutes === null) {
|
||||||
|
return 'start';
|
||||||
|
}
|
||||||
|
if ($roundingMinutes < 1) {
|
||||||
|
throw new LogicException('Rounding minutes must be greater than 0');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'date_bin(\'1 minutes\', start, TIMESTAMP \'1970-01-01\')';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getEndSelectRawForRounding(?TimeEntryRoundingType $roundingType, ?int $roundingMinutes): string
|
||||||
|
{
|
||||||
|
if ($roundingType === null || $roundingMinutes === null) {
|
||||||
|
return 'coalesce("end", \''.Carbon::now()->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).')';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
public function start(Carbon $start): self
|
||||||
{
|
{
|
||||||
return $this->state(function (array $attributes) use ($start): array {
|
return $this->state(function (array $attributes) use ($start): array {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use App\Enums\ExportFormat;
|
|||||||
use App\Enums\Role;
|
use App\Enums\Role;
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
use App\Enums\TimeEntryAggregationTypeInterval;
|
use App\Enums\TimeEntryAggregationTypeInterval;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
use App\Exceptions\Api\TimeEntryCanNotBeRestartedApiException;
|
||||||
use App\Http\Controllers\Api\V1\TimeEntryController;
|
use App\Http\Controllers\Api\V1\TimeEntryController;
|
||||||
use App\Jobs\RecalculateSpentTimeForProject;
|
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
|
public function test_index_endpoint_after_filter_returns_time_entries_after_date(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ declare(strict_types=1);
|
|||||||
namespace Tests\Unit\Service;
|
namespace Tests\Unit\Service;
|
||||||
|
|
||||||
use App\Enums\TimeEntryAggregationType;
|
use App\Enums\TimeEntryAggregationType;
|
||||||
|
use App\Enums\TimeEntryRoundingType;
|
||||||
use App\Enums\Weekday;
|
use App\Enums\Weekday;
|
||||||
use App\Models\Client;
|
use App\Models\Client;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
@@ -40,7 +41,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -87,7 +90,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
Carbon::now()->subDays(2)->utc(),
|
Carbon::now()->subDays(2)->utc(),
|
||||||
Carbon::now()->subDay()->utc(),
|
Carbon::now()->subDay()->utc(),
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -172,7 +177,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
Carbon::now()->subDays(2)->utc(),
|
Carbon::now()->subDays(2)->utc(),
|
||||||
Carbon::now()->subDay()->utc(),
|
Carbon::now()->subDay()->utc(),
|
||||||
false
|
false,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -238,7 +245,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2)->utc(),
|
Carbon::now()->subDays(2)->utc(),
|
||||||
Carbon::now()->subDay()->utc(),
|
Carbon::now()->subDay()->utc(),
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -280,7 +289,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2),
|
Carbon::now()->subDays(2),
|
||||||
Carbon::now()->subDay(),
|
Carbon::now()->subDay(),
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -307,7 +318,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
Carbon::now()->subDays(2),
|
Carbon::now()->subDays(2),
|
||||||
Carbon::now()->subDay(),
|
Carbon::now()->subDay(),
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -343,7 +356,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -408,6 +423,302 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
], $result);
|
], $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
|
public function test_aggregate_time_entries_by_client_and_project_with_filled_gaps(): void
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
@@ -432,7 +743,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
true,
|
true,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -528,7 +841,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
@@ -612,7 +927,9 @@ class TimeEntryAggregationServiceTest extends TestCaseWithDatabase
|
|||||||
false,
|
false,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
true
|
true,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
|
|||||||
Reference in New Issue
Block a user