added project importer and grandtotal converter (#1468)

This commit is contained in:
Kevin Papst
2020-10-11 22:08:00 +02:00
committed by GitHub
parent 0ce02b4f06
commit 996f05c73a
68 changed files with 2827 additions and 384 deletions

View File

@@ -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' ]

View File

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

View File

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

View File

@@ -0,0 +1,234 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Command;
use App\Entity\Team;
use App\Importer\ImporterService;
use App\Importer\ImportNotFoundException;
use App\Repository\TeamRepository;
use App\Repository\UserRepository;
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;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
/**
* This command can change anytime, don't rely on its API for the future!
*/
class ImportProjectCommand extends Command
{
private $importerService;
private $teams;
private $users;
public function __construct(ImporterService $importerService, TeamRepository $teams, UserRepository $users)
{
parent::__construct();
$this->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;
}
}

View File

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

View File

@@ -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
{

View File

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

View File

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

View File

@@ -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()) {

View File

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

View File

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

View File

@@ -0,0 +1,112 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Customer;
use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Event\CustomerCreateEvent;
use App\Event\CustomerCreatePostEvent;
use App\Event\CustomerCreatePreEvent;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerUpdatePostEvent;
use App\Event\CustomerUpdatePreEvent;
use App\Repository\CustomerRepository;
use App\Validator\ValidationFailedException;
use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
class CustomerService
{
private $repository;
private $dispatcher;
private $validator;
private $configuration;
public function __construct(CustomerRepository $customerRepository, SystemConfiguration $configuration, ValidatorInterface $validator, EventDispatcherInterface $dispatcher)
{
$this->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]);
}
}

View File

@@ -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);
}
/**

View File

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

View File

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

View File

@@ -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);
}
/**

View File

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

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Customer;
use Symfony\Contracts\EventDispatcher\Event;
/**
* Base event class to used with customer manipulations.
*/
abstract class AbstractCustomerEvent extends Event
{
/**
* @var Customer
*/
private $customer;
public function __construct(Customer $customer)
{
$this->customer = $customer;
}
public function getCustomer(): Customer
{
return $this->customer;
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for new customer instances, which might or might not be saved.
*/
final class CustomerCreateEvent extends AbstractCustomerEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for customer instances, which were just saved.
*/
final class CustomerCreatePostEvent extends AbstractCustomerEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for customer instances, which are just about to being saved.
*/
final class CustomerCreatePreEvent extends AbstractCustomerEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for customer instances, which were just updated.
*/
final class CustomerUpdatePostEvent extends AbstractCustomerEvent
{
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
/**
* Triggered for customer instances, which are just about to being updated.
*/
final class CustomerUpdatePreEvent extends AbstractCustomerEvent
{
}

View File

@@ -9,7 +9,7 @@
namespace App\EventSubscriber;
use App\Configuration\FormConfiguration;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
@@ -39,15 +39,15 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
*/
protected $voter;
/**
* @var FormConfiguration
* @var SystemConfiguration
*/
protected $formConfig;
protected $configuration;
public function __construct(EventDispatcherInterface $dispatcher, AuthorizationCheckerInterface $voter, FormConfiguration $formConfig)
public function __construct(EventDispatcherInterface $dispatcher, AuthorizationCheckerInterface $voter, SystemConfiguration $formConfig)
{
$this->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();
}

View File

@@ -0,0 +1,121 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use App\Customer\CustomerService;
use App\Entity\Customer;
abstract class AbstractCustomerImporter implements CustomerImporterInterface
{
private $customerService;
public function __construct(CustomerService $repository)
{
$this->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);
}

View File

@@ -0,0 +1,158 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use App\Customer\CustomerService;
use App\Entity\Customer;
use App\Entity\Project;
use App\Project\ProjectService;
abstract class AbstractProjectImporter implements ProjectImporterInterface
{
private $projectService;
private $customerService;
/**
* @var Customer[]
*/
private $customerCache = [];
public function __construct(ProjectService $projectService, CustomerService $customerService)
{
$this->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);
}

View File

@@ -0,0 +1,35 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use League\Csv\Reader;
final class CsvReader implements ImportReaderInterface
{
private $delimiter;
public function __construct(string $delimiter)
{
$this->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();
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use App\Entity\Customer;
interface CustomerImporterInterface
{
public function convertEntryToCustomer(array $entry): Customer;
}

View File

@@ -0,0 +1,162 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use App\Entity\Customer;
use App\Entity\CustomerMeta;
final class DefaultCustomerImporter extends AbstractCustomerImporter
{
protected function mapEntryToCustomer(Customer $customer, array $row)
{
foreach ($row as $name => $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;
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use App\Entity\Project;
use App\Entity\ProjectMeta;
final class DefaultProjectImporter extends AbstractProjectImporter
{
protected function convertEntry(Project $project, array $row)
{
foreach ($row as $name => $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;
}
}

View File

@@ -0,0 +1,159 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use App\Entity\Customer;
final class GrandtotalCustomerImporter extends AbstractCustomerImporter
{
protected function findCustomerName(array $row): string
{
foreach ($row as $name => $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;
}
}

View File

@@ -0,0 +1,14 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
final class ImportNotFoundException extends \Exception
{
}

View File

@@ -0,0 +1,14 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
final class ImportNotReadableException extends \Exception
{
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
interface ImportReaderInterface
{
/**
* @param string $input
* @return \Iterator
* @throws ImportNotFoundException
*/
public function read(string $input): \Iterator;
}

View File

@@ -0,0 +1,102 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use App\Customer\CustomerService;
use App\Entity\Customer;
use App\Entity\Project;
use App\Project\ProjectService;
class ImporterService
{
private $customers;
private $projects;
/**
* @var ProjectImporterInterface[]
*/
private $projectImporter = [];
/**
* @var CustomerImporterInterface[]
*/
private $customerImporter = [];
public function __construct(CustomerService $customers, ProjectService $projects)
{
$this->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);
}
}
}

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use App\Entity\Project;
interface ProjectImporterInterface
{
public function convertEntryToProject(array $entry): Project;
}

View File

@@ -0,0 +1,20 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Importer;
use Throwable;
class UnsupportedFormatException extends \Exception
{
public function __construct(string $message, int $code = 0, Throwable $previous = null)
{
parent::__construct($message, $code, $previous);
}
}

View File

@@ -23,7 +23,7 @@ use InvalidArgumentException;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\Validator\ValidatorInterface;
final class ProjectService
class ProjectService
{
/**
* @var ProjectRepository
@@ -38,11 +38,8 @@ final class ProjectService
*/
private $validator;
public function __construct(
ProjectRepository $projectRepository,
EventDispatcherInterface $dispatcher,
ValidatorInterface $validator
) {
public function __construct(ProjectRepository $projectRepository, EventDispatcherInterface $dispatcher, ValidatorInterface $validator)
{
$this->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]);
}
}

View File

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

View File

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

View File

@@ -0,0 +1,250 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Command;
use App\Command\ImportProjectCommand;
use App\Importer\ImporterService;
use App\Repository\TeamRepository;
use App\Repository\UserRepository;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\Console\Tester\CommandTester;
/**
* @group integration
*/
class ImportProjectCommandTest extends KernelTestCase
{
/**
* @var Application
*/
protected $application;
protected function setUp(): void
{
$kernel = self::bootKernel();
$this->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());
}
}

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ use App\Configuration\ConfigLoaderInterface;
use App\Entity\Configuration;
/**
* @covers \App\Configuration\FormConfiguration
* @covers \App\Configuration\SystemConfiguration
*/
class TestConfigLoader implements ConfigLoaderInterface
{

View File

@@ -64,6 +64,11 @@ class CalendarControllerTest extends ControllerBaseTest
protected function getDefaultSettings()
{
return [
'defaults' => [
'user' => [
'language' => 'en'
],
],
'timesheet' => [
'default_begin' => '08:30:00',
],

View File

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

View File

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

View File

@@ -0,0 +1,156 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Customer;
use App\Configuration\SystemConfiguration;
use App\Customer\CustomerService;
use App\Entity\Customer;
use App\Event\CustomerCreateEvent;
use App\Event\CustomerCreatePostEvent;
use App\Event\CustomerCreatePreEvent;
use App\Event\CustomerMetaDefinitionEvent;
use App\Event\CustomerUpdatePostEvent;
use App\Event\CustomerUpdatePreEvent;
use App\Repository\CustomerRepository;
use App\Validator\ValidationFailedException;
use PHPUnit\Framework\TestCase;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Validator\ConstraintViolation;
use Symfony\Component\Validator\ConstraintViolationList;
use Symfony\Component\Validator\Validator\ValidatorInterface;
/**
* @covers \App\Customer\CustomerService
*/
class CustomerServiceTest extends TestCase
{
private function getSut(
?EventDispatcherInterface $dispatcher = null,
?ValidatorInterface $validator = null,
?CustomerRepository $repository = null,
?SystemConfiguration $configuration = null
): CustomerService {
if ($repository === null) {
$repository = $this->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);
}
}

View File

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

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Customer;
use App\Event\AbstractCustomerEvent;
use PHPUnit\Framework\TestCase;
use Symfony\Contracts\EventDispatcher\Event;
abstract class AbstractCustomerEventTest extends TestCase
{
abstract protected function createCustomerEvent(Customer $customer): AbstractCustomerEvent;
public function testGetterAndSetter()
{
$customer = new Customer();
$sut = $this->createCustomerEvent($customer);
self::assertInstanceOf(Event::class, $sut);
self::assertSame($customer, $sut->getCustomer());
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Customer;
use App\Event\AbstractCustomerEvent;
use App\Event\CustomerCreateEvent;
/**
* @covers \App\Event\AbstractCustomerEvent
* @covers \App\Event\CustomerCreateEvent
*/
class CustomerCreateEventTest extends AbstractCustomerEventTest
{
protected function createCustomerEvent(Customer $customer): AbstractCustomerEvent
{
return new CustomerCreateEvent($customer);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Customer;
use App\Event\AbstractCustomerEvent;
use App\Event\CustomerCreatePostEvent;
/**
* @covers \App\Event\AbstractCustomerEvent
* @covers \App\Event\CustomerCreatePostEvent
*/
class CustomerCreatePostEventTest extends AbstractCustomerEventTest
{
protected function createCustomerEvent(Customer $customer): AbstractCustomerEvent
{
return new CustomerCreatePostEvent($customer);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Customer;
use App\Event\AbstractCustomerEvent;
use App\Event\CustomerCreatePreEvent;
/**
* @covers \App\Event\AbstractCustomerEvent
* @covers \App\Event\CustomerCreatePreEvent
*/
class CustomerCreatePreEventTest extends AbstractCustomerEventTest
{
protected function createCustomerEvent(Customer $customer): AbstractCustomerEvent
{
return new CustomerCreatePreEvent($customer);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Customer;
use App\Event\AbstractCustomerEvent;
use App\Event\CustomerUpdatePostEvent;
/**
* @covers \App\Event\AbstractCustomerEvent
* @covers \App\Event\CustomerUpdatePostEvent
*/
class CustomerUpdatePostEventTest extends AbstractCustomerEventTest
{
protected function createCustomerEvent(Customer $customer): AbstractCustomerEvent
{
return new CustomerUpdatePostEvent($customer);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\Customer;
use App\Event\AbstractCustomerEvent;
use App\Event\CustomerUpdatePreEvent;
/**
* @covers \App\Event\AbstractCustomerEvent
* @covers \App\Event\CustomerUpdatePreEvent
*/
class CustomerUpdatePreEventTest extends AbstractCustomerEventTest
{
protected function createCustomerEvent(Customer $customer): AbstractCustomerEvent
{
return new CustomerUpdatePreEvent($customer);
}
}

View File

@@ -9,7 +9,7 @@
namespace App\Tests\EventSubscriber;
use App\Configuration\FormConfiguration;
use App\Configuration\SystemConfiguration;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
@@ -91,7 +91,7 @@ class UserPreferenceSubscriberTest extends TestCase
$authMock->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);
}

View File

@@ -0,0 +1,62 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Importer;
use App\Importer\CsvReader;
use App\Importer\ImportNotFoundException;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Importer\CsvReader
*/
class CsvReaderTest extends TestCase
{
public function testRead()
{
$sut = new CsvReader(';');
$result = $sut->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<br>
fas<br>
dfasdfasdf<br>
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');
}
}

View File

@@ -0,0 +1,90 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Importer;
use App\Customer\CustomerService;
use App\Entity\Customer;
use App\Importer\DefaultCustomerImporter;
use App\Importer\UnsupportedFormatException;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Importer\DefaultCustomerImporter
*/
class DefaultCustomerImporterTest extends TestCase
{
private function getSut(int $count = 1): DefaultCustomerImporter
{
$customerService = $this->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());
}
}

View File

@@ -0,0 +1,92 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Importer;
use App\Customer\CustomerService;
use App\Entity\Customer;
use App\Entity\Project;
use App\Importer\DefaultProjectImporter;
use App\Importer\UnsupportedFormatException;
use App\Project\ProjectService;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Importer\DefaultProjectImporter
*/
class DefaultProjectImporterTest extends TestCase
{
private function getSut(int $count = 1): DefaultProjectImporter
{
$projectService = $this->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());
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Importer;
use App\Importer\UnsupportedFormatException;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Importer\UnsupportedFormatException
*/
class UnsupportedFormatExceptionTest extends TestCase
{
public function testException()
{
$sut = new UnsupportedFormatException('test');
self::assertEquals('test', $sut->getMessage());
self::assertEquals(0, $sut->getCode());
}
}

View File

@@ -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.,,
1 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
2 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
3 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
4 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.
5 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.
6 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.
7 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.
8 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.
9 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
10 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
11 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.

View File

@@ -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.;;
1 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
2 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
3 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
4 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.
5 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.
6 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.
7 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.
8 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.
9 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
10 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
11 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.

View File

@@ -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<br>
fas<br>
dfasdfasdf<br>
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<br>
fas<br>
dfasdfasdf<br>
asdfasdfasdf";0987654321;12345678;;blub;foo
1 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
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<br> fas<br> dfasdfasdf<br> asdfasdfasdf 0987654321 12345678 blub foo
3 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<br> fas<br> dfasdfasdf<br> asdfasdfasdf 0987654321 12345678 blub foo

View File

@@ -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<br>
fas<br>
dfasdfasdf<br>
asdfasdfasdf";0987654321;12345678;;blub;foo
1 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
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<br> fas<br> dfasdfasdf<br> asdfasdfasdf 0987654321 12345678 blub foo

View File

@@ -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.,
1 ID Name Customer Order number Order date Project Start Project End Color Visible Comment Meta.zzzzz
2 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.
3 188 Test Test P-58944387 1 Facere architecto doloremque quae. Exercitationem ut velit molestiae alias dolore provident. Unde facere iste harum doloribus odio.
4 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.

View File

@@ -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.;
1 ID Name Customer Order number Order date Project Start Project End Color Visible Comment Meta.zzzzz
2 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.
3 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.
4 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.
5 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.
6 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.
7 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.
8 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
9 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.
10 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.
11 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.
12 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.
13 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.
14 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.
15 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
16 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.
17 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.
18 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.
19 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
20 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.
21 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.
22 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.
23 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.
24 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.
25 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
26 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.
27 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.
28 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.
29 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.
30 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.
31 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.
32 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
33 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.
34 24 Proactive explicit info-mediaries A customer name P-19018812 1 Modi dignissimos numquam et quia magni. Magnam impedit ab natus quo.
35 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.
36 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
37 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.
38 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.
39 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.
40 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.

View File

@@ -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.,
1 ID Name Customer Order number Order date Project Start Project End Color Visible Comment Meta.zzzzz
2 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.
3 188 Test Stanton PLC P-58944387 1 Facere architecto doloremque quae. Exercitationem ut velit molestiae alias dolore provident. Unde facere iste harum doloribus odio.
4 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.