Enhanced imports with billable rates and project members; Fixed error handling and import structure

This commit is contained in:
Constantin Graf
2024-04-25 22:44:10 +02:00
committed by Constantin Graf
parent 7433648077
commit a8ce994f08
11 changed files with 128 additions and 30 deletions

View File

@@ -23,6 +23,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property-read Organization $organization
* @property-read Client|null $client
* @property-read Collection<int, Task> $tasks
* @property-read Collection<int, ProjectMember> $members
*
* @method Builder<Project> visibleByUser(User $user)
* @method static ProjectFactory factory()

View File

@@ -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<string> $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
{

View File

@@ -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<ProjectMember>
*/
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<Project> $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<ProjectMember> $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);

View File

@@ -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) {

View File

@@ -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","",""
1 Name Project Client Status Visibility Billability Tasks 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
2 Project for Big Company Project for Big Company Big Company Active Public Yes Task 1, Task 2, Task 3 Task 1, Task 2, Task 3 0.00 0.00 0.00 100.01 0.00 Constantin Graf
3 Project without Client Project without Client Active Public Yes 0.00 0.00 0.00 0.00 Constantin Graf

View File

@@ -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,

View File

@@ -0,0 +1 @@
[]

View File

@@ -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
}
]

View File

@@ -12,7 +12,7 @@
"rate_last_updated": null,
"role": "admin",
"timezone": "Europe/Vienna",
"uid": 0,
"uid": 2001,
"wid": 0,
"working_hours_in_minutes": null
}

View File

@@ -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);

View File

@@ -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);