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

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