Files
kimai2/src/Command/ImportTimesheetCommand.php
Kevin Papst 0f3fa8fdbe Code improvements (#1649)
* use global namespace for faster lookups
* phpstan level 5
2020-04-19 14:37:14 +02:00

518 lines
17 KiB
PHP

<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Configuration\FormConfiguration;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
use App\Entity\Tag;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Importer\InvalidFieldsException;
use App\Importer\UnknownUserException;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Utils\Duration;
use League\Csv\Reader;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command can change anytime, don't rely on its API for the future!
*
* @internal
* @codeCoverageIgnore
*/
class ImportTimesheetCommand extends Command
{
protected static $defaultName = 'kimai:import:timesheet';
// if we use 00:00 we might run into summer/winter time problems which happen between 02:00 and 03:00
public const DEFAULT_BEGIN = '04:00';
public const DEFAULT_CUSTOMER = 'Imported customer - %s';
private static $supportedHeader = [
'Date',
'From',
'To',
'Duration',
'Rate',
'User',
'Customer',
'Project',
'Activity',
'Description',
'Exported',
'Tags',
'Hourly rate',
'Fixed rate',
];
/**
* @var CustomerRepository
*/
private $customers;
/**
* @var ProjectRepository
*/
private $projects;
/**
* @var ActivityRepository
*/
private $activities;
/**
* @var UserRepository
*/
private $users;
/**
* @var TimesheetRepository
*/
private $timesheets;
/**
* @var FormConfiguration
*/
private $configuration;
/**
* @var Customer
*/
private $customerFallback;
/**
* @var Customer[]
*/
private $customerCache = [];
/**
* @var User[]
*/
private $userCache = [];
/**
* Comment that will be added to new customers, projects and activities.
*
* @var string
*/
private $comment = '';
/**
* The datetime of this import as formatted string.
*
* @var string
*/
private $dateTime = '';
/**
* @var string
*/
private $begin = self::DEFAULT_BEGIN;
public function __construct(CustomerRepository $customers, ProjectRepository $projects, ActivityRepository $activities, UserRepository $users, TimesheetRepository $timesheets, FormConfiguration $configuration)
{
parent::__construct();
$this->customers = $customers;
$this->projects = $projects;
$this->activities = $activities;
$this->users = $users;
$this->timesheets = $timesheets;
$this->configuration = $configuration;
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName(self::$defaultName)
->setDescription('Import timesheets from CSV file')
->setHelp(
'This command allows to import timesheets from a CSV file, which are formatted like CSV exports.' . PHP_EOL .
'Imported customer, projects and activities will be matched by name.' . PHP_EOL .
'Supported columns names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
)
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'The timezone to be used. Supports: "valid timezone names", the string "user" (using the configured users timezone) and the string "server" (PHP default timezone)', 'user')
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'A customer ID or name to assign for empty entries. Defaults to creating a new customer which is used for all un-linked projects')
->addOption('activity', null, InputOption::VALUE_OPTIONAL, 'Whether new activities should be "global" or "project" specific. Allowed values are "global" and "project"', 'project')
->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
->addOption('begin', null, InputOption::VALUE_OPTIONAL, 'Default begin if none was provided in the format HH:MM', self::DEFAULT_BEGIN)
->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers, projects and activities. %s will be replaced with the current datetime', 'Imported at %s')
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @return int|null
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
$io->title('Kimai importer: Timesheets');
$csvFile = $input->getArgument('file');
if (!file_exists($csvFile)) {
$io->error('File not existing: ' . $csvFile);
return 1;
}
if (!is_readable($csvFile)) {
$io->error('File cannot be read: ' . $csvFile);
return 2;
}
$this->dateTime = (new \DateTime())->format('Y.m.d H:i');
$this->comment = sprintf($input->getOption('comment'), $this->dateTime);
$this->begin = $input->getOption('begin');
$timezone = $input->getOption('timezone');
switch ($timezone) {
case 'server':
$timezone = new \DateTimeZone(date_default_timezone_get());
break;
case 'user':
// null means fetch from user
$timezone = null;
break;
default:
try {
$timezone = new \DateTimeZone($timezone);
} catch (\Exception $ex) {
$io->error('Invalid timezone given, import canceled.');
return 3;
}
break;
}
$activityType = $input->getOption('activity');
$allowedActivityTypes = ['project', 'global'];
if (!\in_array($activityType, $allowedActivityTypes)) {
$io->error(sprintf('Invalid activity type "%s" given, allowed values are: %s', $activityType, implode(', ', $allowedActivityTypes)));
return 4;
}
$csv = Reader::createFromPath($csvFile, 'r');
$csv->setDelimiter($input->getOption('delimiter'));
$csv->setHeaderOffset(0);
$header = $csv->getHeader();
if (!$this->validateHeader($header)) {
$io->error(
sprintf(
'Found invalid CSV. The header: ' . PHP_EOL .
'%s' . PHP_EOL .
'did not match the expected structure: ' . PHP_EOL .
'%s',
implode(', ', $header),
implode(', ', self::$supportedHeader)
)
);
return 5;
}
$records = $csv->getRecords();
$doImport = true;
$row = 1;
$errors = 0;
// ======================= validate rows =======================
foreach ($records as $record) {
try {
$this->validateRow($record);
} catch (InvalidFieldsException $ex) {
$io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
$doImport = false;
$errors++;
}
try {
$user = $this->getUser($record['User']);
} catch (\Exception $ex) {
$io->error(sprintf('Unknown user %s in row %s', $record['User'], $row));
$doImport = false;
$errors++;
}
$row++;
}
if (!$doImport) {
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
return 5;
}
$durationParser = new Duration();
$row = 0;
foreach ($records as $record) {
$row++;
try {
$project = $this->getProject($record['Project'], $record['Customer'], $input->getOption('customer'));
$activity = $this->getActivity($record['Activity'], $project, $activityType);
$user = $this->getUser($record['User']);
$begin = null;
$end = null;
$duration = 0;
if (!empty($record['Duration'])) {
if (\is_int($record['Duration'])) {
$duration = $record['Duration'];
} else {
$duration = $durationParser->parseDurationString($record['Duration']);
}
}
if (null === $timezone) {
$timezone = new \DateTimeZone($user->getTimezone());
}
if (empty($record['From']) && empty($record['To'])) {
$begin = new \DateTime($record['Date'] . ' ' . $this->begin, $timezone);
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
} elseif (empty($record['From'])) {
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
$begin = (new \DateTime())->setTimezone($timezone)->setTimestamp($end->getTimestamp() - $duration);
} elseif (empty($record['To'])) {
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
$end = (new \DateTime())->setTimezone($timezone)->setTimestamp($begin->getTimestamp() + $duration);
} else {
$begin = new \DateTime($record['Date'] . ' ' . $record['From'], $timezone);
$end = new \DateTime($record['Date'] . ' ' . $record['To'], $timezone);
}
$timesheet = new Timesheet();
$timesheet->setActivity($activity);
$timesheet->setProject($project);
$timesheet->setBegin($begin);
$timesheet->setEnd($end);
$timesheet->setUser($user);
$timesheet->setDescription($record['Description']);
$timesheet->setExported((bool) $record['Exported']);
if (!empty($record['Tags'])) {
foreach (explode(',', $record['Tags']) as $tag) {
if (empty($tag)) {
continue;
}
$timesheet->addTag((new Tag())->setName($tag));
}
}
if (!empty($record['Rate'])) {
$timesheet->setRate($record['Rate']);
}
if (!empty($record['Hourly rate'])) {
$timesheet->setHourlyRate($record['Hourly rate']);
}
if (!empty($record['Fixed rate'])) {
$timesheet->setFixedRate($record['Fixed rate']);
}
$this->timesheets->save($timesheet);
} catch (\Exception $ex) {
$io->error(sprintf('Failed importing timesheet row %s with: %s', $row, $ex->getMessage()));
return 6;
}
}
$io->success(sprintf('Imported %s rows', $row));
return 0;
}
private function getUser($user): User
{
if (!\array_key_exists($user, $this->userCache)) {
$tmpUser = $this->users->findOneBy(['username' => $user]);
if (null === $tmpUser) {
$tmpUser = $this->users->findOneBy(['email' => $user]);
if (null === $tmpUser) {
throw new UnknownUserException($user);
}
}
$this->userCache[$user] = $tmpUser;
}
return $this->userCache[$user];
}
private function getActivity($activity, Project $project, $activityType): Activity
{
$tmpActivity = null;
$tmpActivities = $this->activities->findBy(['project' => $project, 'name' => $activity]);
if (\count($tmpActivities) === 0) {
$tmpActivity = $this->activities->findOneBy(['project' => null, 'name' => $activity]);
} elseif (\count($tmpActivities) === 1) {
$tmpActivity = $tmpActivities[0];
}
if (null === $tmpActivity) {
$tmpActivity = new Activity();
$tmpActivity->setName($activity);
$tmpActivity->setComment($this->comment);
if ($activityType === 'project') {
$tmpActivity->setProject($project);
}
$this->activities->saveActivity($tmpActivity);
}
return $tmpActivity;
}
private function getProject($project, $customer, $fallbackCustomer): Project
{
/** @var Customer $tmpCustomer */
$tmpCustomer = $this->getCustomer($customer, $fallbackCustomer);
/** @var Project $tmpProject */
$tmpProject = null;
/** @var Project[] $tmpProjects */
$tmpProjects = $this->projects->findBy(['name' => $project]);
if (\count($tmpProjects) > 1) {
/** @var Project $prj */
foreach ($tmpProjects as $prj) {
if ($prj->getCustomer()->getName() !== $tmpCustomer->getName()) {
continue;
}
$tmpProject = $prj;
break;
}
} elseif (\count($tmpProjects) === 1) {
$tmpProject = $tmpProjects[0];
}
if (null !== $tmpProject) {
if ($tmpProject->getCustomer()->getName() !== $tmpCustomer->getName()) {
$tmpProject = null;
}
}
if ($tmpProject === null) {
$tmpProject = new Project();
$tmpProject->setName($project);
$tmpProject->setComment($this->comment);
$tmpProject->setCustomer($tmpCustomer);
$this->projects->saveProject($tmpProject);
}
return $tmpProject;
}
private function getCustomer($customer, $fallback): Customer
{
if (!empty($customer)) {
if (!\array_key_exists($customer, $this->customerCache)) {
$tmpCustomer = $this->customers->findBy(['name' => $customer]);
if (\count($tmpCustomer) > 1) {
throw new \Exception(sprintf('Found multiple customers with the name: %s', $customer));
} elseif (\count($tmpCustomer) === 1) {
$tmpCustomer = $tmpCustomer[0];
}
if ($tmpCustomer instanceof Customer) {
$this->customerCache[$customer] = $tmpCustomer;
}
}
if (\array_key_exists($customer, $this->customerCache)) {
return $this->customerCache[$customer];
}
}
if (null === $this->customerFallback) {
$tmpFallback = null;
if (!empty($fallback)) {
if (\is_int($customer)) {
$tmpFallback = $this->customers->find($fallback);
} else {
/** @var Customer|null $tmpFallback */
$tmpFallback = $this->customers->findOneBy(['name' => $fallback]);
}
}
if (null === $tmpFallback) {
$newName = self::DEFAULT_CUSTOMER;
if (!empty($fallback) && \is_string($fallback)) {
$newName = $fallback;
}
$tmpFallback = new Customer();
$tmpFallback->setName(sprintf($newName, $this->dateTime));
$tmpFallback->setComment($this->comment);
$tmpFallback->setCountry($this->configuration->getCustomerDefaultCountry());
$timezone = date_default_timezone_get();
if (null !== $this->configuration->getCustomerDefaultTimezone()) {
$timezone = $this->configuration->getCustomerDefaultTimezone();
}
$tmpFallback->setTimezone($timezone);
$this->customers->saveCustomer($tmpFallback);
}
$this->customerFallback = $tmpFallback;
}
return $this->customerFallback;
}
/**
* @param array $row
* @return bool
* @throws InvalidFieldsException
*/
private function validateRow(array $row)
{
$fields = [];
if (empty($row['Project'])) {
$fields[] = 'Project';
}
if (empty($row['Activity'])) {
$fields[] = 'Activity';
}
if (empty($row['Date'])) {
$fields[] = 'Date';
}
if ((empty($row['From']) || empty($row['To'])) && empty($row['Duration'])) {
$fields[] = 'Duration';
}
if (!empty($fields)) {
throw new InvalidFieldsException($fields);
}
return true;
}
private function validateHeader(array $header)
{
$result = array_diff(self::$supportedHeader, $header);
return empty($result);
}
}