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.,