Add harvest and generic imports

This commit is contained in:
Constantin Graf
2025-02-24 15:21:24 -05:00
committed by Constantin Graf
parent 9faa8fe6e1
commit f93c5370bf
22 changed files with 1120 additions and 3 deletions

View File

@@ -33,6 +33,11 @@ class ColorService
private const string VALID_REGEX = '/^#[0-9a-f]{6}$/';
public function isBuiltInColor(string $color): bool
{
return in_array($color, self::COLORS, true);
}
public function getRandomColor(?string $seed = null): string
{
if ($seed !== null) {

View File

@@ -11,6 +11,7 @@ use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
@@ -23,7 +24,7 @@ class ClockifyTimeEntriesImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(', ', $tags);

View File

@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Service\ColorService;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class GenericProjectsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'name',
];
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
if (isset($record['client']) && $record['client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['client'],
'organization_id' => $this->organization->id,
]);
}
if ($record['name'] !== '') {
$archivedAt = null;
if (isset($record['archived_at']) && $record['archived_at'] !== '') {
try {
$archivedAt = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['archived_at'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of archived_at ("'.$record['archived_at'].'") is invalid');
}
}
$this->projectImportHelper->getKey([
'name' => $record['name'],
'organization_id' => $this->organization->id,
], [
'color' => isset($record['color']) && $record['color'] !== '' ? $record['color'] : app(ColorService::class)->getRandomColor(),
'billable_rate' => isset($record['billable_rate']) && $record['billable_rate'] !== '' ? (int) $record['billable_rate'] : null,
'is_public' => isset($record['is_public']) && $record['is_public'] === 'true',
'client_id' => $clientId,
'is_billable' => isset($record['billable_default']) && $record['billable_default'] === 'true',
'estimated_time' => isset($record['estimated_time']) && $record['estimated_time'] !== '' && is_numeric($record['estimated_time']) && ((int) $record['estimated_time'] !== 0) ? (int) $record['estimated_time'] : null,
'archived_at' => $archivedAt,
]);
}
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.generic_projects.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.generic_projects.description');
}
}

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class GenericTimeEntriesImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'description',
'billable',
'client',
'project',
'tags',
'start',
'end',
'task',
'user_name',
'user_email',
];
/**
* @return array<string>
*
* @throws ImportException
*/
private function getTags(string $tags): array
{
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(',', $tags);
$tagIds = [];
foreach ($tagsParsed as $tagParsed) {
$tagId = $this->tagImportHelper->getKey([
'name' => Str::trim($tagParsed),
'organization_id' => $this->organization->id,
]);
$tagIds[] = $tagId;
}
return $tagIds;
}
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$userId = $this->userImportHelper->getKey([
'email' => $record['user_email'],
], [
'name' => $record['user_name'],
'timezone' => 'UTC',
'is_placeholder' => true,
]);
$memberId = $this->memberImportHelper->getKey([
'user_id' => $userId,
'organization_id' => $this->organization->getKey(),
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'is_billable' => false,
'color' => $this->colorService->getRandomColor(),
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($record['task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
$timeEntry->description = $record['description'];
if (! in_array($record['billable'], ['true', 'false'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['billable'] === 'true';
$timeEntry->tags = $this->getTags($record['tags']);
$timeEntry->is_imported = true;
try {
$start = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['start'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
}
if ($start === null) {
throw new ImportException('Value of start ("'.$record['start'].'") is invalid');
}
$timeEntry->start = $start->utc();
try {
$end = Carbon::createFromFormat('Y-m-d\TH:i:s\Z', $record['end'], 'UTC');
} catch (InvalidFormatException) {
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
}
if ($end === null) {
throw new ImportException('Value of end ("'.$record['end'].'") is invalid');
}
$timeEntry->end = $end->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.generic_time_entries.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.generic_time_entries.description');
}
}

View File

@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use Exception;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
class HarvestClientsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Client Name',
];
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$this->clientImportHelper->getKey([
'name' => $record['Client Name'],
'organization_id' => $this->organization->id,
]);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.harvest_clients.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.harvest_clients.description');
}
}

View File

@@ -0,0 +1,107 @@
<?php
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;
class HarvestProjectsImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Client',
'Project',
'Budget',
'Billable Hours',
];
/**
* @throws ImportException
*/
#[\Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
if ($record['Project'] !== '') {
if (! isset($record['Budget']) || ! is_string($record['Budget'])) {
throw new ImportException('The value for "Budget" is invalid');
}
$estimatedTimeField = Str::replace(',', '.', $record['Budget']);
$estimatedTime = $estimatedTimeField !== '' && is_numeric($estimatedTimeField) ? (int) (((float) $estimatedTimeField) * 60 * 60) : null;
if ($estimatedTime === 0) {
$estimatedTime = null;
}
if (! isset($record['Billable Hours']) || ! is_string($record['Billable Hours'])) {
throw new ImportException('The value for "Billable Hours" is invalid');
}
$billableHoursField = Str::replace(',', '.', $record['Billable Hours']);
$billableHours = $billableHoursField !== '' && is_numeric($billableHoursField) ? (int) ((float) $billableHoursField) : null;
$this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'color' => $this->colorService->getRandomColor(),
'client_id' => $clientId,
'estimated_time' => $estimatedTime,
'is_billable' => $billableHours > 0,
]);
}
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[\Override]
public function getName(): string
{
return __('importer.harvest_projects.name');
}
#[\Override]
public function getDescription(): string
{
return __('importer.harvest_projects.description');
}
}

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Jobs\RecalculateSpentTimeForProject;
use App\Jobs\RecalculateSpentTimeForTask;
use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
use Override;
class HarvestTimeEntriesImporter extends DefaultImporter
{
/**
* @var array<string>
*/
private const array REQUIRED_FIELDS = [
'Date',
'Hours',
'Client',
'Project',
'Task',
'Billable?',
'First Name',
'Last Name',
'Notes',
];
/**
* @throws ImportException
*/
#[Override]
public function importData(string $data, string $timezone): void
{
try {
$reader = Reader::createFromString($data);
$reader->setHeaderOffset(0);
$reader->setDelimiter(',');
$reader->setEnclosure('"');
$reader->setEscape('');
$header = $reader->getHeader();
$this->validateHeader($header);
$records = $reader->getRecords();
foreach ($records as $record) {
$firstname = $record['First Name'];
$lastname = $record['Last Name'];
$userId = $this->userImportHelper->getKey([
'email' => Str::slug($firstname).'.'.Str::slug($lastname).'@solidtime-import.test',
], [
'name' => $firstname.' '.$lastname,
'timezone' => 'UTC',
'is_placeholder' => true,
]);
$memberId = $this->memberImportHelper->getKey([
'user_id' => $userId,
'organization_id' => $this->organization->getKey(),
], [
'role' => Role::Placeholder->value,
]);
$member = $this->memberImportHelper->getModelById($memberId);
$clientId = null;
if ($record['Client'] !== '') {
$clientId = $this->clientImportHelper->getKey([
'name' => $record['Client'],
'organization_id' => $this->organization->id,
]);
}
$projectId = null;
$project = null;
$projectMember = null;
if ($record['Project'] !== '') {
$projectId = $this->projectImportHelper->getKey([
'name' => $record['Project'],
'organization_id' => $this->organization->id,
], [
'client_id' => $clientId,
'color' => $this->colorService->getRandomColor(),
'is_billable' => true,
]);
$project = $this->projectImportHelper->getModelById($projectId);
$projectMember = $this->projectMemberImportHelper->getModel([
'project_id' => $projectId,
'member_id' => $memberId,
]);
}
$taskId = null;
if ($record['Task'] !== '') {
$taskId = $this->taskImportHelper->getKey([
'name' => $record['Task'],
'project_id' => $projectId,
'organization_id' => $this->organization->id,
]);
$this->taskImportHelper->getModelById($taskId);
}
$timeEntry = new TimeEntry;
$timeEntry->disableAuditing();
$timeEntry->user_id = $userId;
$timeEntry->member_id = $memberId;
$timeEntry->task_id = $taskId;
$timeEntry->project_id = $projectId;
$timeEntry->client_id = $clientId;
$timeEntry->organization_id = $this->organization->id;
if (strlen($record['Notes']) > 500) {
throw new ImportException('Time entry note is too long');
}
$timeEntry->description = $record['Notes'];
if (! in_array($record['Billable?'], ['Yes', 'No'], true)) {
throw new ImportException('Invalid billable value');
}
$timeEntry->billable = $record['Billable?'] === 'Yes';
$timeEntry->tags = [];
$timeEntry->is_imported = true;
// Start & End
try {
$date = Carbon::createFromFormat('Y-m-d', $record['Date'], $timezone);
} catch (InvalidFormatException) {
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
}
if ($date === null) {
throw new ImportException('Date ("'.$record['Date'].'") is invalid');
}
if (! isset($record['Hours']) || ! is_string($record['Hours'])) {
throw new ImportException('Hours ("'.($record['Hours'] ?? '<null>').'") is invalid');
}
$hoursField = Str::replace(',', '.', $record['Hours']);
if (! is_numeric($hoursField)) {
throw new ImportException('Hours ("'.$record['Hours'].'") is invalid');
}
$hours = (float) $hoursField;
$timeEntry->start = $date->copy()->startOfDay()->utc();
$timeEntry->end = $date->copy()->startOfDay()->addHours($hours)->utc();
$timeEntry->billable_rate = $this->billableRateService->getBillableRateForTimeEntryWithGivenRelations(
$timeEntry,
$projectMember,
$project,
$member,
$this->organization
);
$timeEntry->save();
$this->timeEntriesCreated++;
}
foreach ($this->projectImportHelper->getCachedModels() as $usedProject) {
RecalculateSpentTimeForProject::dispatch($usedProject);
}
foreach ($this->taskImportHelper->getCachedModels() as $usedTask) {
RecalculateSpentTimeForTask::dispatch($usedTask);
}
} catch (ImportException $exception) {
throw $exception;
} catch (CsvException $exception) {
throw new ImportException('Invalid CSV data');
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
}
}
/**
* @param array<string> $header
*
* @throws ImportException
*/
private function validateHeader(array $header): void
{
foreach (self::REQUIRED_FIELDS as $requiredField) {
if (! in_array($requiredField, $header, true)) {
throw new ImportException('Invalid CSV header, missing field: '.$requiredField);
}
}
}
#[Override]
public function getName(): string
{
return __('importer.harvest_time_entries.name');
}
#[Override]
public function getDescription(): string
{
return __('importer.harvest_time_entries.description');
}
}

View File

@@ -15,6 +15,11 @@ class ImporterProvider
'clockify_time_entries' => ClockifyTimeEntriesImporter::class,
'clockify_projects' => ClockifyProjectsImporter::class,
'solidtime' => SolidtimeImporter::class,
'harvest_projects' => HarvestProjectsImporter::class,
'harvest_time_entries' => HarvestTimeEntriesImporter::class,
'harvest_clients' => HarvestClientsImporter::class,
'generic_projects' => GenericProjectsImporter::class,
'generic_time_entries' => GenericTimeEntriesImporter::class,
];
/**

View File

@@ -328,7 +328,7 @@ class SolidtimeImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = json_decode($tags);

View File

@@ -11,6 +11,7 @@ use App\Models\TimeEntry;
use Carbon\Exceptions\InvalidFormatException;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Str;
use League\Csv\Exception as CsvException;
use League\Csv\Reader;
@@ -23,7 +24,7 @@ class TogglTimeEntriesImporter extends DefaultImporter
*/
private function getTags(string $tags): array
{
if (trim($tags) === '') {
if (Str::trim($tags) === '') {
return [];
}
$tagsParsed = explode(', ', $tags);

View File

@@ -13,6 +13,14 @@ return [
'<br> 4. Now click Export -> Save as CSV. The Export dropdown is in the header of the export table left of the printer symbol. '.
'<br><br>Before you import make sure that the Timezone settings in Clockify are the same as in solidtime.',
],
'generic_project' => [
'name' => 'Generic Projects',
'description' => 'If you want to import many projects yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
],
'generic_time_entries' => [
'name' => 'Generic Time Entries',
'description' => 'If you want to import many time entries yourself this importer the right choice. Please see our docs for <a href="https://docs.solidtime.io/user-guide/import">more information about the CSV structure</a>',
],
'clockify_projects' => [
'name' => 'Clockify Projects',
'description' => '1. Make sure to set the language of Clockify to English in "Preferences -> General".<br>'.
@@ -38,4 +46,22 @@ return [
'name' => 'Solidtime',
'description' => '1. Choose the organization you want to export in dropdown in the left top corner<br>2. Click on "Export" in the left navigation under "Admin" (You need to be Admin or Owner of the organization to see this)<br>3. Click on "Export". <br>4. Save the file and upload it here.',
],
'harvest_clients' => [
'name' => 'Harvest Clients',
'description' => '1. Go to "Manage" (top navigation)<br>2. Click on the "Clients"'.
'<br>3. Click on "Import/Export" and in the dropdown "Export clients to CSV" '.
'<br>',
],
'harvest_projects' => [
'name' => 'Harvest Projects',
'description' => '1. Go to "Projects" (top navigation)<br>2. Click on the "Export" button'.
'<br>3. Select which projects you would like to export and select CSV format '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
],
'harvest_time_entries' => [
'name' => 'Harvest Time Entries',
'description' => '1. Go to Settings (right top corner)<br>2. Click on "Import/Export" in the left navigation'.
'<br>3. Now click on "Export all time" '.
'<br><br>Before you import make sure that the Timezone settings in Harvest are the same as in solidtime.',
],
];

View File

@@ -0,0 +1,4 @@
name,color,billable_rate,is_public,client,billable_default,estimated_time,archived_at
"Project for Big Company",,10001,false,"Big Company",true,,
"Project without Client",#ef5350,,false,,false,1000,
"Project (Archived)",#6a407f,,true,"Some client",true,0,2024-08-25T10:00:00Z
1 name color billable_rate is_public client billable_default estimated_time archived_at
2 Project for Big Company 10001 false Big Company true
3 Project without Client #ef5350 false false 1000
4 Project (Archived) #6a407f true Some client true 0 2024-08-25T10:00:00Z

View File

@@ -0,0 +1,3 @@
description,billable,client,project,tags,start,end,task,user_name,user_email
"","false","","Project without Client","Development, Backend","2024-03-04T09:23:52Z","2024-03-04T09:23:52Z","","Peter Tester","peter.test@email.test"
"Working hard","true","Big Company","Project for Big Company","","2024-03-04T09:23:00Z","2024-03-04T10:23:01Z","Task 1","Peter Tester","peter.test@email.test"
1 description billable client project tags start end task user_name user_email
2 false Project without Client Development, Backend 2024-03-04T09:23:52Z 2024-03-04T09:23:52Z Peter Tester peter.test@email.test
3 Working hard true Big Company Project for Big Company 2024-03-04T09:23:00Z 2024-03-04T10:23:01Z Task 1 Peter Tester peter.test@email.test

View File

@@ -0,0 +1,3 @@
Client Name,Address
Example Client,""
"\\ 🔥 Special characters """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\",""
1 Client Name Address
2 Example Client
3 \\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\

View File

@@ -0,0 +1,3 @@
Client,Project,Project Code,Start Date,End Date,Project Notes,Total Hours,Billable Hours,Billable Amount,Budget By,Budget,Budget Spent,Budget Remaining,Total Costs,Team Costs,Expenses
Example Client,Example Project,,"","",This is an example project to help you trial Harvest. You can track time to this project and see what insights you can get from our reports! Feel free to make any edits you want to this project or even delete it.,"20,01","20,01","2.001,0",Hours,"50,0","20,01","29,99","0,0","0,0","0,0"
"\\ 🔥 Special characters client """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\","\\ 🔥 Special characters project """"""`!@#$%^&*()_+\-=\[\]{};':""\\|,.''<>\/?~ \\\",,"","",,"0,0","0,0","0,0",Hours,"0,0","0,0","50,0","0,0","0,0","0,0"
1 Client Project Project Code Start Date End Date Project Notes Total Hours Billable Hours Billable Amount Budget By Budget Budget Spent Budget Remaining Total Costs Team Costs Expenses
2 Example Client Example Project This is an example project to help you trial Harvest. You can track time to this project and see what insights you can get from our reports! Feel free to make any edits you want to this project or even delete it. 20,01 20,01 2.001,0 Hours 50,0 20,01 29,99 0,0 0,0 0,0
3 \\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\ \\ 🔥 Special characters project """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\ 0,0 0,0 0,0 Hours 0,0 0,0 50,0 0,0 0,0 0,0

View File

@@ -0,0 +1,3 @@
Date,Client,Project,Project Code,Task,Notes,Hours,Billable?,Invoiced?,Approved?,First Name,Last Name,Roles,Employee?,Billable Rate,Billable Amount,Cost Rate,Cost Amount,Currency,External Reference URL
2024-03-04,,Project without Client,,,"","20,0",No,No,No,Peter,Tester,,Yes,"100,0","2.000,0","0,0","0,0",Euro - EUR,
2024-03-04,Big Company,Project for Big Company,,Task 1,Working hard,"0,01",Yes,No,No,Peter,Tester,,Yes,"100,0","1,0","0,0","0,0",Euro - EUR,
1 Date Client Project Project Code Task Notes Hours Billable? Invoiced? Approved? First Name Last Name Roles Employee? Billable Rate Billable Amount Cost Rate Cost Amount Currency External Reference URL
2 2024-03-04 Project without Client 20,0 No No No Peter Tester Yes 100,0 2.000,0 0,0 0,0 Euro - EUR
3 2024-03-04 Big Company Project for Big Company Task 1 Working hard 0,01 Yes No No Peter Tester Yes 100,0 1,0 0,0 0,0 Euro - EUR

View File

@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Task;
use App\Service\ColorService;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\GenericProjectsImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(GenericProjectsImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(GenericProjectsImporter::class)]
class GenericProjectsImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('generic_projects_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$clients = Client::all();
$this->assertCount(2, $clients);
$client1 = $clients->firstWhere('name', 'Big Company');
$this->assertNotNull($client1);
$client2 = $clients->firstWhere('name', 'Some client');
$this->assertNotNull($client2);
$projects = Project::all();
$this->assertCount(3, $projects);
// Project 1
$project1 = $projects->firstWhere('name', 'Project for Big Company');
$this->assertNotNull($project1);
$this->assertTrue(app(ColorService::class)->isBuiltInColor($project1->color));
$this->assertSame(10001, $project1->billable_rate);
$this->assertFalse($project1->is_public);
$this->assertSame($client1->getKey(), $project1->client_id);
$this->assertTrue($project1->is_billable);
$this->assertSame(null, $project1->estimated_time);
$this->assertNull($project1->archived_at);
// Project 2
$project2 = $projects->firstWhere('name', 'Project without Client');
$this->assertNotNull($project2);
$this->assertSame('#ef5350', $project2->color);
$this->assertSame(null, $project2->billable_rate);
$this->assertFalse($project2->is_public);
$this->assertSame(null, $project2->client_id);
$this->assertFalse($project2->is_billable);
$this->assertSame(1000, $project2->estimated_time);
$this->assertSame(null, $project2->archived_at);
$project3 = $projects->firstWhere('name', 'Project (Archived)');
$this->assertNotNull($project3);
$this->assertSame('#6a407f', $project3->color);
$this->assertSame(null, $project3->billable_rate);
$this->assertTrue($project3->is_public);
$this->assertSame($client2->getKey(), $project3->client_id);
$this->assertTrue($project3->is_billable);
$this->assertSame(null, $project3->estimated_time);
$this->assertSame('2024-08-25T10:00:00Z', $project3->archived_at->toIso8601ZuluString());
$tasks = Task::all();
$this->assertCount(0, $tasks);
$this->assertSame(0, $report->timeEntriesCreated);
$this->assertSame(0, $report->tagsCreated);
$this->assertSame(0, $report->tasksCreated);
$this->assertSame(0, $report->usersCreated);
$this->assertSame(3, $report->projectsCreated);
$this->assertSame(2, $report->clientsCreated);
}
}

View File

@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Organization;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\GenericTimeEntriesImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(GenericTimeEntriesImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(GenericTimeEntriesImporter::class)]
class GenericTimeEntriesImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
$this->checkTimeEntries($testScenario);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(2, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
public function test_import_of_test_file_twice_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('generic_time_entries_import_test_1.csv');
$importer->importData($data, $timezone);
$importer = new GenericTimeEntriesImporter;
$importer->init($organization);
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$testScenario = $this->checkTestScenarioAfterImportExcludingTimeEntries();
$this->checkTimeEntries($testScenario, true);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(0, $report->tagsCreated);
$this->assertSame(0, $report->tasksCreated);
$this->assertSame(0, $report->usersCreated);
$this->assertSame(0, $report->projectsCreated);
$this->assertSame(0, $report->clientsCreated);
}
}

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Client;
use App\Models\Organization;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\HarvestClientsImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(HarvestClientsImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(HarvestClientsImporter::class)]
class HarvestClientsImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new HarvestClientsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('harvest_clients_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$clients = Client::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $clients);
$client1 = $clients->where('name', 'Example Client')->first();
$this->assertNotNull($client1);
// Client name in Harvest: \\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\
$client2 = $clients->where('name', '\\\\ 🔥 Special characters """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
$this->assertNotNull($client2);
}
}

View File

@@ -0,0 +1,61 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Models\Client;
use App\Models\Organization;
use App\Models\Project;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\HarvestProjectsImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(HarvestProjectsImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(HarvestProjectsImporter::class)]
class HarvestProjectsImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new HarvestProjectsImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('harvest_projects_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
// Assert
$clients = Client::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $clients);
/** @var Client|null $client1 */
$client1 = $clients->where('name', 'Example Client')->first();
$this->assertNotNull($client1);
// Client name in Harvest: \\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};':"\\|,.''<>\/?~ \\\
/** @var Client|null $client2 */
$client2 = $clients->where('name', '\\\\ 🔥 Special characters client """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
$this->assertNotNull($client2);
$projects = Project::query()->whereBelongsTo($organization, 'organization')->get();
$this->assertCount(2, $projects);
/** @var Project|null $project1 */
$project1 = $projects->where('name', 'Example Project')->first();
$this->assertNotNull($project1);
$this->assertSame($client1->getKey(), $project1->client_id);
$this->assertSame(50 * 60 * 60, $project1->estimated_time); // 50h
$this->assertSame(true, $project1->is_billable);
/** @var Project|null $project2 */
$project2 = $projects->where('name', '\\\\ 🔥 Special characters project """`!@#$%^&*()_+\-=\[\]{};\':"\\\\|,.\'\'<>\/?~ \\\\\\')->first();
$this->assertNotNull($project2);
$this->assertSame($client2->getKey(), $project2->client_id);
$this->assertSame(null, $project2->estimated_time);
$this->assertSame(false, $project2->is_billable);
}
}

View File

@@ -0,0 +1,110 @@
<?php
declare(strict_types=1);
namespace Tests\Unit\Service\Import\Importers;
use App\Enums\Role;
use App\Models\Client;
use App\Models\Member;
use App\Models\Organization;
use App\Models\Project;
use App\Models\Tag;
use App\Models\Task;
use App\Models\TimeEntry;
use App\Models\User;
use App\Service\Import\Importers\DefaultImporter;
use App\Service\Import\Importers\HarvestTimeEntriesImporter;
use App\Service\Import\Importers\ImportException;
use Illuminate\Support\Facades\Storage;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\UsesClass;
#[CoversClass(HarvestTimeEntriesImporter::class)]
#[CoversClass(ImportException::class)]
#[CoversClass(DefaultImporter::class)]
#[UsesClass(HarvestTimeEntriesImporter::class)]
class HarvestTimeEntriesImporterTest extends ImporterTestAbstract
{
public function test_import_of_test_file_succeeds(): void
{
// Arrange
$organization = Organization::factory()->create();
$timezone = 'Europe/Vienna';
$importer = new HarvestTimeEntriesImporter;
$importer->init($organization);
$data = Storage::disk('testfiles')->get('harvest_time_entries_import_test_1.csv');
// Act
$importer->importData($data, $timezone);
$report = $importer->getReport();
// Assert
$users = User::all();
$this->assertCount(2, $users);
$user1 = $users->firstWhere('name', 'Peter Tester');
$this->assertNotNull($user1);
$this->assertSame(null, $user1->password);
$this->assertSame('Peter Tester', $user1->name);
$this->assertSame('peter.tester@solidtime-import.test', $user1->email);
$members = Member::all();
$this->assertCount(1, $members);
$member1 = $members->firstWhere('user_id', $user1->getKey());
$this->assertNotNull($member1);
$this->assertSame(Role::Placeholder->value, $member1->role);
$clients = Client::all();
$this->assertCount(1, $clients);
$client1 = $clients->firstWhere('name', 'Big Company');
$this->assertNotNull($client1);
$this->assertNull($client1->archived_at);
$projects = Project::with(['members'])->get();
$this->assertCount(2, $projects);
/** @var Project|null $project1 */
$project1 = $projects->firstWhere('name', 'Project without Client');
$this->assertNotNull($project1);
$this->assertNull($project1->client_id);
/** @var Project|null $project2 */
$project2 = $projects->firstWhere('name', 'Project for Big Company');
$this->assertNotNull($project2);
$this->assertSame($client1->getKey(), $project2->client_id);
$project3 = null;
// Project without Client
$this->assertSame(false, $project1->is_public);
// Project for Big Company
$this->assertSame(false, $project2->is_public);
$tasks = Task::all();
$this->assertCount(1, $tasks);
$task1 = $tasks->firstWhere('name', 'Task 1');
$this->assertNotNull($task1);
$this->assertNull($task1->done_at);
$this->assertSame($project2->getKey(), $task1->project_id);
$tags = Tag::all();
$this->assertCount(0, $tags);
$timeEntries = TimeEntry::all();
$this->assertCount(2, $timeEntries);
$timeEntry1 = $timeEntries->firstWhere('description', '');
$this->assertNotNull($timeEntry1);
$this->assertSame('', $timeEntry1->description);
$this->assertSame('2024-03-03 23:00:00', $timeEntry1->start->toDateTimeString());
$this->assertSame('2024-03-04 19:00:00', $timeEntry1->end->toDateTimeString());
$this->assertFalse($timeEntry1->billable);
$this->assertTrue($timeEntry1->is_imported);
$this->assertSame([], $timeEntry1->tags);
$timeEntry2 = $timeEntries->firstWhere('description', 'Working hard');
$this->assertNotNull($timeEntry2);
$this->assertSame('Working hard', $timeEntry2->description);
$this->assertSame('2024-03-03 23:00:00', $timeEntry2->start->toDateTimeString());
$this->assertSame('2024-03-03 23:00:36', $timeEntry2->end->toDateTimeString());
$this->assertTrue($timeEntry2->billable);
$this->assertTrue($timeEntry2->is_imported);
$this->assertSame([], $timeEntry2->tags);
$this->assertSame(2, $report->timeEntriesCreated);
$this->assertSame(0, $report->tagsCreated);
$this->assertSame(1, $report->tasksCreated);
$this->assertSame(1, $report->usersCreated);
$this->assertSame(2, $report->projectsCreated);
$this->assertSame(1, $report->clientsCreated);
}
}

View File

@@ -42,6 +42,11 @@ class ImporterProviderTest extends TestCase
'clockify_time_entries',
'clockify_projects',
'solidtime',
'harvest_projects',
'harvest_time_entries',
'harvest_clients',
'generic_projects',
'generic_time_entries',
], $keys);
}
}