mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-08-10 09:12:15 +01:00
Enhanced imports with billable rates and project members; Fixed error handling and import structure
This commit is contained in:
committed by
Constantin Graf
parent
7433648077
commit
a8ce994f08
@@ -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()
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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","",""
|
||||
|
||||
|
@@ -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,
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
[]
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
@@ -12,7 +12,7 @@
|
||||
"rate_last_updated": null,
|
||||
"role": "admin",
|
||||
"timezone": "Europe/Vienna",
|
||||
"uid": 0,
|
||||
"uid": 2001,
|
||||
"wid": 0,
|
||||
"working_hours_in_minutes": null
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user