mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-12 10:12:17 +01:00
Add rounding feature
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user