diff --git a/config/services_test.yaml b/config/services_test.yaml
index a123e808..e1781a9d 100644
--- a/config/services_test.yaml
+++ b/config/services_test.yaml
@@ -1,6 +1,6 @@
parameters:
locale: en
- app_locales: ar|cs|de|de_CH|da|en|es|fr|hu|it|ja|ko|nl|pt_BR|ru|sk|sv|tr|zh_CN
+ app_locales: ar|cs|de|de_CH|da|en|eo|es|eu|fr|he|hu|it|ja|ko|nl|pl|pt_BR|ro|ru|sk|sv|tr|vi|zh_CN
services:
_defaults:
@@ -20,3 +20,15 @@ services:
class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\Configuration']
+
+ # required for the importer command test
+ App\Repository\UserRepository:
+ class: Doctrine\ORM\EntityRepository
+ factory: ['@doctrine.orm.entity_manager', getRepository]
+ arguments: ['App\Entity\User']
+
+ # required for the importer command test
+ App\Importer\ImporterService:
+ public: true
+ arguments:
+ [ '@App\Customer\CustomerService', '@App\Project\ProjectService' ]
diff --git a/src/API/UserController.php b/src/API/UserController.php
index 18dfefe1..0d36cda9 100644
--- a/src/API/UserController.php
+++ b/src/API/UserController.php
@@ -11,7 +11,7 @@ declare(strict_types=1);
namespace App\API;
-use App\Configuration\FormConfiguration;
+use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Form\API\UserApiCreateForm;
use App\Form\API\UserApiEditForm;
@@ -56,11 +56,11 @@ final class UserController extends BaseApiController
*/
private $encoder;
/**
- * @var FormConfiguration
+ * @var SystemConfiguration
*/
private $configuration;
- public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository, UserPasswordEncoderInterface $encoder, FormConfiguration $config)
+ public function __construct(ViewHandlerInterface $viewHandler, UserRepository $repository, UserPasswordEncoderInterface $encoder, SystemConfiguration $config)
{
$this->viewHandler = $viewHandler;
$this->repository = $repository;
diff --git a/src/Command/ImportCustomerCommand.php b/src/Command/ImportCustomerCommand.php
index 8fd3a554..57745b99 100644
--- a/src/Command/ImportCustomerCommand.php
+++ b/src/Command/ImportCustomerCommand.php
@@ -9,17 +9,10 @@
namespace App\Command;
-use App\Configuration\FormConfiguration;
-use App\Entity\Customer;
-use App\Entity\Project;
-use App\Entity\Team;
-use App\Importer\InvalidFieldsException;
-use App\Repository\CustomerRepository;
-use App\Repository\ProjectRepository;
-use App\Repository\TeamRepository;
-use App\Repository\UserRepository;
-use League\Csv\Reader;
+use App\Importer\ImporterService;
+use App\Importer\ImportNotFoundException;
use Symfony\Component\Console\Command\Command;
+use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
@@ -28,72 +21,15 @@ use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command can change anytime, don't rely on its API for the future!
- *
- * @internal
- * @codeCoverageIgnore
*/
class ImportCustomerCommand extends Command
{
- protected static $defaultName = 'kimai:import:customer';
+ private $importer;
- private static $requiredHeader = [
- 'Name',
- 'Customer',
- ];
-
- private static $supportedHeader = [
- 'Name',
- 'Customer',
- 'Comment',
- 'OrderNumber',
- 'OrderDate',
- ];
-
- /**
- * @var CustomerRepository
- */
- private $customers;
- /**
- * @var ProjectRepository
- */
- private $projects;
- /**
- * @var TeamRepository
- */
- private $teams;
- /**
- * @var UserRepository
- */
- private $users;
- /**
- * @var FormConfiguration
- */
- private $configuration;
- /**
- * @var Customer[]
- */
- private $customerCache = [];
- /**
- * The datetime of this import as formatted string.
- *
- * @var string
- */
- private $dateTime = '';
- /**
- * Comment that will be added to new customers, projects and activities.
- *
- * @var string
- */
- private $comment = '';
-
- public function __construct(CustomerRepository $customers, ProjectRepository $projects, TeamRepository $teams, UserRepository $users, FormConfiguration $configuration)
+ public function __construct(ImporterService $importer)
{
parent::__construct();
- $this->customers = $customers;
- $this->projects = $projects;
- $this->teams = $teams;
- $this->users = $users;
- $this->configuration = $configuration;
+ $this->importer = $importer;
}
/**
@@ -102,18 +38,16 @@ class ImportCustomerCommand extends Command
protected function configure()
{
$this
- ->setName(self::$defaultName)
- ->setDescription('Import projects from CSV file')
+ ->setName('kimai:import:customer')
+ ->setDescription('Import customer from CSV file')
->setHelp(
- 'This command allows to import projects from a CSV file, creating customers (if not existing) and optional empty teams for each project.' . PHP_EOL .
- 'Imported customer will be matched by name and optionally created on the fly.' . PHP_EOL .
- 'Required column names: ' . implode(', ', self::$requiredHeader) . PHP_EOL .
- 'Supported column names: ' . implode(', ', self::$supportedHeader) . PHP_EOL
+ 'Import customers from a CSV file.' . PHP_EOL .
+ 'Customer will be matched by name or number, and if not found created on the fly.' . PHP_EOL
)
- ->addOption('teamlead', null, InputOption::VALUE_REQUIRED, 'If you want to create empty teams for each project, give the username of the teamlead to be assigned')
- ->addOption('delimiter', null, InputOption::VALUE_OPTIONAL, 'The CSV field delimiter', ',')
- ->addOption('comment', null, InputOption::VALUE_OPTIONAL, 'A description to be added to created customers and projects. %s will be replaced with the current datetime', 'Imported at %s')
->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
+ ->addOption('importer', null, InputOption::VALUE_REQUIRED, 'The importer to use (supported: default, grandtotal)', 'default')
+ ->addOption('reader', null, InputOption::VALUE_REQUIRED, 'The reader to use (supported: csv, csv-semicolon)', 'csv')
+ ->addOption('no-update', null, InputOption::VALUE_NONE, 'If you want to create new customers, but not update existing ones')
;
}
@@ -126,213 +60,108 @@ class ImportCustomerCommand extends Command
{
$io = new SymfonyStyle($input, $output);
- $io->title('Kimai importer: Projects');
+ $io->title('Kimai importer: Customers');
- $csvFile = $input->getArgument('file');
- if (!file_exists($csvFile)) {
- $io->error('File not existing: ' . $csvFile);
+ $skipUpdate = $input->getOption('no-update');
+ $doImport = true;
+ $row = 1;
+ $errors = 0;
+ $customers = [];
+ $importer = null;
+
+ try {
+ $importer = $this->importer->getCustomerImporter($input->getOption('importer'));
+ $reader = $this->importer->getReader($input->getOption('reader'));
+ } catch (\Exception $ex) {
+ $io->error($ex->getMessage());
return 1;
}
- if (!is_readable($csvFile)) {
- $io->error('File cannot be read: ' . $csvFile);
+ $importerFile = $input->getArgument('file');
+
+ try {
+ $records = $reader->read($importerFile);
+ } catch (ImportNotFoundException $ex) {
+ $io->error('File not existing or not readable: ' . $importerFile);
return 2;
}
- $this->dateTime = (new \DateTime())->format('Y.m.d H:i');
- $this->comment = sprintf($input->getOption('comment'), $this->dateTime);
+ $amount = iterator_count($records);
+ $records->rewind();
+ $io->text(sprintf('Found %s rows to process, converting now ...', $amount));
- $csv = Reader::createFromPath($csvFile, 'r');
- $csv->setDelimiter($input->getOption('delimiter'));
- $csv->setHeaderOffset(0);
- $header = $csv->getHeader();
-
- // validate teamlead
- $teamlead = $input->getOption('teamlead');
- if (null !== $teamlead) {
- $tmpUser = $this->users->findOneBy(['username' => $teamlead]);
- if (null === $tmpUser) {
- $tmpUser = $this->users->findOneBy(['email' => $teamlead]);
- if (null === $tmpUser) {
- $io->error(
- sprintf(
- 'You requested to create empty teams for each project, but the given teamlead cannot be found.' . PHP_EOL .
- 'Please create a user with the name (or email) %s first, before continuing.' . PHP_EOL,
- $teamlead
- )
- );
-
- return 3;
- }
- }
-
- $teamlead = $tmpUser;
- }
-
- if (!$this->validateHeader($header)) {
- $io->error(
- sprintf(
- 'Found invalid CSV header: %s' . PHP_EOL .
- 'Required fields: %s' . PHP_EOL .
- 'All supported fields: %s' . PHP_EOL,
- implode(', ', $header),
- implode(', ', self::$requiredHeader),
- implode(', ', self::$supportedHeader)
- )
- );
-
- return 4;
- }
-
- $records = $csv->getRecords();
-
- $doImport = true;
- $row = 1;
- $errors = 0;
+ $progressBar = new ProgressBar($output, $amount);
foreach ($records as $record) {
try {
- $this->validateRow($record);
- } catch (InvalidFieldsException $ex) {
- $io->error(sprintf('Invalid row %s, invalid fields: %s', $row, implode(', ', $ex->getFields())));
+ $customers[] = $importer->convertEntryToCustomer($record);
+ } catch (\Exception $ex) {
+ $io->error(sprintf('Invalid row %s: %s', $row, $ex->getMessage()));
$doImport = false;
$errors++;
}
+ $progressBar->advance();
$row++;
}
+ $progressBar->finish();
+ $io->writeln('');
if (!$doImport) {
$io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
- return 5;
+ return 3;
}
- $row = 0;
- foreach ($records as $record) {
- $row++;
+ $amount = \count($customers);
+ $io->text(sprintf('Converted %s customers, importing into Kimai now ...', $amount));
+
+ $progressBar = new ProgressBar($output, $amount);
+
+ $created = 0;
+ $updated = 0;
+ $noUpdatedCustomers = 0;
+
+ foreach ($customers as $customer) {
try {
- $customer = $this->getCustomer($record['Customer']);
- $projectName = $record['Name'];
+ $progressBar->advance();
- $project = new Project();
- $project->setName($projectName);
- $project->setCustomer($customer);
-
- $comment = $this->comment;
- if (isset($record['Comment']) && !empty($record['Comment'])) {
- $comment = $record['Comment'];
- }
- $project->setComment($comment);
-
- if (isset($record['OrderNumber']) && !empty($record['OrderNumber'])) {
- $project->setOrderNumber($record['OrderNumber']);
- }
- if (isset($record['OrderDate']) && !empty($record['OrderDate'])) {
- $project->setOrderDate($record['OrderDate']);
- }
-
- $team = null;
- if (null !== $teamlead) {
- $team = new Team();
- $team->setName($projectName);
- $team->setTeamLead($teamlead);
-
- $this->teams->saveTeam($team);
- }
-
- $this->projects->saveProject($project);
-
- if (null !== $team) {
- $project->addTeam($team);
- $team->addProject($project);
-
- $this->teams->saveTeam($team);
- $this->projects->saveProject($project);
+ if ($customer->getId() === null) {
+ $this->importer->importCustomer($customer);
+ $created++;
+ } elseif ($skipUpdate === false) {
+ $this->importer->importCustomer($customer);
+ $updated++;
+ } else {
+ $noUpdatedCustomers++;
}
} catch (\Exception $ex) {
- $io->error(sprintf('Failed importing project row %s with: %s', $row, $ex->getMessage()));
+ $io->error(sprintf('Failed importing customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
- return 6;
+ return 4;
}
}
- $io->success(sprintf('Imported %s rows', $row));
+ $progressBar->finish();
+ $io->writeln('');
+ $io->writeln('');
+
+ if ($created > 0) {
+ $io->success(sprintf('Imported %s customer', $created));
+ }
+ if ($updated > 0) {
+ $io->success(sprintf('Updated %s customer', $updated));
+ }
+ if ($noUpdatedCustomers > 0) {
+ $io->success(sprintf('Skipped %s existing customer', $noUpdatedCustomers));
+ }
+
+ if ($updated === 0 && $created === 0) {
+ $io->text('Nothing was imported');
+ }
return 0;
}
-
- private function getCustomer(string $customerName): Customer
- {
- if (!\array_key_exists($customerName, $this->customerCache)) {
- $tmpCustomer = $this->customers->findBy(['name' => $customerName]);
-
- if (\count($tmpCustomer) > 1) {
- throw new \Exception(sprintf('Found multiple customers with the name: %s', $customerName));
- } elseif (\count($tmpCustomer) === 1) {
- $tmpCustomer = $tmpCustomer[0];
- }
-
- if ($tmpCustomer instanceof Customer) {
- $this->customerCache[$customerName] = $tmpCustomer;
- }
- }
-
- if (\array_key_exists($customerName, $this->customerCache)) {
- return $this->customerCache[$customerName];
- }
-
- $customer = new Customer();
- $customer->setName(sprintf($customerName, $this->dateTime));
- $customer->setComment($this->comment);
- $customer->setCountry($this->configuration->getCustomerDefaultCountry());
- $timezone = date_default_timezone_get();
- if (null !== $this->configuration->getCustomerDefaultTimezone()) {
- $timezone = $this->configuration->getCustomerDefaultTimezone();
- }
- $customer->setTimezone($timezone);
-
- $this->customers->saveCustomer($customer);
-
- $this->customerCache[$customerName] = $customer;
-
- return $customer;
- }
-
- /**
- * @param array $row
- * @return bool
- * @throws InvalidFieldsException
- */
- private function validateRow(array $row)
- {
- $fields = [];
-
- foreach (self::$requiredHeader as $headerName) {
- if (!isset($row[$headerName]) || empty($row[$headerName])) {
- $fields[] = $headerName;
- }
- }
-
- if (!empty($fields)) {
- throw new InvalidFieldsException($fields);
- }
-
- return true;
- }
-
- private function validateHeader(array $header)
- {
- $fields = [];
-
- foreach (self::$requiredHeader as $headerName) {
- if (!\in_array($headerName, $header)) {
- $fields[] = $headerName;
- }
- }
-
- return empty($fields);
- }
}
diff --git a/src/Command/ImportProjectCommand.php b/src/Command/ImportProjectCommand.php
new file mode 100644
index 00000000..4582860d
--- /dev/null
+++ b/src/Command/ImportProjectCommand.php
@@ -0,0 +1,234 @@
+importerService = $importerService;
+ $this->teams = $teams;
+ $this->users = $users;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function configure()
+ {
+ $this
+ ->setName('kimai:import:project')
+ ->setDescription('Import projects from CSV file')
+ ->setHelp(
+ 'Import projects from a CSV file, creating customers (if not existing) and optional empty teams for each project.' . PHP_EOL .
+ 'Imported customer will be matched by name and optionally created on the fly.' . PHP_EOL
+ )
+ ->addArgument('file', InputArgument::REQUIRED, 'The CSV file to be imported')
+ ->addOption('importer', null, InputOption::VALUE_REQUIRED, 'The importer to use (supported: default)', 'default')
+ ->addOption('reader', null, InputOption::VALUE_REQUIRED, 'The reader to use (supported: csv, csv-semicolon)', 'csv')
+ ->addOption('teamlead', null, InputOption::VALUE_REQUIRED, 'If you want to create empty teams for each project, give the username of the teamlead to be assigned')
+ ->addOption('no-update', null, InputOption::VALUE_NONE, 'If you want to create new project, but not update existing ones')
+ ;
+ }
+
+ /**
+ * @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: Projects');
+
+ // validate teamlead
+ $teamlead = $input->getOption('teamlead');
+ if (null !== $teamlead) {
+ $tmpUser = $this->users->findOneBy(['username' => $teamlead]);
+ if ($tmpUser === null) {
+ $tmpUser = $this->users->findOneBy(['email' => $teamlead]);
+ if ($tmpUser === null) {
+ $io->error(
+ sprintf(
+ 'You requested to create empty teams for each project, but the given teamlead cannot be found.' . PHP_EOL .
+ 'Please create a user with the name (or email) %s first, before continuing.' . PHP_EOL,
+ $teamlead
+ )
+ );
+
+ return 3;
+ }
+ }
+
+ $teamlead = $tmpUser;
+ }
+
+ $skipUpdate = $input->getOption('no-update');
+ $doImport = true;
+ $row = 1;
+ $errors = 0;
+ $projects = [];
+
+ try {
+ $importer = $this->importerService->getProjectImporter($input->getOption('importer'));
+ $reader = $this->importerService->getReader($input->getOption('reader'));
+ } catch (\Exception $ex) {
+ $io->error($ex->getMessage());
+
+ return 1;
+ }
+
+ $importerFile = $input->getArgument('file');
+
+ $io->text('Reading import file ...');
+
+ try {
+ $records = $reader->read($importerFile);
+ } catch (ImportNotFoundException $ex) {
+ $io->error('File not existing or not readable: ' . $importerFile);
+
+ return 2;
+ }
+
+ $amount = iterator_count($records);
+ $records->rewind();
+ $io->text(sprintf('Found %s rows to process, converting now ...', $amount));
+
+ $progressBar = new ProgressBar($output, $amount);
+
+ foreach ($records as $record) {
+ try {
+ $projects[] = $importer->convertEntryToProject($record);
+ } catch (\Exception $ex) {
+ $io->error(sprintf('Invalid row %s: %s', $row, $ex->getMessage()));
+ $doImport = false;
+ $errors++;
+ }
+ $progressBar->advance();
+
+ $row++;
+ }
+ $progressBar->finish();
+ $io->writeln('');
+
+ if (!$doImport) {
+ $io->caution(sprintf('Not importing, previous %s errors need to be fixed first.', $errors));
+
+ return 3;
+ }
+
+ $createdProjects = 0;
+ $updatedProjects = 0;
+ $noUpdatedProjects = 0;
+ $createdCustomers = 0;
+ $createdTeams = 0;
+
+ $amount = \count($projects);
+ $io->text(sprintf('Converted %s projects, importing into Kimai now ...', $amount));
+
+ $progressBar = new ProgressBar($output, $amount);
+
+ foreach ($projects as $project) {
+ $row++;
+ $progressBar->advance();
+ try {
+ if ($project->getCustomer()->getId() === null) {
+ $this->importerService->importCustomer($project->getCustomer());
+ $createdCustomers++;
+ }
+
+ $createTeam = false;
+
+ if ($project->getId() === null) {
+ $this->importerService->importProject($project);
+ $createdProjects++;
+ $createTeam = (null !== $teamlead);
+ } elseif ($skipUpdate === false) {
+ $this->importerService->importProject($project);
+ $updatedProjects++;
+ } else {
+ $noUpdatedProjects++;
+ }
+
+ if (!$createTeam) {
+ continue;
+ }
+
+ $team = new Team();
+ $team->setName($project->getName());
+ $team->setTeamLead($teamlead);
+
+ $this->teams->saveTeam($team);
+
+ $project->addTeam($team);
+ $team->addProject($project);
+
+ $this->teams->saveTeam($team);
+ $createdTeams++;
+ } catch (\Exception $ex) {
+ $io->error(sprintf('Failed importing project row %s with: %s', $row, $ex->getMessage()));
+
+ return 4;
+ }
+ }
+
+ $progressBar->finish();
+ $io->writeln('');
+ $io->writeln('');
+
+ if ($createdCustomers === 0 && $updatedProjects === 0 && $createdProjects === 0) {
+ if ($noUpdatedProjects > 0) {
+ $io->success(sprintf('Skipped %s existing projects', $noUpdatedProjects));
+ } else {
+ $io->text('Nothing was imported');
+ }
+ } else {
+ if ($createdCustomers > 0) {
+ $io->success(sprintf('Imported %s customers', $createdCustomers));
+ }
+ if ($updatedProjects > 0) {
+ $io->success(sprintf('Updated %s projects', $updatedProjects));
+ }
+ if ($noUpdatedProjects > 0) {
+ $io->success(sprintf('Skipped %s existing projects', $noUpdatedProjects));
+ }
+ if ($createdProjects > 0) {
+ $io->success(sprintf('Imported %s projects', $createdProjects));
+ }
+ if ($createdTeams > 0) {
+ $io->success(sprintf('Created %s teams', $createdTeams));
+ }
+ }
+
+ return 0;
+ }
+}
diff --git a/src/Command/ImportTimesheetCommand.php b/src/Command/ImportTimesheetCommand.php
index d47c1541..43e824ca 100644
--- a/src/Command/ImportTimesheetCommand.php
+++ b/src/Command/ImportTimesheetCommand.php
@@ -9,7 +9,7 @@
namespace App\Command;
-use App\Configuration\FormConfiguration;
+use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\Project;
@@ -89,7 +89,7 @@ class ImportTimesheetCommand extends Command
*/
private $timesheets;
/**
- * @var FormConfiguration
+ * @var SystemConfiguration
*/
private $configuration;
/**
@@ -121,7 +121,7 @@ class ImportTimesheetCommand extends Command
*/
private $begin = self::DEFAULT_BEGIN;
- public function __construct(CustomerRepository $customers, ProjectRepository $projects, ActivityRepository $activities, UserRepository $users, TagRepository $tagRepository, TimesheetRepository $timesheets, FormConfiguration $configuration)
+ public function __construct(CustomerRepository $customers, ProjectRepository $projects, ActivityRepository $activities, UserRepository $users, TagRepository $tagRepository, TimesheetRepository $timesheets, SystemConfiguration $configuration)
{
parent::__construct();
$this->customers = $customers;
diff --git a/src/Configuration/FormConfiguration.php b/src/Configuration/FormConfiguration.php
index 92aa6f60..c8d620e2 100644
--- a/src/Configuration/FormConfiguration.php
+++ b/src/Configuration/FormConfiguration.php
@@ -10,7 +10,7 @@
namespace App\Configuration;
/**
- * @internal will be deprecated soon, use SystemConfiguration instead
+ * @deprecated will be removed with 2.0, use SystemConfiguration instead
*/
class FormConfiguration implements SystemBundleConfiguration
{
diff --git a/src/Configuration/SystemConfiguration.php b/src/Configuration/SystemConfiguration.php
index 73c18ed6..8191f4e1 100644
--- a/src/Configuration/SystemConfiguration.php
+++ b/src/Configuration/SystemConfiguration.php
@@ -82,4 +82,39 @@ class SystemConfiguration implements SystemBundleConfiguration
{
return (string) $this->find('calendar.slot_duration');
}
+
+ public function getCustomerDefaultTimezone(): ?string
+ {
+ return $this->find('defaults.customer.timezone');
+ }
+
+ public function getCustomerDefaultCurrency(): string
+ {
+ return $this->find('defaults.customer.currency');
+ }
+
+ public function getCustomerDefaultCountry(): string
+ {
+ return $this->find('defaults.customer.country');
+ }
+
+ public function getUserDefaultTimezone(): ?string
+ {
+ return $this->find('defaults.user.timezone');
+ }
+
+ public function getUserDefaultTheme(): ?string
+ {
+ return $this->find('defaults.user.theme');
+ }
+
+ public function getUserDefaultLanguage(): string
+ {
+ return $this->find('defaults.user.language');
+ }
+
+ public function getUserDefaultCurrency(): string
+ {
+ return $this->find('defaults.user.currency');
+ }
}
diff --git a/src/Controller/ActivityController.php b/src/Controller/ActivityController.php
index e3ae80c3..80b9a76e 100644
--- a/src/Controller/ActivityController.php
+++ b/src/Controller/ActivityController.php
@@ -9,7 +9,7 @@
namespace App\Controller;
-use App\Configuration\FormConfiguration;
+use App\Configuration\SystemConfiguration;
use App\Entity\Activity;
use App\Entity\ActivityRate;
use App\Entity\MetaTableTypeInterface;
@@ -51,7 +51,7 @@ final class ActivityController extends AbstractController
*/
private $repository;
/**
- * @var FormConfiguration
+ * @var SystemConfiguration
*/
private $configuration;
/**
@@ -59,7 +59,7 @@ final class ActivityController extends AbstractController
*/
private $dispatcher;
- public function __construct(ActivityRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher)
+ public function __construct(ActivityRepository $repository, SystemConfiguration $configuration, EventDispatcherInterface $dispatcher)
{
$this->repository = $repository;
$this->configuration = $configuration;
diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php
index e54b8560..7b10fbbe 100644
--- a/src/Controller/CustomerController.php
+++ b/src/Controller/CustomerController.php
@@ -9,7 +9,7 @@
namespace App\Controller;
-use App\Configuration\FormConfiguration;
+use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\CustomerComment;
use App\Entity\CustomerRate;
@@ -109,7 +109,7 @@ final class CustomerController extends AbstractController
* @Route(path="/create", name="admin_customer_create", methods={"GET", "POST"})
* @Security("is_granted('create_customer')")
*/
- public function createAction(Request $request, FormConfiguration $configuration)
+ public function createAction(Request $request, SystemConfiguration $configuration)
{
$timezone = date_default_timezone_get();
if (null !== $configuration->getCustomerDefaultTimezone()) {
diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php
index c92980fd..393dc20e 100644
--- a/src/Controller/ProjectController.php
+++ b/src/Controller/ProjectController.php
@@ -9,7 +9,7 @@
namespace App\Controller;
-use App\Configuration\FormConfiguration;
+use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\MetaTableTypeInterface;
use App\Entity\Project;
@@ -57,7 +57,7 @@ final class ProjectController extends AbstractController
*/
private $repository;
/**
- * @var FormConfiguration
+ * @var SystemConfiguration
*/
private $configuration;
/**
@@ -69,7 +69,7 @@ final class ProjectController extends AbstractController
*/
private $projectService;
- public function __construct(ProjectRepository $repository, FormConfiguration $configuration, EventDispatcherInterface $dispatcher, ProjectService $projectService)
+ public function __construct(ProjectRepository $repository, SystemConfiguration $configuration, EventDispatcherInterface $dispatcher, ProjectService $projectService)
{
$this->repository = $repository;
$this->configuration = $configuration;
diff --git a/src/Controller/UserController.php b/src/Controller/UserController.php
index 0fa85ef9..2d9de61a 100644
--- a/src/Controller/UserController.php
+++ b/src/Controller/UserController.php
@@ -9,7 +9,7 @@
namespace App\Controller;
-use App\Configuration\FormConfiguration;
+use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Event\UserPreferenceDisplayEvent;
use App\Export\Spreadsheet\UserExporter;
@@ -97,7 +97,7 @@ final class UserController extends AbstractController
]);
}
- private function createNewDefaultUser(FormConfiguration $config): User
+ private function createNewDefaultUser(SystemConfiguration $config): User
{
$user = new User();
$user->setEnabled(true);
@@ -112,7 +112,7 @@ final class UserController extends AbstractController
* @Route(path="/create", name="admin_user_create", methods={"GET", "POST"})
* @Security("is_granted('create_user')")
*/
- public function createAction(Request $request, FormConfiguration $config): Response
+ public function createAction(Request $request, SystemConfiguration $config): Response
{
$user = $this->createNewDefaultUser($config);
$editForm = $this->getCreateUserForm($user);
diff --git a/src/Customer/CustomerService.php b/src/Customer/CustomerService.php
new file mode 100644
index 00000000..e704fefc
--- /dev/null
+++ b/src/Customer/CustomerService.php
@@ -0,0 +1,112 @@
+repository = $customerRepository;
+ $this->dispatcher = $dispatcher;
+ $this->validator = $validator;
+ $this->configuration = $configuration;
+ }
+
+ private function getDefaultTimezone(): string
+ {
+ if (null === ($timezone = $this->configuration->getCustomerDefaultTimezone())) {
+ $timezone = date_default_timezone_get();
+ }
+
+ return $timezone;
+ }
+
+ public function createNewCustomer(): Customer
+ {
+ $customer = new Customer();
+ $customer->setTimezone($this->getDefaultTimezone());
+ $customer->setCountry($this->configuration->getCustomerDefaultCountry());
+ $customer->setCurrency($this->configuration->getCustomerDefaultCurrency());
+
+ $this->dispatcher->dispatch(new CustomerMetaDefinitionEvent($customer));
+ $this->dispatcher->dispatch(new CustomerCreateEvent($customer));
+
+ return $customer;
+ }
+
+ public function saveNewCustomer(Customer $customer): Customer
+ {
+ if (null !== $customer->getId()) {
+ throw new InvalidArgumentException('Cannot create customer, already persisted');
+ }
+
+ $this->validateCustomer($customer);
+
+ $this->dispatcher->dispatch(new CustomerCreatePreEvent($customer));
+ $this->repository->saveCustomer($customer);
+ $this->dispatcher->dispatch(new CustomerCreatePostEvent($customer));
+
+ return $customer;
+ }
+
+ /**
+ * @param Customer $customer
+ * @param string[] $groups
+ * @throws ValidationFailedException
+ */
+ private function validateCustomer(Customer $customer, array $groups = []): void
+ {
+ $errors = $this->validator->validate($customer, null, $groups);
+
+ if ($errors->count() > 0) {
+ throw new ValidationFailedException($errors, 'Validation Failed');
+ }
+ }
+
+ public function updateCustomer(Customer $customer): Customer
+ {
+ $this->validateCustomer($customer);
+
+ $this->dispatcher->dispatch(new CustomerUpdatePreEvent($customer));
+ $this->repository->saveCustomer($customer);
+ $this->dispatcher->dispatch(new CustomerUpdatePostEvent($customer));
+
+ return $customer;
+ }
+
+ public function findCustomerByName(string $name): ?Customer
+ {
+ return $this->repository->findOneBy(['name' => $name]);
+ }
+
+ public function findCustomerByNumber(string $number): ?Customer
+ {
+ return $this->repository->findOneBy(['number' => $number]);
+ }
+}
diff --git a/src/DataFixtures/CustomerFixtures.php b/src/DataFixtures/CustomerFixtures.php
index b9fab31a..99389956 100644
--- a/src/DataFixtures/CustomerFixtures.php
+++ b/src/DataFixtures/CustomerFixtures.php
@@ -69,7 +69,7 @@ class CustomerFixtures extends Fixture
}
$manager->flush();
- $manager->clear();
+ $manager->clear(Activity::class);
}
$amountGlobalActivities = rand(self::MIN_GLOBAL_ACTIVITIES, self::MAX_GLOBAL_ACTIVITIES);
@@ -80,7 +80,9 @@ class CustomerFixtures extends Fixture
}
$manager->flush();
- $manager->clear();
+ $manager->clear(Activity::class);
+ $manager->clear(Project::class);
+ $manager->clear(Customer::class);
}
/**
diff --git a/src/DataFixtures/TeamFixtures.php b/src/DataFixtures/TeamFixtures.php
index 77486345..27f7dc6f 100644
--- a/src/DataFixtures/TeamFixtures.php
+++ b/src/DataFixtures/TeamFixtures.php
@@ -32,9 +32,6 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
public const MAX_USERS_PER_TEAM = 15;
public const MAX_PROJECTS_PER_TEAM = 5;
- // lower batch size, as user preferences are added in the same run
- public const BATCH_SIZE = 50;
-
/**
* @return class-string[]
*/
@@ -127,11 +124,6 @@ class TeamFixtures extends Fixture implements DependentFixtureInterface
}
$manager->persist($team);
-
- if ($i % self::BATCH_SIZE === 0) {
- $manager->flush();
- $manager->clear(Team::class);
- }
}
$manager->flush();
diff --git a/src/DataFixtures/TimesheetFixtures.php b/src/DataFixtures/TimesheetFixtures.php
index 4d348801..2e375555 100644
--- a/src/DataFixtures/TimesheetFixtures.php
+++ b/src/DataFixtures/TimesheetFixtures.php
@@ -40,9 +40,11 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
public const TIMERANGE_RUNNING = 1047; // in minutes = 17:45 hours
public const MIN_MINUTES_PER_ENTRY = 15;
public const MAX_MINUTES_PER_ENTRY = 840; // 14h
- public const MAX_TAG_PER_ENTRY = 3;
public const MAX_DESCRIPTION_LENGTH = 500;
+ public const ADD_TAGS_MAX_ENTRIES = 10000;
+ public const MAX_TAG_PER_ENTRY = 3;
+
public const BATCH_SIZE = 100;
/**
@@ -117,6 +119,8 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
null,
false
);
+
+ $all++;
$manager->persist($entry);
}
@@ -125,13 +129,16 @@ class TimesheetFixtures extends Fixture implements DependentFixtureInterface
}
$manager->flush();
- $entries = $manager->getRepository(Timesheet::class)->findAll();
- foreach ($entries as $temp) {
- $tagAmount = rand(0, self::MAX_TAG_PER_ENTRY);
- for ($iTag = 0; $iTag < $tagAmount; $iTag++) {
- $tagId = rand(1, TagFixtures::MAX_TAGS);
- if (isset($allTags[$tagId])) {
- $temp->addTag($allTags[$tagId]);
+ // TODO this breaks if too many records need to be loaded: find a better way of adding tags
+ if ($all < self::ADD_TAGS_MAX_ENTRIES) {
+ $entries = $manager->getRepository(Timesheet::class)->findAll();
+ foreach ($entries as $temp) {
+ $tagAmount = rand(0, self::MAX_TAG_PER_ENTRY);
+ for ($iTag = 0; $iTag < $tagAmount; $iTag++) {
+ $tagId = rand(1, TagFixtures::MAX_TAGS);
+ if (isset($allTags[$tagId])) {
+ $temp->addTag($allTags[$tagId]);
+ }
}
}
}
diff --git a/src/DataFixtures/UserFixtures.php b/src/DataFixtures/UserFixtures.php
index a1a83652..0797275c 100644
--- a/src/DataFixtures/UserFixtures.php
+++ b/src/DataFixtures/UserFixtures.php
@@ -41,9 +41,6 @@ class UserFixtures extends Fixture
public const MIN_RATE = 30;
public const MAX_RATE = 120;
- // lower batch size, as user preferences are added in the same run
- public const BATCH_SIZE = 50;
-
/**
* @var UserPasswordEncoderInterface
*/
@@ -88,14 +85,18 @@ class UserFixtures extends Fixture
->setEnabled($userData[6])
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
->setApiToken($passwordEncoder->encodePassword($user, self::DEFAULT_API_TOKEN))
- ->setPreferences($this->getUserPreferences($user, $userData[7]))
;
-
$manager->persist($user);
+
+ $prefs = $this->getUserPreferences($user, $userData[7]);
+ $user->setPreferences($prefs);
+ $manager->persist($prefs[0]);
+ $manager->persist($prefs[1]);
}
$manager->flush();
- $manager->clear();
+ $manager->clear(User::class);
+ $manager->clear(UserPreference::class);
}
/**
@@ -160,19 +161,17 @@ class UserFixtures extends Fixture
->setRoles([User::ROLE_USER])
->setEnabled(true)
->setPassword($passwordEncoder->encodePassword($user, self::DEFAULT_PASSWORD))
- ->setPreferences($this->getUserPreferences($user))
;
-
$manager->persist($user);
- if ($i % self::BATCH_SIZE === 0) {
- $manager->flush();
- $manager->clear();
- }
+ $prefs = $this->getUserPreferences($user);
+ $user->setPreferences($prefs);
+ $manager->persist($prefs[0]);
}
$manager->flush();
- $manager->clear();
+ $manager->clear(User::class);
+ $manager->clear(UserPreference::class);
}
/**
diff --git a/src/Entity/MetaTableTypeTrait.php b/src/Entity/MetaTableTypeTrait.php
index 5562f86b..507860a4 100644
--- a/src/Entity/MetaTableTypeTrait.php
+++ b/src/Entity/MetaTableTypeTrait.php
@@ -214,15 +214,21 @@ trait MetaTableTypeTrait
public function merge(MetaTableTypeInterface $meta): MetaTableTypeInterface
{
$this
- ->setType($meta->getType())
->setConstraints($meta->getConstraints())
->setIsRequired($meta->isRequired())
->setIsVisible($meta->isVisible())
- ->setLabel($meta->getLabel())
->setOptions($meta->getOptions())
->setOrder($meta->getOrder())
;
+ if ($meta->getLabel() !== null) {
+ $this->setLabel($meta->getLabel());
+ }
+
+ if ($meta->getType() !== null) {
+ $this->setType($meta->getType());
+ }
+
return $this;
}
diff --git a/src/Event/AbstractCustomerEvent.php b/src/Event/AbstractCustomerEvent.php
new file mode 100644
index 00000000..9c4281ec
--- /dev/null
+++ b/src/Event/AbstractCustomerEvent.php
@@ -0,0 +1,34 @@
+customer = $customer;
+ }
+
+ public function getCustomer(): Customer
+ {
+ return $this->customer;
+ }
+}
diff --git a/src/Event/CustomerCreateEvent.php b/src/Event/CustomerCreateEvent.php
new file mode 100644
index 00000000..06a0065c
--- /dev/null
+++ b/src/Event/CustomerCreateEvent.php
@@ -0,0 +1,17 @@
+eventDispatcher = $dispatcher;
$this->voter = $voter;
- $this->formConfig = $formConfig;
+ $this->configuration = $formConfig;
}
public static function getSubscribedEvents(): array
@@ -59,22 +59,22 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
private function getDefaultTheme(): ?string
{
- return $this->formConfig->getUserDefaultTheme();
+ return $this->configuration->getUserDefaultTheme();
}
private function getDefaultCurrency(): string
{
- return $this->formConfig->getUserDefaultCurrency();
+ return $this->configuration->getUserDefaultCurrency();
}
private function getDefaultLanguage(): string
{
- return $this->formConfig->getUserDefaultLanguage();
+ return $this->configuration->getUserDefaultLanguage();
}
private function getDefaultTimezone(): string
{
- $timezone = $this->formConfig->getUserDefaultTimezone();
+ $timezone = $this->configuration->getUserDefaultTimezone();
if (null === $timezone) {
$timezone = date_default_timezone_get();
}
diff --git a/src/Importer/AbstractCustomerImporter.php b/src/Importer/AbstractCustomerImporter.php
new file mode 100644
index 00000000..1b656a6d
--- /dev/null
+++ b/src/Importer/AbstractCustomerImporter.php
@@ -0,0 +1,121 @@
+customerService = $repository;
+ }
+
+ protected function findCustomerByName(string $name): ?Customer
+ {
+ return $this->customerService->findCustomerByName($name);
+ }
+
+ protected function findCustomerByNumber(string $number): ?Customer
+ {
+ return $this->customerService->findCustomerByNumber($number);
+ }
+
+ public function convertEntryToCustomer(array $entry): Customer
+ {
+ $customer = $this->findCustomer($entry);
+
+ $this->mapEntryToCustomer($customer, $entry);
+
+ return $customer;
+ }
+
+ protected function createNewCustomer(string $name): Customer
+ {
+ $customer = $this->customerService->createNewCustomer();
+ $customer->setName(substr($name, 0, 149));
+
+ return $customer;
+ }
+
+ protected function findCustomer(array $entry): ?Customer
+ {
+ $name = $this->findCustomerName($entry);
+ $customer = $this->findCustomerByName($name);
+
+ if ($customer === null) {
+ $number = $this->findCustomerNumber($entry);
+ if ($number !== null) {
+ $customer = $this->findCustomerByNumber($number);
+ }
+ }
+
+ if ($customer === null) {
+ $customer = $this->createNewCustomer($name);
+ }
+
+ return $customer;
+ }
+
+ /**
+ * Find the unique customer name inside $entry.
+ *
+ * @param array $entry
+ * @return string
+ * @throws UnsupportedFormatException
+ */
+ protected function findCustomerName(array $entry): string
+ {
+ foreach ($entry as $name => $value) {
+ switch (strtolower($name)) {
+ case 'name':
+ if (!empty($value)) {
+ return $value;
+ }
+ }
+ }
+
+ throw new UnsupportedFormatException('Missing customer name, expected in column: "Name"');
+ }
+
+ /**
+ * Find the unique customer number inside $entry.
+ *
+ * @param array $entry
+ * @return string
+ * @throws UnsupportedFormatException
+ */
+ protected function findCustomerNumber(array $entry): ?string
+ {
+ foreach ($entry as $name => $value) {
+ switch (strtolower($name)) {
+ case 'number':
+ case 'account':
+ if (!empty($value)) {
+ return $value;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ /**
+ * Applies all supported values from $entry to $customer.
+ *
+ * @param Customer $customer
+ * @param array $entry
+ * @return mixed
+ */
+ abstract protected function mapEntryToCustomer(Customer $customer, array $entry);
+}
diff --git a/src/Importer/AbstractProjectImporter.php b/src/Importer/AbstractProjectImporter.php
new file mode 100644
index 00000000..90046a7f
--- /dev/null
+++ b/src/Importer/AbstractProjectImporter.php
@@ -0,0 +1,158 @@
+projectService = $projectService;
+ $this->customerService = $customerService;
+ }
+
+ protected function findProjectByName(string $name): ?Project
+ {
+ return $this->projectService->findProjectByName($name);
+ }
+
+ protected function findCustomerByName(string $name): ?Customer
+ {
+ return $this->customerService->findCustomerByName($name);
+ }
+
+ public function convertEntryToProject(array $entry): Project
+ {
+ $project = $this->findProject($entry);
+
+ $this->convertEntry($project, $entry);
+
+ return $project;
+ }
+
+ protected function createNewProject(Customer $customer, string $name): Project
+ {
+ $project = $this->projectService->createNewProject($customer);
+ $project->setName($name);
+
+ return $project;
+ }
+
+ protected function findProject(array $entry): ?Project
+ {
+ $name = $this->findCustomerName($entry);
+ $customer = $this->findCustomer($name);
+
+ $name = $this->findProjectName($entry);
+ $project = $this->findProjectByName($name);
+
+ if ($project === null) {
+ $project = $this->createNewProject($customer, $name);
+ }
+
+ if ($customer->getId() !== $project->getCustomer()->getId()) {
+ throw new \InvalidArgumentException(
+ sprintf(
+ 'Customer mismatch for project %s with attached customer %s and new customer %s',
+ $project->getName(),
+ $project->getCustomer()->getName(),
+ $customer->getName()
+ )
+ );
+ }
+
+ return $project;
+ }
+
+ private function findCustomer(string $customerName): Customer
+ {
+ if (!\array_key_exists($customerName, $this->customerCache)) {
+ $customer = $this->findCustomerByName($customerName);
+
+ if ($customer === null) {
+ $customer = $this->customerService->createNewCustomer();
+ $customer->setName($customerName);
+ }
+
+ $this->customerCache[$customerName] = $customer;
+ }
+
+ return $this->customerCache[$customerName];
+ }
+
+ /**
+ * Find the unique project name inside $entry.
+ *
+ * @param array $entry
+ * @return string
+ * @throws UnsupportedFormatException
+ */
+ protected function findProjectName(array $entry): string
+ {
+ foreach ($entry as $name => $value) {
+ switch (strtolower($name)) {
+ case 'project':
+ case 'projectname':
+ case 'project name':
+ case 'project-name':
+ case 'name':
+ if (!empty($value)) {
+ return substr($value, 0, 149);
+ }
+ }
+ }
+
+ throw new UnsupportedFormatException('Missing project name, expected in one of the columns: "Name", "Project , "Project Name", "Project-Name", "ProjectName"');
+ }
+
+ /**
+ * Find the unique project name inside $entry.
+ *
+ * @param array $entry
+ * @return string
+ * @throws UnsupportedFormatException
+ */
+ protected function findCustomerName(array $entry): string
+ {
+ foreach ($entry as $name => $value) {
+ switch (strtolower($name)) {
+ case 'customer':
+ case 'customername':
+ case 'customer-name':
+ case 'customer name':
+ if (!empty($value)) {
+ return substr($value, 0, 149);
+ }
+ }
+ }
+
+ throw new UnsupportedFormatException('Missing customer name, expected in one of the columns: "Customer", "Customer Name", "Customer-Name" or "CustomerName"');
+ }
+
+ /**
+ * Applies all supported values from $entry to $project.
+ *
+ * @param Project $project
+ * @param array $entry
+ * @return mixed
+ */
+ abstract protected function convertEntry(Project $project, array $entry);
+}
diff --git a/src/Importer/CsvReader.php b/src/Importer/CsvReader.php
new file mode 100644
index 00000000..6aac241e
--- /dev/null
+++ b/src/Importer/CsvReader.php
@@ -0,0 +1,35 @@
+delimiter = $delimiter;
+ }
+
+ public function read(string $input): \Iterator
+ {
+ if (!is_readable($input)) {
+ throw new ImportNotFoundException();
+ }
+
+ $csv = Reader::createFromPath($input, 'r');
+ $csv->setDelimiter($this->delimiter);
+ $csv->setHeaderOffset(0);
+
+ return $csv->getRecords();
+ }
+}
diff --git a/src/Importer/CustomerImporterInterface.php b/src/Importer/CustomerImporterInterface.php
new file mode 100644
index 00000000..78f047ac
--- /dev/null
+++ b/src/Importer/CustomerImporterInterface.php
@@ -0,0 +1,17 @@
+ $value) {
+ switch (strtolower($name)) {
+ case 'name':
+ $customer->setName(substr($value, 0, 149));
+ if (empty($customer->getCompany())) {
+ $customer->setCompany($value);
+ }
+ break;
+
+ case 'company':
+ case 'company-name':
+ case 'company name':
+ $customer->setCompany($value);
+ break;
+
+ case 'email':
+ case 'e-mail':
+ case 'e mail':
+ if (!empty($value)) {
+ $customer->setEmail($value);
+ }
+ break;
+
+ case 'country':
+ if (!empty($value)) {
+ $customer->setCountry($value);
+ }
+ break;
+
+ case 'number':
+ case 'account':
+ case 'customer number':
+ case 'customer account':
+ if (!empty($value)) {
+ $customer->setNumber($value);
+ }
+ break;
+
+ case 'vat':
+ case 'vat-id':
+ case 'vat id':
+ case 'tax-id':
+ case 'tax id':
+ if (!empty($value)) {
+ $customer->setVatId($value);
+ }
+ break;
+
+ case 'comment':
+ case 'description':
+ if (!empty($value)) {
+ $customer->setComment($value);
+ }
+ break;
+
+ case 'address':
+ if (!empty($value)) {
+ $customer->setAddress($value);
+ }
+ break;
+
+ case 'contact':
+ if (!empty($value)) {
+ $customer->setContact($value);
+ }
+ break;
+
+ case 'currency':
+ if (!empty($value)) {
+ $customer->setCurrency($value);
+ }
+ break;
+
+ case 'timezone':
+ if (!empty($value)) {
+ $customer->setTimezone($value);
+ }
+ break;
+
+ case 'phone':
+ if (!empty($value)) {
+ $customer->setPhone($value);
+ }
+ break;
+
+ case 'mobile':
+ if (!empty($value)) {
+ $customer->setMobile($value);
+ }
+ break;
+
+ case 'fax':
+ if (!empty($value)) {
+ $customer->setFax($value);
+ }
+ break;
+
+ case 'homepage':
+ if (!empty($value)) {
+ $customer->setHomepage($value);
+ }
+ break;
+
+ case 'color':
+ if (!empty($value)) {
+ $customer->setColor($value);
+ }
+ break;
+
+ case 'visible':
+ if ($value !== '') {
+ $customer->setVisible((bool) $value);
+ }
+ break;
+
+ case 'budget':
+ if (!empty($value)) {
+ $customer->setBudget($value);
+ }
+ break;
+
+ case 'time budget':
+ case 'time-budget':
+ if (!empty($value)) {
+ $customer->setTimeBudget($value);
+ }
+ break;
+
+ default:
+ if (stripos($name, 'meta.') === 0) {
+ $tmpName = str_replace('meta.', '', $name);
+ $meta = new CustomerMeta();
+ $meta->setIsVisible(true);
+ $meta->setName($tmpName);
+ $meta->setValue($value);
+ $customer->setMetaField($meta);
+ }
+ break;
+ }
+ }
+
+ return $customer;
+ }
+}
diff --git a/src/Importer/DefaultProjectImporter.php b/src/Importer/DefaultProjectImporter.php
new file mode 100644
index 00000000..e01ccea6
--- /dev/null
+++ b/src/Importer/DefaultProjectImporter.php
@@ -0,0 +1,90 @@
+ $value) {
+ switch (strtolower($name)) {
+ case 'name':
+ $project->setName(substr($value, 0, 149));
+ break;
+
+ case 'comment':
+ case 'description':
+ if (!empty($value)) {
+ $project->setComment($value);
+ }
+ break;
+
+ case 'ordernumber':
+ case 'order-number':
+ case 'order number':
+ if (!empty($value)) {
+ $project->setOrderNumber($value);
+ }
+ break;
+
+ case 'orderdate':
+ case 'order-date':
+ case 'order date':
+ if (!empty($value)) {
+ $timezone = $project->getCustomer()->getTimezone();
+ $timezone = new \DateTimeZone($timezone ?? date_default_timezone_get());
+ $project->setOrderDate(new \DateTime($value, $timezone));
+ }
+ break;
+
+ case 'color':
+ if (!empty($value)) {
+ $project->setColor($value);
+ }
+ break;
+
+ case 'budget':
+ if (!empty($value)) {
+ $project->setBudget($value);
+ }
+ break;
+
+ case 'time budget':
+ case 'time-budget':
+ if (!empty($value)) {
+ $project->setTimeBudget($value);
+ }
+ break;
+
+ case 'visible':
+ if ($value !== '') {
+ $project->setVisible((bool) $value);
+ }
+ break;
+
+ default:
+ if (stripos($name, 'meta.') === 0) {
+ $tmpName = str_replace('meta.', '', $name);
+ $meta = new ProjectMeta();
+ $meta->setIsVisible(true);
+ $meta->setName($tmpName);
+ $meta->setValue($value);
+ $project->setMetaField($meta);
+ }
+ break;
+ }
+ }
+
+ return $project;
+ }
+}
diff --git a/src/Importer/GrandtotalCustomerImporter.php b/src/Importer/GrandtotalCustomerImporter.php
new file mode 100644
index 00000000..9530c72d
--- /dev/null
+++ b/src/Importer/GrandtotalCustomerImporter.php
@@ -0,0 +1,159 @@
+ $value) {
+ switch (strtolower($name)) {
+ case 'organization':
+ case 'firma':
+ if (!empty($value)) {
+ return $value;
+ }
+ }
+ }
+
+ throw new UnsupportedFormatException('Missing customer name, expected in one of the columns: "Organization", "Firma"');
+ }
+
+ protected function findCustomerNumber(array $row): ?string
+ {
+ foreach ($row as $name => $value) {
+ switch (strtolower($name)) {
+ case 'customer number':
+ case 'kundennummer':
+ if (!empty($value)) {
+ return $value;
+ }
+ }
+ }
+
+ return null;
+ }
+
+ protected function mapEntryToCustomer(Customer $customer, array $row)
+ {
+ $names = ['first' => '', 'middle' => '', 'last' => '', 'title' => ''];
+ $address = ['street' => '', 'city' => '', 'code' => ''];
+
+ foreach ($row as $name => $value) {
+ switch (strtolower($name)) {
+ case 'department':
+ case 'abteilung':
+
+ case 'salutation':
+ case 'briefanrede':
+
+ case 'state':
+ case 'bundesland':
+
+ case 'iban':
+ case 'bic':
+
+ case 'sepa mandate id':
+ case 'sepa mandat':
+ // not supported in Kimai
+ break;
+
+ case 'organization':
+ case 'firma':
+ $customer->setCompany($value);
+ break;
+
+ case 'e-mail':
+ if (!empty($value)) {
+ $customer->setEmail($value);
+ }
+ break;
+
+ case 'country':
+ case 'land':
+ if (!empty($value)) {
+ $customer->setCountry($value);
+ }
+ break;
+
+ case 'customer number':
+ case 'kundennummer':
+ if (!empty($value)) {
+ $customer->setNumber($value);
+ }
+ break;
+
+ case 'tax-id':
+ case 'umsatzsteuer':
+ if (!empty($value)) {
+ $customer->setVatId($value);
+ }
+ break;
+
+ case 'note':
+ case 'notiz':
+ if (!empty($value)) {
+ $customer->setComment(strip_tags($value));
+ }
+ break;
+
+ case 'title':
+ case 'titel':
+ $names['title'] = $value;
+ break;
+
+ case 'first name':
+ case 'vorname':
+ $names['first'] = $value;
+ break;
+
+ case 'middle name':
+ case 'zweiter vorname':
+ $names['middle'] = $value;
+ break;
+
+ case 'last name':
+ case 'nachname':
+ $names['last'] = $value;
+ break;
+
+ case 'street':
+ case 'straße':
+ $address['street'] = $value;
+ break;
+
+ case 'zip':
+ case 'plz':
+ $address['code'] = $value;
+ break;
+
+ case 'city':
+ case 'ort':
+ $address['city'] = $value;
+ break;
+ }
+ }
+
+ $calculatedAddress = trim($address['street'] . PHP_EOL . $address['code'] . ' ' . $address['city']);
+ $calculatedContact = trim(str_replace(' ', ' ', $names['title'] . ' ' . $names['first'] . ' ' . $names['middle'] . ' ' . $names['last']));
+
+ if (!empty($calculatedAddress)) {
+ $customer->setAddress($calculatedAddress);
+ }
+
+ if (!empty($calculatedContact)) {
+ $customer->setContact($calculatedContact);
+ }
+
+ return $customer;
+ }
+}
diff --git a/src/Importer/ImportNotFoundException.php b/src/Importer/ImportNotFoundException.php
new file mode 100644
index 00000000..cdc93f19
--- /dev/null
+++ b/src/Importer/ImportNotFoundException.php
@@ -0,0 +1,14 @@
+customers = $customers;
+ $this->projects = $projects;
+ }
+
+ public function registerProjectImporter(string $name, ProjectImporterInterface $importer): void
+ {
+ $this->projectImporter[$name] = $importer;
+ }
+
+ public function registerCustomerImporter(string $name, CustomerImporterInterface $importer): void
+ {
+ $this->customerImporter[$name] = $importer;
+ }
+
+ public function getProjectImporter(string $name): ProjectImporterInterface
+ {
+ if (!\array_key_exists('default', $this->projectImporter)) {
+ $this->registerProjectImporter('default', new DefaultProjectImporter($this->projects, $this->customers));
+ }
+
+ if (!\array_key_exists($name, $this->projectImporter)) {
+ throw new \InvalidArgumentException('Unknown project importer: ' . $name);
+ }
+
+ return $this->projectImporter[$name];
+ }
+
+ public function getCustomerImporter(string $name): CustomerImporterInterface
+ {
+ if (!\array_key_exists('default', $this->customerImporter)) {
+ $this->registerCustomerImporter('default', new DefaultCustomerImporter($this->customers));
+ $this->registerCustomerImporter('grandtotal', new GrandtotalCustomerImporter($this->customers));
+ }
+
+ if (!\array_key_exists($name, $this->customerImporter)) {
+ throw new \InvalidArgumentException('Unknown customer importer: ' . $name);
+ }
+
+ return $this->customerImporter[$name];
+ }
+
+ public function importProject(Project $project): void
+ {
+ if ($project->getId() === null) {
+ $this->projects->saveNewProject($project);
+ } else {
+ $this->projects->updateProject($project);
+ }
+ }
+
+ public function getReader(string $name): ImportReaderInterface
+ {
+ switch ($name) {
+ case 'csv':
+ return new CsvReader(',');
+ case 'csv-semicolon':
+ return new CsvReader(';');
+ }
+
+ throw new \InvalidArgumentException('Unknown import reader: ' . $name);
+ }
+
+ public function importCustomer(Customer $customer): void
+ {
+ if ($customer->getId() === null) {
+ $this->customers->saveNewCustomer($customer);
+ } else {
+ $this->customers->updateCustomer($customer);
+ }
+ }
+}
diff --git a/src/Importer/ProjectImporterInterface.php b/src/Importer/ProjectImporterInterface.php
new file mode 100644
index 00000000..16006ac3
--- /dev/null
+++ b/src/Importer/ProjectImporterInterface.php
@@ -0,0 +1,17 @@
+repository = $projectRepository;
$this->dispatcher = $dispatcher;
$this->validator = $validator;
@@ -101,4 +98,9 @@ final class ProjectService
return $project;
}
+
+ public function findProjectByName(string $name): ?Project
+ {
+ return $this->repository->findOneBy(['name' => $name]);
+ }
}
diff --git a/src/Repository/ConfigurationRepository.php b/src/Repository/ConfigurationRepository.php
index 3d2e4012..60f4cc4b 100644
--- a/src/Repository/ConfigurationRepository.php
+++ b/src/Repository/ConfigurationRepository.php
@@ -20,24 +20,25 @@ use Doctrine\ORM\ORMException;
*/
class ConfigurationRepository extends EntityRepository implements ConfigLoaderInterface
{
- private static $cacheByPrefix = null;
+ private static $cacheByPrefix = [];
private static $cacheAll = [];
+ private static $initialized = false;
public function clearCache()
{
- static::$cacheByPrefix = null;
- static::$cacheAll = null;
+ static::$cacheByPrefix = [];
+ static::$cacheAll = [];
+ static::$initialized = false;
}
private function prefillCache()
{
- if (null !== static::$cacheByPrefix) {
+ if (static::$initialized === true) {
return;
}
/** @var Configuration[] $configs */
$configs = $this->findAll();
- static::$cacheByPrefix = [];
foreach ($configs as $config) {
$key = substr($config->getName(), 0, strpos($config->getName(), '.'));
if (!\array_key_exists($key, static::$cacheByPrefix)) {
@@ -46,6 +47,7 @@ class ConfigurationRepository extends EntityRepository implements ConfigLoaderIn
static::$cacheByPrefix[$key][] = $config;
static::$cacheAll[] = $config;
}
+ static::$initialized = true;
}
public function saveConfiguration(Configuration $configuration)
diff --git a/tests/Command/ImportCustomerCommandTest.php b/tests/Command/ImportCustomerCommandTest.php
index 1fca83e3..ac153bd1 100644
--- a/tests/Command/ImportCustomerCommandTest.php
+++ b/tests/Command/ImportCustomerCommandTest.php
@@ -10,16 +10,12 @@
namespace App\Tests\Command;
use App\Command\ImportCustomerCommand;
-use App\Configuration\FormConfiguration;
-use App\Repository\CustomerRepository;
-use App\Repository\ProjectRepository;
-use App\Repository\TeamRepository;
-use App\Repository\UserRepository;
+use App\Importer\ImporterService;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
+use Symfony\Component\Console\Tester\CommandTester;
/**
- * @covers \App\Command\ImportCustomerCommand
* @group integration
*/
class ImportCustomerCommandTest extends KernelTestCase
@@ -33,14 +29,11 @@ class ImportCustomerCommandTest extends KernelTestCase
{
$kernel = self::bootKernel();
$this->application = new Application($kernel);
+ $container = self::$kernel->getContainer();
- $customers = $this->createMock(CustomerRepository::class);
- $projects = $this->createMock(ProjectRepository::class);
- $teams = $this->createMock(TeamRepository::class);
- $users = $this->createMock(UserRepository::class);
- $configuration = $this->createMock(FormConfiguration::class);
+ $importer = $container->get(ImporterService::class);
- $this->application->add(new ImportCustomerCommand($customers, $projects, $teams, $users, $configuration));
+ $this->application->add(new ImportCustomerCommand($importer));
}
public function testCommandName()
@@ -48,4 +41,184 @@ class ImportCustomerCommandTest extends KernelTestCase
$command = $this->application->find('kimai:import:customer');
self::assertInstanceOf(ImportCustomerCommand::class, $command);
}
+
+ public function testImportWithMissingFile()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/foo_bar.csv1'
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Kimai importer: Customers', $result);
+ self::assertStringContainsString('[ERROR] File not existing or not readable', $result);
+ self::assertStringContainsString('_data/foo_bar', $result);
+
+ self::assertEquals(2, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportWithUnknownReader()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/customers2.csv',
+ '--reader' => 'fooo',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('[ERROR] Unknown import reader: fooo', $result);
+
+ self::assertEquals(1, $commandTester->getStatusCode());
+ }
+
+ public function testImportWithUnknownImporter()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/customers2.csv',
+ '--importer' => 'fooo',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('[ERROR] Unknown customer importer: fooo', $result);
+
+ self::assertEquals(1, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImport()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/customers.csv'
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 10 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 10 customers, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 9 customer', $result);
+ self::assertStringContainsString('[OK] Updated 1 customer', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportSkipUpdate()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/customers.csv',
+ '--no-update' => true,
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 10 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 10 customers, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 9 customer', $result);
+ self::assertStringContainsString('[OK] Skipped 1 existing customer', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportWithSemicolon()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/customers2.csv',
+ '--importer' => 'default',
+ '--reader' => 'csv-semicolon',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 10 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 10 customers, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 10 customer', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
+
+ public function testGrandtotalImportWithInvalidCsvFile()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/customers2.csv',
+ '--importer' => 'grandtotal',
+ '--reader' => 'csv-semicolon',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Invalid row 1: Missing customer name', $result);
+ self::assertStringContainsString('! [CAUTION] Not importing, previous 10 errors need to be fixed first.', $result);
+
+ self::assertEquals(3, $commandTester->getStatusCode());
+ }
+
+ public function testGrandtotalImport()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/grandtotal_en.csv',
+ '--importer' => 'grandtotal',
+ '--reader' => 'csv-semicolon',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 1 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 1 customers, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 1 customer', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
+
+ public function testGrandtotalImportGerman()
+ {
+ $command = $this->application->find('kimai:import:customer');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/grandtotal_de.csv',
+ '--importer' => 'grandtotal',
+ '--reader' => 'csv-semicolon',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 2 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 2 customers, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 1 customer', $result);
+ self::assertStringContainsString('[OK] Updated 1 customer', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
}
diff --git a/tests/Command/ImportProjectCommandTest.php b/tests/Command/ImportProjectCommandTest.php
new file mode 100644
index 00000000..1437fc17
--- /dev/null
+++ b/tests/Command/ImportProjectCommandTest.php
@@ -0,0 +1,250 @@
+application = new Application($kernel);
+ $container = self::$kernel->getContainer();
+
+ $importer = $container->get(ImporterService::class);
+ $teams = $this->createMock(TeamRepository::class);
+ /** @var UserRepository $users */
+ $users = $container->get(UserRepository::class);
+
+ $this->application->add(new ImportProjectCommand($importer, $teams, $users));
+ }
+
+ public function testCommandName()
+ {
+ $command = $this->application->find('kimai:import:project');
+ self::assertInstanceOf(ImportProjectCommand::class, $command);
+ }
+
+ public function testImportWithMissingFile()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/foo_bar.csv1'
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Kimai importer: Projects', $result);
+ self::assertStringContainsString('[ERROR] File not existing or not readable', $result);
+ self::assertStringContainsString('_data/foo_bar', $result);
+
+ self::assertEquals(2, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportWithUnknownReader()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/customers2.csv',
+ '--reader' => 'fooo',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('[ERROR] Unknown import reader: fooo', $result);
+
+ self::assertEquals(1, $commandTester->getStatusCode());
+ }
+
+ public function testImportWithUnknownImporter()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/customers2.csv',
+ '--importer' => 'grandtotal',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('[ERROR] Unknown project importer: grandtotal', $result);
+
+ self::assertEquals(1, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImport()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/projects.csv',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 3 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 3 projects, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 2 projects', $result);
+ self::assertStringContainsString('[OK] Updated 1 projects', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportSkipUpdate()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/projects.csv',
+ '--no-update' => true,
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 3 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 3 projects, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 2 projects', $result);
+ self::assertStringContainsString('[OK] Skipped 1 existing projects', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportWithInvalidCustomerMapping()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/projects_invalid.csv',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 3 rows to process, converting now ...', $result);
+ self::assertStringContainsString('[ERROR] Invalid row 2: Customer mismatch for project', $result);
+ self::assertStringContainsString('[CAUTION] Not importing, previous 1 errors need to be fixed first.', $result);
+
+ self::assertEquals(3, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportWithSemicolon()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/projects2.csv',
+ '--importer' => 'default',
+ '--reader' => 'csv-semicolon',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 39 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 39 projects, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 39 projects', $result);
+ self::assertStringContainsString('[OK] Imported 10 customers', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
+
+ public function testGrandtotalImportWithInvalidCsvFile()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/projects.csv',
+ '--reader' => 'csv-semicolon',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Invalid row 1: Missing customer name', $result);
+ self::assertStringContainsString('! [CAUTION] Not importing, previous 3 errors need to be fixed first.', $result);
+
+ self::assertEquals(3, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportWithSemicolonAndTeamlead()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/projects2.csv',
+ '--importer' => 'default',
+ '--reader' => 'csv-semicolon',
+ '--teamlead' => 'clara_customer',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('Found 39 rows to process, converting now ...', $result);
+ self::assertStringContainsString('Converted 39 projects, importing into Kimai now ...', $result);
+ self::assertStringContainsString('[OK] Imported 39 projects', $result);
+ self::assertStringContainsString('[OK] Imported 10 customers', $result);
+ self::assertStringContainsString('[OK] Created 39 teams', $result);
+
+ self::assertEquals(0, $commandTester->getStatusCode());
+ }
+
+ public function testDefaultImportWithSemicolonAndMissingTeamlead()
+ {
+ $command = $this->application->find('kimai:import:project');
+ $commandTester = new CommandTester($command);
+ $commandTester->setInputs(['no']);
+ $commandTester->execute([
+ 'command' => $command->getName(),
+ 'file' => __DIR__ . '/../Importer/_data/projects2.csv',
+ '--importer' => 'default',
+ '--reader' => 'csv-semicolon',
+ '--teamlead' => 'foobar',
+ ]);
+
+ $result = $commandTester->getDisplay();
+
+ self::assertStringContainsString('You requested to create empty teams for each project', $result);
+ self::assertStringContainsString('Please create a user with the name (or email) foobar', $result);
+
+ self::assertEquals(3, $commandTester->getStatusCode());
+ }
+}
diff --git a/tests/Command/ImportTimesheetCommandTest.php b/tests/Command/ImportTimesheetCommandTest.php
index 35d18368..a53c6366 100644
--- a/tests/Command/ImportTimesheetCommandTest.php
+++ b/tests/Command/ImportTimesheetCommandTest.php
@@ -10,7 +10,7 @@
namespace App\Tests\Command;
use App\Command\ImportTimesheetCommand;
-use App\Configuration\FormConfiguration;
+use App\Configuration\SystemConfiguration;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
@@ -42,7 +42,7 @@ class ImportTimesheetCommandTest extends KernelTestCase
$users = $this->createMock(UserRepository::class);
$tagRepository = $this->createMock(TagRepository::class);
$timesheets = $this->createMock(TimesheetRepository::class);
- $configuration = $this->createMock(FormConfiguration::class);
+ $configuration = $this->createMock(SystemConfiguration::class);
$this->application->add(new ImportTimesheetCommand($customers, $projects, $activities, $users, $tagRepository, $timesheets, $configuration));
}
diff --git a/tests/Configuration/FormConfigurationTest.php b/tests/Configuration/FormConfigurationTest.php
index 8a7814d2..b6e739fe 100644
--- a/tests/Configuration/FormConfigurationTest.php
+++ b/tests/Configuration/FormConfigurationTest.php
@@ -16,6 +16,7 @@ use PHPUnit\Framework\TestCase;
/**
* @covers \App\Configuration\FormConfiguration
* @covers \App\Configuration\StringAccessibleConfigTrait
+ * @group legacy
*/
class FormConfigurationTest extends TestCase
{
@@ -34,6 +35,13 @@ class FormConfigurationTest extends TestCase
'currency' => 'GBP',
'country' => 'FR',
],
+ 'user' => [
+ 'timezone' => 'Europe/London',
+ 'currency' => 'GBP',
+ 'country' => 'FR',
+ 'language' => 'it',
+ 'theme' => 'blue',
+ ],
];
}
@@ -43,6 +51,11 @@ class FormConfigurationTest extends TestCase
(new Configuration())->setName('defaults.customer.timezone')->setValue('Russia/Moscov'),
(new Configuration())->setName('defaults.customer.currency')->setValue('USD'),
(new Configuration())->setName('defaults.customer.country')->setValue('RU'),
+ (new Configuration())->setName('defaults.user.timezone')->setValue('Russia/Moscov'),
+ (new Configuration())->setName('defaults.user.currency')->setValue('USD'),
+ (new Configuration())->setName('defaults.user.language')->setValue('RU'),
+ (new Configuration())->setName('defaults.user.country')->setValue('RU'),
+ (new Configuration())->setName('defaults.user.theme')->setValue('black'),
];
}
@@ -66,6 +79,11 @@ class FormConfigurationTest extends TestCase
$this->assertEquals('Russia/Moscov', $sut->getCustomerDefaultTimezone());
$this->assertEquals('USD', $sut->getCustomerDefaultCurrency());
$this->assertEquals('RU', $sut->getCustomerDefaultCountry());
+ $this->assertEquals('USD', $sut->getUserDefaultCurrency());
+ $this->assertEquals('RU', $sut->getUserDefaultLanguage());
+ $this->assertEquals('black', $sut->getUserDefaultTheme());
+ $this->assertEquals('Russia/Moscov', $sut->getUserDefaultTimezone());
+ $this->assertEquals('Russia/Moscov', $sut->offsetGet('defaults.user.timezone'));
}
public function testDefaultWithMixedConfigs()
diff --git a/tests/Configuration/SystemConfigurationTest.php b/tests/Configuration/SystemConfigurationTest.php
index 064184f6..63d8e11f 100644
--- a/tests/Configuration/SystemConfigurationTest.php
+++ b/tests/Configuration/SystemConfigurationTest.php
@@ -51,6 +51,12 @@ class SystemConfigurationTest extends TestCase
'currency' => 'GBP',
'country' => 'FR',
],
+ 'user' => [
+ 'timezone' => 'foo/bar',
+ 'theme' => 'blue',
+ 'language' => 'IT',
+ 'currency' => 'USD',
+ ],
],
'calendar' => [
'businessHours' => [
@@ -164,4 +170,28 @@ class SystemConfigurationTest extends TestCase
$sources = $sut->getCalendarGoogleSources();
$this->assertEquals(2, \count($sources));
}
+
+ public function testFormDefaultWithoutLoader()
+ {
+ $sut = $this->getSut($this->getDefaultSettings(), []);
+ $this->assertEquals('Europe/London', $sut->getCustomerDefaultTimezone());
+ $this->assertEquals('GBP', $sut->getCustomerDefaultCurrency());
+ $this->assertEquals('FR', $sut->getCustomerDefaultCountry());
+ $this->assertEquals('foo/bar', $sut->getUserDefaultTimezone());
+ $this->assertEquals('blue', $sut->getUserDefaultTheme());
+ $this->assertEquals('IT', $sut->getUserDefaultLanguage());
+ $this->assertEquals('USD', $sut->getUserDefaultCurrency());
+ }
+
+ public function testFormDefaultWithLoader()
+ {
+ $sut = $this->getSut($this->getDefaultSettings(), $this->getDefaultLoaderSettings());
+ $this->assertEquals('Russia/Moscov', $sut->getCustomerDefaultTimezone());
+ $this->assertEquals('RUB', $sut->getCustomerDefaultCurrency());
+ $this->assertEquals('FR', $sut->getCustomerDefaultCountry());
+ $this->assertEquals('foo/bar', $sut->getUserDefaultTimezone());
+ $this->assertEquals('blue', $sut->getUserDefaultTheme());
+ $this->assertEquals('IT', $sut->getUserDefaultLanguage());
+ $this->assertEquals('USD', $sut->getUserDefaultCurrency());
+ }
}
diff --git a/tests/Configuration/TestConfigLoader.php b/tests/Configuration/TestConfigLoader.php
index 94aa206f..07fb6bb3 100644
--- a/tests/Configuration/TestConfigLoader.php
+++ b/tests/Configuration/TestConfigLoader.php
@@ -13,7 +13,7 @@ use App\Configuration\ConfigLoaderInterface;
use App\Entity\Configuration;
/**
- * @covers \App\Configuration\FormConfiguration
+ * @covers \App\Configuration\SystemConfiguration
*/
class TestConfigLoader implements ConfigLoaderInterface
{
diff --git a/tests/Controller/CalendarControllerTest.php b/tests/Controller/CalendarControllerTest.php
index 3db88202..3b2eb0c3 100644
--- a/tests/Controller/CalendarControllerTest.php
+++ b/tests/Controller/CalendarControllerTest.php
@@ -64,6 +64,11 @@ class CalendarControllerTest extends ControllerBaseTest
protected function getDefaultSettings()
{
return [
+ 'defaults' => [
+ 'user' => [
+ 'language' => 'en'
+ ],
+ ],
'timesheet' => [
'default_begin' => '08:30:00',
],
diff --git a/tests/Controller/ControllerBaseTest.php b/tests/Controller/ControllerBaseTest.php
index 894f89c3..f13f20cc 100644
--- a/tests/Controller/ControllerBaseTest.php
+++ b/tests/Controller/ControllerBaseTest.php
@@ -11,6 +11,7 @@ namespace App\Tests\Controller;
use App\DataFixtures\UserFixtures;
use App\Entity\User;
+use App\Repository\ConfigurationRepository;
use App\Tests\KernelTestTrait;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -26,6 +27,15 @@ abstract class ControllerBaseTest extends WebTestCase
public const DEFAULT_LANGUAGE = 'en';
+ protected function tearDown(): void
+ {
+ /** @var ConfigurationRepository $repository */
+ $repository = static::$kernel->getContainer()->get(ConfigurationRepository::class);
+ $repository->clearCache();
+
+ parent::tearDown();
+ }
+
protected function getClientForAuthenticatedUser(string $role = User::ROLE_USER): HttpKernelBrowser
{
switch ($role) {
diff --git a/tests/Controller/TimesheetControllerTest.php b/tests/Controller/TimesheetControllerTest.php
index 0d367978..30e3ff51 100644
--- a/tests/Controller/TimesheetControllerTest.php
+++ b/tests/Controller/TimesheetControllerTest.php
@@ -9,12 +9,10 @@
namespace App\Tests\Controller;
-use App\Entity\Configuration;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
use App\Form\Type\DateRangeType;
-use App\Repository\ConfigurationRepository;
use App\Tests\DataFixtures\TimesheetFixtures;
use App\Tests\Mocks\TimesheetTestMetaFieldSubscriberMock;
@@ -53,7 +51,6 @@ class TimesheetControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$start = new \DateTime('first day of this month');
- $em = $this->getEntityManager();
$fixture = new TimesheetFixtures();
$fixture->setAmount(5);
$fixture->setAmountRunning(2);
@@ -90,7 +87,6 @@ class TimesheetControllerTest extends ControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$start = new \DateTime('first day of this month');
- $em = $this->getEntityManager();
$fixture = new TimesheetFixtures();
$fixture->setAmount(5);
$fixture->setUser($this->getUserByRole(User::ROLE_USER));
@@ -127,7 +123,6 @@ class TimesheetControllerTest extends ControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser();
- $em = $this->getEntityManager();
$fixture = new TimesheetFixtures();
$fixture->setAmount(5);
$fixture->setUser($this->getUserByRole(User::ROLE_USER));
@@ -305,60 +300,54 @@ class TimesheetControllerTest extends ControllerBaseTest
public function testCreateActionWithFromAndToValuesTwiceFailsOnOverlappingRecord()
{
- $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
- $this->request($client, '/timesheet/create?from=2018-08-02T20%3A00%3A00&to=2018-08-02T20%3A30%3A00');
- $this->assertTrue($client->getResponse()->isSuccessful());
+ $client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
+ $this->assertAccessIsGranted($client, '/admin/system-config/');
- $form = $client->getCrawler()->filter('form[name=timesheet_edit_form]')->form();
+ $form = $client->getCrawler()->filter('form[name=system_configuration_form_timesheet]')->form();
$client->submit($form, [
- 'timesheet_edit_form' => [
- 'hourlyRate' => 100,
- 'project' => 1,
- 'activity' => 1,
+ 'system_configuration_form_timesheet' => [
+ 'configuration' => [
+ ['name' => 'timesheet.mode', 'value' => 'default'],
+ ['name' => 'timesheet.active_entries.default_begin', 'value' => '08:00'],
+ ['name' => 'timesheet.rules.allow_future_times', 'value' => true],
+ ['name' => 'timesheet.rules.allow_overlapping_records', 'value' => false],
+ ['name' => 'timesheet.rules.lockdown_period_start', 'value' => null],
+ ['name' => 'timesheet.rules.lockdown_period_end', 'value' => null],
+ ['name' => 'timesheet.rules.lockdown_grace_period', 'value' => null],
+ ['name' => 'timesheet.active_entries.hard_limit', 'value' => 1],
+ ['name' => 'timesheet.active_entries.soft_limit', 'value' => 1],
+ ]
]
]);
- $this->assertIsRedirect($client, $this->createUrl('/timesheet/'));
- $client->followRedirect();
- $this->assertTrue($client->getResponse()->isSuccessful());
- $this->assertHasFlashSuccess($client);
+ $client = $this->getClientForAuthenticatedUser();
- $em = $this->getEntityManager();
- /** @var Timesheet $timesheet */
- $timesheet = $em->getRepository(Timesheet::class)->find(1);
- $this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
- $this->assertInstanceOf(\DateTime::class, $timesheet->getEnd());
- $this->assertEquals(50, $timesheet->getRate());
+ $begin = new \DateTime('2018-08-02T20:00:00');
+ $end = new \DateTime('2018-08-02T20:30:00');
- $expected = new \DateTime('2018-08-02T20:00:00');
- $this->assertEquals($expected->format(\DateTime::ATOM), $timesheet->getBegin()->format(\DateTime::ATOM));
+ $fixture = new TimesheetFixtures();
+ $fixture->setCallback(function (Timesheet $timesheet) use ($begin, $end) {
+ $timesheet->setBegin($begin);
+ $timesheet->setEnd($end);
+ });
+ $fixture->setAmount(1);
+ $fixture->setUser($this->getUserByRole(User::ROLE_USER));
+ $this->importFixture($fixture);
- $expected = new \DateTime('2018-08-02T20:30:00');
- $this->assertEquals($expected->format(\DateTime::ATOM), $timesheet->getEnd()->format(\DateTime::ATOM));
-
- /** @var ConfigurationRepository $configurations */
- $configurations = $em->getRepository(Configuration::class);
- $config = new Configuration();
- $config->setName('timesheet.rules.allow_overlapping_records');
- $config->setValue(false);
- $configurations->saveConfiguration($config);
-
- // create a second entry that is overlapping fails due to the changed config above
+ // create a second entry that is overlapping - should fail due to the changed config above
$this->assertHasValidationError(
$client,
'/timesheet/create?from=2018-08-02T20%3A02%3A00&to=2018-08-02T20%3A20%3A00',
'form[name=timesheet_edit_form]',
[
'timesheet_edit_form' => [
- 'hourlyRate' => 100,
+ //'hourlyRate' => 100,
'project' => 1,
'activity' => 1,
]
],
['#timesheet_edit_form_begin']
);
-
- $configurations->clearCache();
}
public function testCreateActionWithBeginAndEndAndTagValues()
@@ -401,7 +390,6 @@ class TimesheetControllerTest extends ControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser();
- $em = $this->getEntityManager();
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
$fixture->setUser($this->getUserByRole(User::ROLE_USER));
@@ -442,7 +430,6 @@ class TimesheetControllerTest extends ControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
- $em = $this->getEntityManager();
$user = $this->getUserByRole(User::ROLE_USER);
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
@@ -481,7 +468,6 @@ class TimesheetControllerTest extends ControllerBaseTest
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
- $em = $this->getEntityManager();
$user = $this->getUserByRole(User::ROLE_SUPER_ADMIN);
$fixture = new TimesheetFixtures();
$fixture->setAmount(10);
diff --git a/tests/Customer/CustomerServiceTest.php b/tests/Customer/CustomerServiceTest.php
new file mode 100644
index 00000000..83d94131
--- /dev/null
+++ b/tests/Customer/CustomerServiceTest.php
@@ -0,0 +1,156 @@
+createMock(CustomerRepository::class);
+ }
+
+ if ($dispatcher === null) {
+ $dispatcher = $this->createMock(EventDispatcherInterface::class);
+ }
+
+ if ($validator === null) {
+ $validator = $this->createMock(ValidatorInterface::class);
+ $validator->method('validate')->willReturn(new ConstraintViolationList());
+ }
+
+ if ($configuration === null) {
+ $configuration = $this->createMock(SystemConfiguration::class);
+ $configuration->method('getCustomerDefaultTimezone')->willReturn('Europe/Vienna');
+ $configuration->method('getCustomerDefaultCountry')->willReturn('IN');
+ $configuration->method('getCustomerDefaultCurrency')->willReturn('RUB');
+ }
+
+ $service = new CustomerService($repository, $configuration, $validator, $dispatcher);
+
+ return $service;
+ }
+
+ public function testCannotSavePersistedCustomerAsNew()
+ {
+ $Customer = $this->createMock(Customer::class);
+ $Customer->expects($this->once())->method('getId')->willReturn(1);
+
+ $sut = $this->getSut();
+
+ $this->expectException(\InvalidArgumentException::class);
+ $this->expectExceptionMessage('Cannot create customer, already persisted');
+
+ $sut->saveNewCustomer($Customer);
+ }
+
+ public function testSaveNewCustomerHasValidationError()
+ {
+ $constraints = new ConstraintViolationList();
+ $constraints->add(new ConstraintViolation('toooo many tests', 'abc.def', [], '$root', 'begin', 4, null, null, null, '$cause'));
+
+ $validator = $this->createMock(ValidatorInterface::class);
+ $validator->method('validate')->willReturn($constraints);
+
+ $sut = $this->getSut(null, $validator);
+
+ $this->expectException(ValidationFailedException::class);
+ $this->expectExceptionMessage('Validation Failed');
+
+ $sut->saveNewCustomer(new Customer());
+ }
+
+ public function testUpdateDispatchesEvents()
+ {
+ $Customer = $this->createMock(Customer::class);
+ $Customer->method('getId')->willReturn(1);
+
+ $dispatcher = $this->createMock(EventDispatcherInterface::class);
+ $dispatcher->expects($this->exactly(2))->method('dispatch')->willReturnCallback(function ($event) use ($Customer) {
+ if ($event instanceof CustomerUpdatePostEvent) {
+ self::assertSame($Customer, $event->getCustomer());
+ } elseif ($event instanceof CustomerUpdatePreEvent) {
+ self::assertSame($Customer, $event->getCustomer());
+ } else {
+ $this->fail('Invalid event received');
+ }
+ });
+
+ $sut = $this->getSut($dispatcher);
+
+ $sut->updateCustomer($Customer);
+ }
+
+ public function testCreateNewCustomerDispatchesEvents()
+ {
+ $dispatcher = $this->createMock(EventDispatcherInterface::class);
+ $dispatcher->expects($this->exactly(2))->method('dispatch')->willReturnCallback(function ($event) {
+ if ($event instanceof CustomerMetaDefinitionEvent) {
+ self::assertInstanceOf(Customer::class, $event->getEntity());
+ } elseif ($event instanceof CustomerCreateEvent) {
+ self::assertInstanceOf(Customer::class, $event->getCustomer());
+ } else {
+ $this->fail('Invalid event received');
+ }
+ });
+
+ $sut = $this->getSut($dispatcher);
+
+ $customer = $sut->createNewCustomer();
+
+ self::assertInstanceOf(Customer::class, $customer);
+ self::assertEquals('Europe/Vienna', $customer->getTimezone());
+ self::assertEquals('IN', $customer->getCountry());
+ self::assertEquals('RUB', $customer->getCurrency());
+ }
+
+ public function testSaveNewCustomerDispatchesEvents()
+ {
+ $dispatcher = $this->createMock(EventDispatcherInterface::class);
+ $dispatcher->expects($this->exactly(2))->method('dispatch')->willReturnCallback(function ($event) {
+ if ($event instanceof CustomerCreatePreEvent) {
+ self::assertInstanceOf(Customer::class, $event->getCustomer());
+ } elseif ($event instanceof CustomerCreatePostEvent) {
+ self::assertInstanceOf(Customer::class, $event->getCustomer());
+ } else {
+ $this->fail('Invalid event received');
+ }
+ });
+
+ $sut = $this->getSut($dispatcher);
+
+ $Customer = new Customer();
+ $sut->saveNewCustomer($Customer);
+ }
+}
diff --git a/tests/DependencyInjection/AppExtensionTest.php b/tests/DependencyInjection/AppExtensionTest.php
index 89d07a21..2d7bd2ce 100644
--- a/tests/DependencyInjection/AppExtensionTest.php
+++ b/tests/DependencyInjection/AppExtensionTest.php
@@ -253,7 +253,6 @@ class AppExtensionTest extends TestCase
'kimai.i18n_domains' => []
];
- // nasty parameter, should be removed!!!
$this->assertTrue($container->hasParameter('kimai.config'));
foreach ($expected as $key => $value) {
diff --git a/tests/Event/AbstractCustomerEventTest.php b/tests/Event/AbstractCustomerEventTest.php
new file mode 100644
index 00000000..103ea04c
--- /dev/null
+++ b/tests/Event/AbstractCustomerEventTest.php
@@ -0,0 +1,29 @@
+createCustomerEvent($customer);
+
+ self::assertInstanceOf(Event::class, $sut);
+ self::assertSame($customer, $sut->getCustomer());
+ }
+}
diff --git a/tests/Event/CustomerCreateEventTest.php b/tests/Event/CustomerCreateEventTest.php
new file mode 100644
index 00000000..ca704224
--- /dev/null
+++ b/tests/Event/CustomerCreateEventTest.php
@@ -0,0 +1,26 @@
+expects($this->once())->method('isGranted')->willReturn($seeHourlyRate);
$eventMock = $this->createMock(EventDispatcherInterface::class);
- $formConfigMock = $this->createMock(FormConfiguration::class);
+ $formConfigMock = $this->createMock(SystemConfiguration::class);
return new UserPreferenceSubscriber($eventMock, $authMock, $formConfigMock);
}
diff --git a/tests/Importer/CsvReaderTest.php b/tests/Importer/CsvReaderTest.php
new file mode 100644
index 00000000..4a88203d
--- /dev/null
+++ b/tests/Importer/CsvReaderTest.php
@@ -0,0 +1,62 @@
+read(__DIR__ . '/_data/grandtotal_en.csv');
+ $result = iterator_to_array($result);
+ self::assertEquals([1 => [
+ 'Organization' => 'Keleo',
+ 'Department' => 'IT Abteilung',
+ 'Title' => 'Bc',
+ 'First name' => 'Kevin',
+ 'Middle name' => '',
+ 'Last name' => 'Papst',
+ 'E-Mail' => 'unknown@kimai.org',
+ 'Street' => 'Acme Street
+Downtown',
+ 'ZIP' => '1022',
+ 'City' => 'Vienna',
+ 'State' => '',
+ 'Salutation' => 'Sehr geehrter Herr',
+ 'Country' => 'DE',
+ 'Customer number' => '00001',
+ 'Tax-ID' => 'DE1234567890',
+ 'Note' => 'sakdjhfg laksjhdfasd f#asd
+ fas
+ dfasdfasdf
+ asdfasdfasdf',
+ 'IBAN' => '0987654321',
+ 'BIC' => '12345678',
+ 'SEPA Mandate ID' => '',
+ 'zusatz 1' => 'blub',
+ 'zusatz 2' => 'foo',
+ ]], $result);
+ }
+
+ public function testReadNotFound()
+ {
+ $this->expectException(ImportNotFoundException::class);
+
+ $sut = new CsvReader(';');
+ $sut->read(__DIR__ . '/_data/fffffoooooooooooo');
+ }
+}
diff --git a/tests/Importer/DefaultCustomerImporterTest.php b/tests/Importer/DefaultCustomerImporterTest.php
new file mode 100644
index 00000000..aab3b261
--- /dev/null
+++ b/tests/Importer/DefaultCustomerImporterTest.php
@@ -0,0 +1,90 @@
+createMock(CustomerService::class);
+ $customerService->expects($this->exactly($count))->method('createNewCustomer')->willReturnCallback(
+ function () {
+ return new Customer();
+ }
+ );
+
+ $sut = new DefaultCustomerImporter($customerService);
+
+ return $sut;
+ }
+
+ private function getDefaultImport(): array
+ {
+ return [
+ 'name' => 'Test customer',
+ ];
+ }
+
+ private function prepareCustomer(array $values = []): Customer
+ {
+ $sut = $this->getSut();
+
+ $import = array_merge($this->getDefaultImport(), $values);
+
+ return $sut->convertEntryToCustomer($import);
+ }
+
+ public function testImportMissingName()
+ {
+ $this->expectException(UnsupportedFormatException::class);
+ $this->expectExceptionMessage('Missing customer name, expected in column: "Name"');
+
+ return $this->getSut(0)->convertEntryToCustomer(['sdfgsdfgsdfg' => 'sdfgsdfg']);
+ }
+
+ public function testImport()
+ {
+ $customer = $this->prepareCustomer([]);
+ self::assertEquals('Test customer', $customer->getName());
+ }
+
+ public function testImportWithMultipleValues()
+ {
+ $customer = $this->prepareCustomer([
+ 'e mail' => 'test@example.com',
+ 'contact' => 'Foo Bar',
+ 'phone' => '0123 4567890',
+ 'mobile' => '111 354687',
+ 'fax' => '999 112233445566778899',
+ 'homepage' => 'www.example.com',
+ 'budget' => 1000.17,
+ 'time budget' => 3600,
+ 'meta.qwertz' => 'uztiuzgubhöklji7gl',
+ ]);
+ self::assertEquals('Test customer', $customer->getName());
+ self::assertEquals(1000.17, $customer->getBudget());
+ self::assertEquals(3600, $customer->getTimeBudget());
+ self::assertEquals('0123 4567890', $customer->getPhone());
+ self::assertEquals('111 354687', $customer->getMobile());
+ self::assertEquals('999 112233445566778899', $customer->getFax());
+ self::assertEquals('www.example.com', $customer->getHomepage());
+ self::assertEquals('www.example.com', $customer->getHomepage());
+ self::assertEquals('uztiuzgubhöklji7gl', $customer->getMetaField('qwertz')->getValue());
+ }
+}
diff --git a/tests/Importer/DefaultProjectImporterTest.php b/tests/Importer/DefaultProjectImporterTest.php
new file mode 100644
index 00000000..929c4864
--- /dev/null
+++ b/tests/Importer/DefaultProjectImporterTest.php
@@ -0,0 +1,92 @@
+createMock(ProjectService::class);
+ $projectService->expects($this->exactly($count))->method('createNewProject')->willReturnCallback(
+ function (Customer $customer) {
+ $customer->setTimezone('Europe/Paris');
+ $project = new Project();
+ $project->setCustomer($customer);
+
+ return $project;
+ }
+ );
+ $customerService = $this->createMock(CustomerService::class);
+ $customerService->expects($this->once())->method('createNewCustomer')->willReturn(new Customer());
+
+ $sut = new DefaultProjectImporter($projectService, $customerService);
+
+ return $sut;
+ }
+
+ private function getDefaultImport(): array
+ {
+ return [
+ 'name' => 'Test project',
+ ];
+ }
+
+ private function prepareProject(array $values = []): Project
+ {
+ $sut = $this->getSut();
+
+ $import = array_merge($this->getDefaultImport(), $values);
+
+ return $sut->convertEntryToProject($import);
+ }
+
+ public function testImportMissingName()
+ {
+ $this->expectException(UnsupportedFormatException::class);
+ $this->expectExceptionMessage('Missing project name, expected in one of the columns: "Name", "Project , "Project Name", "Project-Name", "ProjectName"');
+
+ return $this->getSut(0)->convertEntryToProject(['CuStOMer-name' => 'Test CUSTOMER!']);
+ }
+
+ public function testImport()
+ {
+ $project = $this->prepareProject(['CuStOMer-name' => 'Test CUSTOMER!']);
+ self::assertEquals('Test project', $project->getName());
+ self::assertEquals('Test CUSTOMER!', $project->getCustomer()->getName());
+ }
+
+ public function testImportWithMultipleValues()
+ {
+ $project = $this->prepareProject([
+ 'CuStOMer-name' => 'Test CUSTOMER!',
+ 'order date' => '2020-07-21 17:28:54',
+ 'budget' => 1000.17,
+ 'time budget' => 3600,
+ 'meta.abcd' => 'uztiuzgubhöklji7gl',
+ ]);
+ self::assertEquals('Test project', $project->getName());
+ self::assertEquals('Test CUSTOMER!', $project->getCustomer()->getName());
+ self::assertEquals(1000.17, $project->getBudget());
+ self::assertEquals(3600, $project->getTimeBudget());
+ self::assertEquals('2020-07-21T17:28:54+0200', $project->getOrderDate()->format(DATE_ISO8601));
+ self::assertEquals('uztiuzgubhöklji7gl', $project->getMetaField('abcd')->getValue());
+ }
+}
diff --git a/tests/Importer/UnsupportedFormatExceptionTest.php b/tests/Importer/UnsupportedFormatExceptionTest.php
new file mode 100644
index 00000000..d84945b4
--- /dev/null
+++ b/tests/Importer/UnsupportedFormatExceptionTest.php
@@ -0,0 +1,26 @@
+getMessage());
+ self::assertEquals(0, $sut->getCode());
+ }
+}
diff --git a/tests/Importer/_data/customers.csv b/tests/Importer/_data/customers.csv
new file mode 100644
index 00000000..76ab1492
--- /dev/null
+++ b/tests/Importer/_data/customers.csv
@@ -0,0 +1,21 @@
+ID,Name,Company name,Account,VAT-ID,Address,Contact,Email,Phone,Mobile,Fax,Homepage,Country,Currency,Timezone,Color,Visible,Comment,meta.some-id,meta.aaaaa
+8,Cassin LLC,Cassin Limited,C-63671629,AT U33445071,"7170 Scottie Motorway Suite 828
+Bridgettestad, MO 04855-1492",,,,,,,YE,EUR,Pacific/Pitcairn,#0d0403,1,Et placeat error esse rerum. Vitae optio sed et consequuntur est molestias. Sunt accusantium error qui deleniti nisi esse ab.,4,
+12,Gaylord-Kling,,C-81069873,AT U41003217,"999 Stracke Underpass Apt. 278
+West Celestine, NC 28716",,,,,,,HN,NPR,Europe/Kiev,#17dd05,1,Quam itaque illo aut omnis et. Explicabo molestias ut consequatur ad voluptatem illum ut. Voluptas enim ut vitae explicabo quis.,5,
+7,"Kub, Lowe and Daugherty",,C-08406910,AT U34327342,"3410 Ferry Ferry
+New Raphaelstad, MA 86907-3325",,,,,,,AS,HRK,America/Indiana/Knox,,1,"Architecto explicabo in iure debitis sunt sed. Eum animi ducimus adipisci quod voluptas reprehenderit molestias laudantium. Eos voluptatem non est officia molestias minus.",,
+6,"Nitzsche, Pfeffer and Dickens",,C-73111252,AT U92452965,"563 Alvis Plains Apt. 816
+Schowalterberg, MT 54542",,,,,,,MU,JMD,America/Port_of_Spain,#d14add,1,Itaque ipsa et expedita. Porro aut ab quo. Cumque quia blanditiis aut et vero in neque.,,
+2,"Smitham, Balistreri and Ondricka",,C-29186648,AT U94223297,"685 Waelchi Mountains
+Vanceport, AK 24274",,,,,,,BR,RUB,Atlantic/Faroe,#dd0c08,0,Architecto eum quaerat minus in sed quia impedit. Amet qui aliquam rerum provident optio. Voluptatem pariatur qui ad vitae corporis.,,
+13,Stanton PLC,,C-65852132,AT U66535044,"1927 O'Keefe Island
+North Carmella, MI 02061-1971",,,,,,,GD,GNF,America/Denver,#dd8a11,1,Iusto dignissimos quibusdam neque fuga ut et quam nobis. Non atque natus esse consequatur quod et est.,,
+4,"Toy, Larson and Schaefer",,C-07787515,AT U10587666,"760 Dare Highway Suite 023
+Port Vernice, IA 78379-6315",,,,,,,GL,USD,America/Grand_Turk,#191add,1,Perferendis enim nihil fugiat explicabo animi. Molestiae odit nemo enim quo id. Odio dolores aperiam repudiandae accusantium. Consequuntur illo doloremque veritatis minima animi maiores impedit.,,
+14,"Ullrich, Cruickshank and Emard",,C-89509630,AT U88402013,"4532 Hahn Squares Suite 090
+Port Eliezer, MI 45157",,,,,,,HT,GHS,Australia/Perth,#d2d6de,1,Culpa debitis repellendus quisquam dolor non consequuntur. Omnis doloribus dolore recusandae dolorem quia quidem. Odio veniam expedita quam. Et ut aut voluptatem veniam et necessitatibus.,1,
+11,"Ullrich, Satterfield and Homenick",,C-91464415,AT U72675057,"46915 Hartmann Crossroad Suite 125
+Antoniahaven, VT 39060-5021",,,,,,,MU,ZMW,Asia/Taipei,#00dddd,1,Temporibus aut numquam et fugiat. Laboriosam itaque quibusdam dolore sunt delectus est. Iusto reprehenderit mollitia iure tempora blanditiis praesentium quia quisquam.,3,
+9,"Test",,C-76507878,AT U18747846,"95027 Murray Walks Apt. 283
+New Kendrick, UT 09361-3329",,,,,,,SZ,LKR,Asia/Riyadh,,1,Nulla velit voluptatem atque molestias consequuntur sed. Ut et sunt quia quidem ea.,,
diff --git a/tests/Importer/_data/customers2.csv b/tests/Importer/_data/customers2.csv
new file mode 100644
index 00000000..a4a5d1ed
--- /dev/null
+++ b/tests/Importer/_data/customers2.csv
@@ -0,0 +1,21 @@
+ID;Name;Company name;Account;VAT-ID;Address;Contact;Email;Phone;Mobile;Fax;Homepage;Country;Currency;Timezone;Color;Visible;Comment;meta.some-id;meta.aaaaa
+8;Cassin LLC;Cassin Limited;C-63671629;AT U33445071;"7170 Scottie Motorway Suite 828
+Bridgettestad, MO 04855-1492";;;;;;;YE;EUR;Pacific/Pitcairn;#0d0403;1;Et placeat error esse rerum. Vitae optio sed et consequuntur est molestias. Sunt accusantium error qui deleniti nisi esse ab.;4;
+12;Gaylord-Kling;;C-81069873;AT U41003217;"999 Stracke Underpass Apt. 278
+West Celestine, NC 28716";;;;;;;HN;NPR;Europe/Kiev;#17dd05;1;Quam itaque illo aut omnis et. Explicabo molestias ut consequatur ad voluptatem illum ut. Voluptas enim ut vitae explicabo quis.;5;
+7;Kub, Lowe and Daugherty;;C-08406910;AT U34327342;"3410 Ferry Ferry
+New Raphaelstad, MA 86907-3325";;;;;;;AS;HRK;America/Indiana/Knox;;1;Architecto explicabo in iure debitis sunt sed. Eum animi ducimus adipisci quod voluptas reprehenderit molestias laudantium. Eos voluptatem non est officia molestias minus.;;
+6;Nitzsche, Pfeffer and Dickens;;C-73111252;AT U92452965;"563 Alvis Plains Apt. 816
+Schowalterberg, MT 54542";;;;;;;MU;JMD;America/Port_of_Spain;#d14add;1;Itaque ipsa et expedita. Porro aut ab quo. Cumque quia blanditiis aut et vero in neque.;;
+2;Smitham, Balistreri and Ondricka;;C-29186648;AT U94223297;"685 Waelchi Mountains
+Vanceport, AK 24274";;;;;;;BR;RUB;Atlantic/Faroe;#dd0c08;0;Architecto eum quaerat minus in sed quia impedit. Amet qui aliquam rerum provident optio. Voluptatem pariatur qui ad vitae corporis.;;
+13;Stanton PLC;;C-65852132;AT U66535044;"1927 O'Keefe Island
+North Carmella, MI 02061-1971";;;;;;;GD;GNF;America/Denver;#dd8a11;1;Iusto dignissimos quibusdam neque fuga ut et quam nobis. Non atque natus esse consequatur quod et est.;;
+4;Toy, Larson and Schaefer;;C-07787515;AT U10587666;"760 Dare Highway Suite 023
+Port Vernice, IA 78379-6315";;;;;;;GL;USD;America/Grand_Turk;#191add;1;Perferendis enim nihil fugiat explicabo animi. Molestiae odit nemo enim quo id. Odio dolores aperiam repudiandae accusantium. Consequuntur illo doloremque veritatis minima animi maiores impedit.;;
+14;Ullrich, Cruickshank and Emard;;C-89509630;AT U88402013;"4532 Hahn Squares Suite 090
+Port Eliezer, MI 45157";;;;;;;HT;GHS;Australia/Perth;#d2d6de;1;Culpa debitis repellendus quisquam dolor non consequuntur. Omnis doloribus dolore recusandae dolorem quia quidem. Odio veniam expedita quam. Et ut aut voluptatem veniam et necessitatibus.;1;
+11;Ullrich, Satterfield and Homenick;;C-91464415;AT U72675057;"46915 Hartmann Crossroad Suite 125
+Antoniahaven, VT 39060-5021";;;;;;;MU;ZMW;Asia/Taipei;#00dddd;1;Temporibus aut numquam et fugiat. Laboriosam itaque quibusdam dolore sunt delectus est. Iusto reprehenderit mollitia iure tempora blanditiis praesentium quia quisquam.;3;
+9;Weber, Weber and Mosciski;;C-76507878;AT U18747846;"95027 Murray Walks Apt. 283
+New Kendrick, UT 09361-3329";;;;;;;SZ;LKR;Asia/Riyadh;;1;Nulla velit voluptatem atque molestias consequuntur sed. Ut et sunt quia quidem ea.;;
diff --git a/tests/Importer/_data/grandtotal_de.csv b/tests/Importer/_data/grandtotal_de.csv
new file mode 100644
index 00000000..314472a0
--- /dev/null
+++ b/tests/Importer/_data/grandtotal_de.csv
@@ -0,0 +1,11 @@
+Firma;Abteilung;Titel;Vorname;Zweiter Vorname;Nachname;E-Mail;Straße;PLZ;Ort;Bundesland;Briefanrede;Land;Kundennummer;Umsatzsteuer-ID;Notiz;IBAN;BIC;SEPA Mandat ID;zusatz 1;zusatz 2
+Keleo;IT Abteilung;Bc;Kevin;;Papst;unknown@kimai.org;"Acme Street
+Downtown";1022;Vienna;;Sehr geehrter Herr;DE;00001;DE1234567890;"sakdjhfg laksjhdfasd f#asd
+ fas
+ dfasdfasdf
+ asdfasdfasdf";0987654321;12345678;;blub;foo
+Test;IT Abteilung;Bc;Kevin;;Tspap;unknown@kimai.org;"Acme Street 2
+Downtown";1022;Vienna;;Sehr geehrter Herr;DE;1;DE0987654321;"sakdjhfg laksjhdfasd f#asd
+ fas
+ dfasdfasdf
+ asdfasdfasdf";0987654321;12345678;;blub;foo
diff --git a/tests/Importer/_data/grandtotal_en.csv b/tests/Importer/_data/grandtotal_en.csv
new file mode 100644
index 00000000..c6495d64
--- /dev/null
+++ b/tests/Importer/_data/grandtotal_en.csv
@@ -0,0 +1,6 @@
+Organization;Department;Title;First name;Middle name;Last name;E-Mail;Street;ZIP;City;State;Salutation;Country;Customer number;Tax-ID;Note;IBAN;BIC;SEPA Mandate ID;zusatz 1;zusatz 2
+Keleo;IT Abteilung;Bc;Kevin;;Papst;unknown@kimai.org;"Acme Street
+Downtown";1022;Vienna;;Sehr geehrter Herr;DE;00001;DE1234567890;"sakdjhfg laksjhdfasd f#asd
+ fas
+ dfasdfasdf
+ asdfasdfasdf";0987654321;12345678;;blub;foo
diff --git a/tests/Importer/_data/projects.csv b/tests/Importer/_data/projects.csv
new file mode 100644
index 00000000..646233ba
--- /dev/null
+++ b/tests/Importer/_data/projects.csv
@@ -0,0 +1,4 @@
+ID,Name,Customer,Order number,Order date,Project Start,Project End,Color,Visible,Comment,Meta.zzzzz
+147,Multi-channelled 4thgeneration strategy,"Ullrich, Satterfield and Homenick",P-53400468,,,,,1,Amet non magni est. Cupiditate in magni adipisci nisi voluptatem sequi id. Accusamus sed alias enim et perferendis quo ut. Veniam voluptatem dicta dolores ut tenetur.,
+188,Test,Test,P-58944387,,,,,1,Facere architecto doloremque quae. Exercitationem ut velit molestiae alias dolore provident. Unde facere iste harum doloribus odio.,
+177,Multi-channelled transitional data-warehouse,Gaylord-Kling,P-17219648,,,,,1,Voluptatibus ipsa eos et maiores. Autem dolorem quia sit dolores. Qui adipisci aut aut eligendi voluptatibus. Libero voluptatem dignissimos rerum dignissimos aliquid consectetur.,
diff --git a/tests/Importer/_data/projects2.csv b/tests/Importer/_data/projects2.csv
new file mode 100644
index 00000000..fc24aa55
--- /dev/null
+++ b/tests/Importer/_data/projects2.csv
@@ -0,0 +1,40 @@
+ID;Name;Customer;Order number;Order date;Project Start;Project End;Color;Visible;Comment;Meta.zzzzz
+147;Multi-channelled 4thgeneration strategy;Ullrich, Satterfield and Homenick;P-53400468;;;;;1;Amet non magni est. Cupiditate in magni adipisci nisi voluptatem sequi id. Accusamus sed alias enim et perferendis quo ut. Veniam voluptatem dicta dolores ut tenetur.;
+188;Multi-channelled background workforce;Stanton PLC;P-58944387;;;;;1;Facere architecto doloremque quae. Exercitationem ut velit molestiae alias dolore provident. Unde facere iste harum doloribus odio.;
+177;Multi-channelled transitional data-warehouse;Gaylord-Kling;P-17219648;;;;;1;Voluptatibus ipsa eos et maiores. Autem dolorem quia sit dolores. Qui adipisci aut aut eligendi voluptatibus. Libero voluptatem dignissimos rerum dignissimos aliquid consectetur.;
+154;Multi-channelled uniform focusgroup;Ullrich, Satterfield and Homenick;P-36133086;;;;;0;Similique eaque ea aut provident. Explicabo maiores consectetur est voluptatum expedita. Fugit cumque non iusto recusandae aspernatur.;
+165;Multi-channelled web-enabled adapter;Gaylord-Kling;P-27569344;;;;;1;Odit beatae nostrum quia laboriosam nam dolores. Rem similique voluptate facilis rerum omnis quia. Officiis a culpa vel aliquam sunt perspiciatis.;
+201;Multi-layered empowering neural-net;Ullrich, Cruickshank and Emard;P-96679708;;;;;1;Minus vero praesentium mollitia earum repellat quas eveniet eius. Et velit aut quia. Placeat non aperiam in iure eligendi harum porro.;
+27;Multi-layered uniform project;Smitham, Balistreri and Ondricka;P-86389174;;;;;1;Id placeat ut ut eius fugiat voluptate qui. Unde deleniti velit voluptatem quia. Neque harum eum est.;#d2d6de
+181;Multi-tiered impactful paradigm;Stanton PLC;P-78069558;;;;;1;Cupiditate nemo ut omnis consectetur est ipsam voluptates. A minus quod sit vel adipisci rerum. Voluptatum ut nihil et rerum. Recusandae voluptatem porro sed omnis fuga ut ea.;
+115;Networked contextually-based blockchain;Weber, Weber and Mosciski;P-14827181;;;;;1;Animi maiores dolorem impedit molestias. Et est quia et aut. Eligendi et quis quia occaecati exercitationem. Autem vel voluptas maxime modi reprehenderit earum.;
+18;Networked mission-critical functionalities;A customer name;P-06598815;;;;;1;Et illo labore sit sint. Similique quis natus voluptates aut ut. Ipsum distinctio laboriosam veritatis nesciunt consequatur laborum odit autem. Recusandae impedit veritatis sunt quasi.;
+138;Open-architected human-resource instructionset;Ullrich, Satterfield and Homenick;P-87900477;;;;;0;At commodi quis non eveniet recusandae voluptas. Assumenda earum esse et est odio ut dolorem. Enim nihil ipsa quo et. Rerum provident facilis non deleniti dolore aut. Culpa sed rerum sequi.;
+183;Open-source web-enabled projection;Stanton PLC;P-44548292;;;;;1;Distinctio eum earum debitis facere velit numquam exercitationem ipsum. Et et iusto possimus facere. Unde autem et recusandae sed quae. Sed vero eligendi nihil corporis harum fugiat.;
+37;Optimized content-based array;Smitham, Balistreri and Ondricka;P-74951659;;;;;1;Est aperiam est similique sit velit et. Rerum commodi veniam ullam et consequuntur.;
+178;Optional bandwidth-monitored moratorium;Stanton PLC;P-72979020;;;;;1;Voluptas suscipit voluptatibus dolorum ullam eos consequuntur aut. Nam voluptatem magni enim quas ad tempore voluptatum. Cum incidunt est qui sit.;Sdfgsdf
+53;Organic context-sensitive neural-net;Toy, Larson and Schaefer;P-53133083;;;;#d2d6de;1;Vitae sint sed ea nulla facere qui cum. Eligendi velit repellat non sit sit atque aut. Est minus nobis id accusamus repellat et. Quo in et tempora expedita.;
+83;Organic incremental interface;Nitzsche, Pfeffer and Dickens;P-10570715;;;;;1;Iure est impedit explicabo dolores qui. Quo amet est iste nulla et voluptatum. Sed magni id aliquam aut velit voluptas omnis dolores.;
+84;Organic needs-based methodology;Nitzsche, Pfeffer and Dickens;P-16957282;;;;;1;Dignissimos dolores et nam ipsa. Aut ipsam fugiat vel. Neque vel consequatur corporis quibusdam eaque.;
+26;Organic nextgeneration array;Smitham, Balistreri and Ondricka;P-90074059;;;;;1;Rerum at animi sint ipsum. Expedita earum omnis in voluptatem voluptatem porro libero. Perspiciatis possimus ratione voluptatem nobis.;Sfgh0zu90
+100;Organic responsive artificialintelligence;Cassin LLC;P-47808997;;;;#d2d6de;1;Enim molestias molestiae error ab quisquam qui. Nihil tempore voluptatum vel id et. Sint nisi consequatur iste ipsa vitae veniam neque.;
+99;Organized composite capability;Cassin LLC;P-88143507;;;;;1;Ut quia recusandae occaecati voluptates dolores. Consectetur quasi et et. Nostrum error modi quam ea a quam eveniet.;
+167;Organized interactive hardware;Gaylord-Kling;P-80225447;;;;;1;Doloribus eveniet repellendus ut. Dolor quaerat vel eos velit et aspernatur. Consequatur rerum provident quia voluptatem.;
+119;Organized reciprocal groupware;Weber, Weber and Mosciski;P-96414873;;;;;1;Et reprehenderit reiciendis magnam corrupti. Laudantium fugit fugit nobis unde quod magnam. Sit rerum earum nostrum repellendus.;
+150;Persistent asynchronous migration;Ullrich, Satterfield and Homenick;P-72771556;;;;;0;Dolores quia nisi aliquam vitae rem. Dolorem ipsum velit culpa quo. Voluptatem iste aliquam maiores consequatur. Et quisquam quae cupiditate ea labore dolor qui.;
+193;Persistent dedicated extranet;Stanton PLC;P-83020315;;;;;1;Eligendi harum in consequatur enim aut quidem. Debitis est autem et amet quia. Recusandae enim cumque quidem sequi suscipit sunt.;#d2d6de
+96;Phased assymetric contingency;Cassin LLC;P-63450040;;;;;1;Earum odit non ad cum nihil voluptas et. Totam ea corrupti dicta est. Odit minima est repudiandae sed aperiam sed dolores.;
+44;Polarised regional capacity;Toy, Larson and Schaefer;P-86899055;;;;;1;Accusamus a sit nisi voluptatem doloremque ipsa totam. Autem sit laudantium magnam perferendis sapiente. Assumenda aperiam quos nobis qui cumque accusantium.;
+55;Pre-emptive client-driven neural-net;Toy, Larson and Schaefer;P-93689915;;;;;1;Eos labore exercitationem rerum modi tenetur. Occaecati labore sit in rerum quas. Ullam quos illum iure.;
+51;Pre-emptive directional application;Toy, Larson and Schaefer;P-82374365;;;;;1;Voluptas consequatur consequuntur deserunt dolores placeat. Velit dolores accusamus commodi quae sit. Ut optio in ipsam rerum doloribus.;
+45;Pre-emptive grid-enabled migration;Toy, Larson and Schaefer;P-44070519;;;;;1;Et nihil alias occaecati nostrum. Doloribus neque nemo repellat nihil. Nihil voluptas est non fugit. Hic nulla omnis alias incidunt asperiores asperiores aut.;
+90;Pre-emptive homogeneous migration;Cassin LLC;P-37388904;;;;;1;Voluptate quo ipsa et natus. Eum impedit dignissimos et consequatur rerum magnam in provident. Quis qui nesciunt voluptatem minus ut distinctio illum. Consequatur rem et quia quis cupiditate animi.;
+142;Pre-emptive mission-critical extranet;Ullrich, Satterfield and Homenick;P-36044153;;;;;1;Reiciendis ullam voluptas quia corrupti quod est ut. Qui mollitia dolorem possimus asperiores. Non assumenda qui dolor odio et nihil. Accusantium sint laboriosam veniam quia.;2314
+125;Pre-emptive optimal extranet;Weber, Weber and Mosciski;P-44924478;;;;;1;Sed aut cumque aut id. Ea qui dignissimos natus inventore praesentium dolorem nihil. Aut sint doloremque minima consequuntur ut ut et. Magnam vel distinctio aperiam laudantium.;
+24;Proactive explicit info-mediaries;A customer name;P-19018812;;;;;1;Modi dignissimos numquam et quia magni. Magnam impedit ab natus quo.;
+174;Proactive solution-oriented groupware;Gaylord-Kling;P-42846727;;;;;1;Expedita error voluptatem occaecati. Architecto nobis et perferendis et ipsum soluta. Repudiandae occaecati iure et.;
+192;Proactive uniform internetsolution;Stanton PLC;P-75026981;;;;;0;Ipsum perferendis amet non sed non perferendis voluptates. Ut quasi quia vitae rerum eos quod. Nihil cum odit provident expedita.;#d2d6de
+135;Profit-focused contextually-based moratorium;Ullrich, Satterfield and Homenick;P-46607140;;;;;1;Et nisi placeat aut modi. Atque reprehenderit dolorum ducimus non in. Dolor id repudiandae dicta fugit ratione perspiciatis.;
+160;Profound didactic artificialintelligence;Gaylord-Kling;P-61821699;;;;;1;Repudiandae omnis labore sed vel. Sed vel aperiam nobis id sed mollitia. Sunt impedit ipsum reiciendis ut nobis at provident.;
+162;Profound fault-tolerant data-warehouse;Gaylord-Kling;P-04359890;;;;;1;Ut esse blanditiis totam consequatur illo quibusdam nostrum rerum. Aspernatur dolores est tempora incidunt voluptas facere vero quod. In autem sint iste architecto inventore itaque blanditiis eum.;
+12;Profound homogeneous instructionset;A customer name;P-87850895;;;;;1;Dignissimos a commodi cum et et incidunt molestias. Similique odio labore molestias ipsam velit nihil. Et excepturi fuga animi inventore consequatur minus.;
\ No newline at end of file
diff --git a/tests/Importer/_data/projects_invalid.csv b/tests/Importer/_data/projects_invalid.csv
new file mode 100644
index 00000000..4ed79984
--- /dev/null
+++ b/tests/Importer/_data/projects_invalid.csv
@@ -0,0 +1,4 @@
+ID,Name,Customer,Order number,Order date,Project Start,Project End,Color,Visible,Comment,Meta.zzzzz
+147,Multi-channelled 4thgeneration strategy,"Ullrich, Satterfield and Homenick",P-53400468,,,,,1,Amet non magni est. Cupiditate in magni adipisci nisi voluptatem sequi id. Accusamus sed alias enim et perferendis quo ut. Veniam voluptatem dicta dolores ut tenetur.,
+188,Test,Stanton PLC,P-58944387,,,,,1,Facere architecto doloremque quae. Exercitationem ut velit molestiae alias dolore provident. Unde facere iste harum doloribus odio.,
+177,Multi-channelled transitional data-warehouse,Gaylord-Kling,P-17219648,,,,,1,Voluptatibus ipsa eos et maiores. Autem dolorem quia sit dolores. Qui adipisci aut aut eligendi voluptatibus. Libero voluptatem dignissimos rerum dignissimos aliquid consectetur.,