diff --git a/app/Models/Project.php b/app/Models/Project.php index 48a78002..81c8e0bd 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -23,6 +23,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany; * @property-read Organization $organization * @property-read Client|null $client * @property-read Collection $tasks + * @property-read Collection $members * * @method Builder visibleByUser(User $user) * @method static ProjectFactory factory() diff --git a/app/Service/Import/Importers/ClockifyProjectsImporter.php b/app/Service/Import/Importers/ClockifyProjectsImporter.php index daa0f003..e4f09ed5 100644 --- a/app/Service/Import/Importers/ClockifyProjectsImporter.php +++ b/app/Service/Import/Importers/ClockifyProjectsImporter.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Service\Import\Importers; use Exception; +use Illuminate\Support\Str; use League\Csv\Exception as CsvException; use League\Csv\Reader; @@ -22,6 +23,7 @@ class ClockifyProjectsImporter extends DefaultImporter $reader->setDelimiter(','); $header = $reader->getHeader(); $this->validateHeader($header); + $billableRateKey = $this->getBillableRateKey($header); $records = $reader->getRecords(); foreach ($records as $record) { $clientId = null; @@ -32,18 +34,19 @@ class ClockifyProjectsImporter extends DefaultImporter ]); } $projectId = null; - if ($record['Name'] !== '') { + if ($record['Project'] !== '') { $projectId = $this->projectImportHelper->getKey([ - 'name' => $record['Name'], + 'name' => $record['Project'], 'organization_id' => $this->organization->id, ], [ 'client_id' => $clientId, 'color' => $this->colorService->getRandomColor(), + 'billable_rate' => $billableRateKey !== null && $record[$billableRateKey] !== '' ? (int) (((float) $record[$billableRateKey]) * 100) : null, ]); } - if ($record['Tasks'] !== '') { - $tasks = explode(', ', $record['Tasks']); + if ($record['Task'] !== '') { + $tasks = explode(', ', $record['Task']); foreach ($tasks as $task) { $this->taskImportHelper->getKey([ 'name' => $task, @@ -71,12 +74,12 @@ class ClockifyProjectsImporter extends DefaultImporter private function validateHeader(array $header): void { $requiredFields = [ - 'Name', + 'Project', 'Client', 'Status', 'Visibility', 'Billability', - 'Tasks', + 'Task', ]; foreach ($requiredFields as $requiredField) { if (! in_array($requiredField, $header, true)) { @@ -85,6 +88,22 @@ class ClockifyProjectsImporter extends DefaultImporter } } + /** + * @param array $header + */ + private function getBillableRateKey(array $header): ?string + { + $billableRateKey = null; + foreach ($header as $value) { + if (Str::startsWith($value, 'Billable Rate (')) { + $billableRateKey = $value; + break; + } + } + + return $billableRateKey; + } + #[\Override] public function getName(): string { diff --git a/app/Service/Import/Importers/DefaultImporter.php b/app/Service/Import/Importers/DefaultImporter.php index 8645ceda..98905a77 100644 --- a/app/Service/Import/Importers/DefaultImporter.php +++ b/app/Service/Import/Importers/DefaultImporter.php @@ -4,9 +4,11 @@ declare(strict_types=1); namespace App\Service\Import\Importers; +use App\Enums\Role; use App\Models\Client; use App\Models\Organization; use App\Models\Project; +use App\Models\ProjectMember; use App\Models\Tag; use App\Models\Task; use App\Models\User; @@ -50,6 +52,11 @@ abstract class DefaultImporter implements ImporterContract protected TimezoneService $timezoneService; + /** + * @var ImportDatabaseHelper + */ + protected ImportDatabaseHelper $projectMemberImportHelper; + public function init(Organization $organization): void { $this->organization = $organization; @@ -58,7 +65,7 @@ abstract class DefaultImporter implements ImporterContract return $builder->belongsToOrganization($this->organization); }, function (User $user) { $user->organizations()->attach($this->organization, [ - 'role' => 'placeholder', + 'role' => Role::Placeholder->value, ]); }, validate: [ 'name' => [ @@ -71,12 +78,26 @@ abstract class DefaultImporter implements ImporterContract ], ]); $this->projectImportHelper = new ImportDatabaseHelper(Project::class, ['name', 'organization_id'], true, function (Builder $builder) { + /** @var Builder $builder */ return $builder->where('organization_id', $this->organization->id); }, validate: [ 'name' => [ 'required', 'max:255', ], + 'billable_rate' => [ + 'nullable', + 'integer', + ], + ]); + $this->projectMemberImportHelper = new ImportDatabaseHelper(ProjectMember::class, ['project_id', 'user_id'], true, function (Builder $builder) { + /** @var Builder $builder */ + return $builder->whereBelongsToOrganization($this->organization); + }, validate: [ + 'billable_rate' => [ + 'nullable', + 'integer', + ], ]); $this->tagImportHelper = new ImportDatabaseHelper(Tag::class, ['name', 'organization_id'], true, function (Builder $builder) { return $builder->where('organization_id', $this->organization->id); diff --git a/app/Service/Import/Importers/TogglDataImporter.php b/app/Service/Import/Importers/TogglDataImporter.php index 07a4bb04..ac8b5e9e 100644 --- a/app/Service/Import/Importers/TogglDataImporter.php +++ b/app/Service/Import/Importers/TogglDataImporter.php @@ -28,24 +28,36 @@ class TogglDataImporter extends DefaultImporter $temporaryDirectory = TemporaryDirectory::make(); $zip->extractTo($temporaryDirectory->path()); $zip->close(); + if (! file_exists($temporaryDirectory->path('clients.json'))) { + throw new ImportException('File "clients.json" missing in ZIP'); + } $clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json')); if ($clientsFileContent === false) { - throw new ImportException('File clients.json missing in ZIP'); + throw new ImportException('File "clients.json" can not be opened'); } $clients = json_decode($clientsFileContent); + if (! file_exists($temporaryDirectory->path('projects.json'))) { + throw new ImportException('File "projects.json" missing in ZIP'); + } $projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json')); if ($projectsFileContent === false) { - throw new ImportException('File projects.json missing in ZIP'); + throw new ImportException('File "projects.json" can not be opened'); } $projects = json_decode($projectsFileContent); + if (! file_exists($temporaryDirectory->path('tags.json'))) { + throw new ImportException('File "tags.json" missing in ZIP'); + } $tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json')); if ($tagsFileContent === false) { - throw new ImportException('File tags.json missing in ZIP'); + throw new ImportException('File "tags.json" can not be opened'); } $tags = json_decode($tagsFileContent); + if (! file_exists($temporaryDirectory->path('workspace_users.json'))) { + throw new ImportException('File "workspace_users.json" missing in ZIP'); + } $workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json')); if ($workspaceUsersFileContent === false) { - throw new ImportException('File workspace_users.json missing in ZIP'); + throw new ImportException('File "workspace_users.json" can not be opened'); } $workspaceUsers = json_decode($workspaceUsersFileContent); foreach ($clients as $client) { @@ -61,6 +73,16 @@ class TogglDataImporter extends DefaultImporter ], [], (string) $tag->id); } + foreach ($workspaceUsers as $workspaceUser) { + $this->userImportHelper->getKey([ + 'email' => $workspaceUser->email, + ], [ + 'name' => $workspaceUser->name, + 'timezone' => $workspaceUser->timezone ?? 'UTC', + 'is_placeholder' => true, + ], (string) $workspaceUser->uid); + } + foreach ($projects as $project) { $clientId = null; if ($project->client_id !== null) { @@ -74,28 +96,40 @@ class TogglDataImporter extends DefaultImporter throw new ImportException('Invalid color'); } - $this->projectImportHelper->getKey([ + $projectId = $this->projectImportHelper->getKey([ 'name' => $project->name, 'organization_id' => $this->organization->getKey(), ], [ 'client_id' => $clientId, 'color' => $project->color, + 'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null, ], (string) $project->id); - } - foreach ($workspaceUsers as $workspaceUser) { - $this->userImportHelper->getKey([ - 'email' => $workspaceUser->email, - ], [ - 'name' => $workspaceUser->name, - 'timezone' => $workspaceUser->timezone ?? 'UTC', - 'is_placeholder' => true, - ], (string) $workspaceUser->id); + + if (! file_exists($temporaryDirectory->path('projects_users/'.$project->id.'.json'))) { + throw new ImportException('File "projects_users/'.$project->id.'.json" missing in ZIP'); + } + $projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$project->id.'.json')); + if ($projectMembersFileContent === false) { + throw new ImportException('File "projects_users/'.$project->id.'.json" can not be opened'); + } + $projectMembers = json_decode($projectMembersFileContent); + foreach ($projectMembers as $projectMember) { + $this->projectMemberImportHelper->getKey([ + 'project_id' => $projectId, + 'user_id' => $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id), + ], [ + 'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null, + ]); + } } $projectIds = $this->projectImportHelper->getExternalIds(); foreach ($projectIds as $projectIdExternal) { + if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) { + continue; + } $tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json')); if ($tasksFileContent === false) { - throw new ImportException('File tasks/'.$projectIdExternal.'.json missing in ZIP'); + throw new ImportException('File "tasks/'.$projectIdExternal.'.json" can not be opened'); } $tasks = json_decode($tasksFileContent); foreach ($tasks as $task) { diff --git a/storage/tests/clockify_projects_import_test_1.csv b/storage/tests/clockify_projects_import_test_1.csv index d8c018fb..cb979bab 100644 --- a/storage/tests/clockify_projects_import_test_1.csv +++ b/storage/tests/clockify_projects_import_test_1.csv @@ -1,3 +1,3 @@ -"Name","Client","Status","Visibility","Billability","Tasks","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Project members","Project manager","Note" -"Project for Big Company","Big Company","Active","Public","Yes","Task 1, Task 2, Task 3","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","","" +"Project","Client","Status","Visibility","Billability","Task","Tracked (h)","Estimated (h)","Remaining (h)","Overage (h)","Progress(%)","Billable (h)","Non-billable (h)","Billable Rate (USD)","Amount (USD)","Project members","Project manager","Note" +"Project for Big Company","Big Company","Active","Public","Yes","Task 1, Task 2, Task 3","0.00","","","","","0.00","0.00","100.01","0.00","Constantin Graf","","" "Project without Client","","Active","Public","Yes","","0.00","","","","","0.00","0.00","","0.00","Constantin Graf","","" diff --git a/storage/tests/toggl_data_import_test_1/projects.json b/storage/tests/toggl_data_import_test_1/projects.json index 7c28f2d7..f350a715 100644 --- a/storage/tests/toggl_data_import_test_1/projects.json +++ b/storage/tests/toggl_data_import_test_1/projects.json @@ -44,7 +44,7 @@ "id": 402, "is_private": true, "name": "Project for Big Company", - "rate": null, + "rate": 100.01, "rate_last_updated": null, "recurring": false, "recurring_parameters": null, diff --git a/storage/tests/toggl_data_import_test_1/projects_users/401.json b/storage/tests/toggl_data_import_test_1/projects_users/401.json new file mode 100644 index 00000000..fe51488c --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/projects_users/401.json @@ -0,0 +1 @@ +[] diff --git a/storage/tests/toggl_data_import_test_1/projects_users/402.json b/storage/tests/toggl_data_import_test_1/projects_users/402.json new file mode 100644 index 00000000..134ff705 --- /dev/null +++ b/storage/tests/toggl_data_import_test_1/projects_users/402.json @@ -0,0 +1,14 @@ +[ + { + "gid": null, + "group_id": null, + "id": 801, + "labour_cost": null, + "manager": true, + "project_id": 402, + "rate": 100.02, + "rate_last_updated": null, + "user_id": 2001, + "workspace_id": 0 + } +] diff --git a/storage/tests/toggl_data_import_test_1/workspace_users.json b/storage/tests/toggl_data_import_test_1/workspace_users.json index 356daa1f..edc14aaa 100644 --- a/storage/tests/toggl_data_import_test_1/workspace_users.json +++ b/storage/tests/toggl_data_import_test_1/workspace_users.json @@ -12,7 +12,7 @@ "rate_last_updated": null, "role": "admin", "timezone": "Europe/Vienna", - "uid": 0, + "uid": 2001, "wid": 0, "working_hours_in_minutes": null } diff --git a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php index e22ed3a9..a8ea1d3d 100644 --- a/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php +++ b/tests/Unit/Service/Import/Importer/ImporterTestAbstract.php @@ -19,7 +19,7 @@ class ImporterTestAbstract extends TestCase /** * @return object{user1: User, project1: Project, project2: Project, tag1: Tag, tag2: Tag} */ - protected function checkTestScenarioAfterImportExcludingTimeEntries(): object + protected function checkTestScenarioAfterImportExcludingTimeEntries(bool $detailed = false): object { $users = User::all(); $this->assertCount(2, $users); @@ -32,7 +32,7 @@ class ImporterTestAbstract extends TestCase $this->assertCount(1, $clients); $client1 = $clients->firstWhere('name', 'Big Company'); $this->assertNotNull($client1); - $projects = Project::all(); + $projects = Project::with(['members'])->get(); $this->assertCount(2, $projects); $project1 = $projects->firstWhere('name', 'Project without Client'); $this->assertNotNull($project1); @@ -40,6 +40,13 @@ class ImporterTestAbstract extends TestCase $project2 = $projects->firstWhere('name', 'Project for Big Company'); $this->assertNotNull($project2); $this->assertSame($client1->getKey(), $project2->client_id); + if ($detailed) { + $this->assertSame(10001, $project2->billable_rate); + $projectMembersOfProject2 = $project2->members; + $this->assertCount(1, $projectMembersOfProject2); + $this->assertSame($user1->getKey(), $projectMembersOfProject2->first()->user_id); + $this->assertSame(10002, $projectMembersOfProject2->first()->billable_rate); + } $tasks = Task::all(); $this->assertCount(1, $tasks); $task1 = $tasks->firstWhere('name', 'Task 1'); @@ -76,6 +83,7 @@ class ImporterTestAbstract extends TestCase $this->assertNull($project1->client_id); $project2 = $projects->firstWhere('name', 'Project for Big Company'); $this->assertNotNull($project2); + $this->assertSame(10001, $project2->billable_rate); $this->assertSame($client1->getKey(), $project2->client_id); $tasks = Task::all(); $this->assertCount(3, $tasks); diff --git a/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php b/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php index 06ebb5c5..139480c8 100644 --- a/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php +++ b/tests/Unit/Service/Import/Importer/TogglDataImporterTest.php @@ -62,7 +62,7 @@ class TogglDataImporterTest extends ImporterTestAbstract $report = $importer->getReport(); // Assert - $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $this->checkTestScenarioAfterImportExcludingTimeEntries(true); $this->assertSame(0, $report->timeEntriesCreated); $this->assertSame(2, $report->tagsCreated); $this->assertSame(1, $report->tasksCreated); @@ -88,7 +88,7 @@ class TogglDataImporterTest extends ImporterTestAbstract $report = $importer->getReport(); // Assert - $this->checkTestScenarioAfterImportExcludingTimeEntries(); + $this->checkTestScenarioAfterImportExcludingTimeEntries(true); $this->assertSame(0, $report->timeEntriesCreated); $this->assertSame(0, $report->tagsCreated); $this->assertSame(0, $report->tasksCreated);