diff --git a/README.md b/README.md
index acf7457e..a41b3485 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Do
### About
-This is new version of the open source timetracker Kimai. It is stable and production ready, ships
+This is the new version of the open source timetracker Kimai. It is stable and production ready, ships
with most advanced features from Kimai 1 and many new ones, including but not limited to:
JSON API, invoicing, data exports, multi-timer and punch-in punch-out mode, tagging, multi-user and multi-timezones,
@@ -59,9 +59,9 @@ You can see a rough development roadmap in the [Milestones](https://github.com/k
It is open for changes and input from the community, your [ideas and questions](https://github.com/kevinpapst/kimai2/issues) are welcome.
> Kimai 2 uses a rolling release concept for delivering updates.
-> You can upgrade Kimai at any time, you don't need to wait for the next official release.
+> You can upgrade Kimai at any time , you don't need to wait for the next official release.
-Release versions will be created on a regular base (approx. one release per month) and you can use these tags if you are familiar with git.
+Release versions will be created on a regular base (approx. one release per month) and you can should use these tags if you are not familiar with git.
Every code change, whether it's a new feature or a bug fix, will be done on the master branch.
I have to do it this way, as I develop Kimai in my free time and want to put my effort into the software instead of backporting changes for old versions.
diff --git a/UPGRADING.md b/UPGRADING.md
index 1b95a3c2..b267acaa 100644
--- a/UPGRADING.md
+++ b/UPGRADING.md
@@ -8,14 +8,15 @@ you can upgrade your Kimai installation to the latest stable release.
Check below if there are more version specific steps required, which need to be executed after the normal update process.
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
-## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6) and [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1)
+## [1.6](https://github.com/kevinpapst/kimai2/releases/tag/1.6), [1.6.1](https://github.com/kevinpapst/kimai2/releases/tag/1.6.1), [1.6.2](https://github.com/kevinpapst/kimai2/releases/tag/1.6.2)
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
-- Invoice improvements:
+- Invoice changes:
- Moved CSV, ODS and XSLX invoice templates to [another repository](https://github.com/Keleo/kimai2-invoice-templates). Using them? Install them manually (see [invoice documentation](https://www.kimai.org/documentation/invoices.html)).
- Added new invoice fields (VAT, contact, payment details) and customer field (VAT). Used the twig settings before? Move them to the respective invoice template settings.
- Permissions can be managed via Admin UI. Please move your permission settings from [local.yaml to your database](https://www.kimai.org/documentation/permissions.html).
+- Important permission change: regular users with the `view_other_timesheet` permission could see all timesheets. This was a legacy from the time before team permissions were introduced. If you rely on this behavior, you need to create a team with all users and the teamlead being the user who needs access to all timesheets.
### Developer
diff --git a/composer.json b/composer.json
index bb20a541..8e678df6 100644
--- a/composer.json
+++ b/composer.json
@@ -32,8 +32,8 @@
"nelmio/api-doc-bundle": "^3.2",
"nelmio/cors-bundle": "^1.5",
"ocramius/proxy-manager": "^2.1.1",
- "phpoffice/phpspreadsheet": "^1.4",
- "phpoffice/phpword": "^0.15.0",
+ "phpoffice/phpspreadsheet": "^1.10",
+ "phpoffice/phpword": "^0.17",
"psr/log": "^1.1",
"sensio/framework-extra-bundle": "^5.2",
"symfony/asset": "^4.0",
diff --git a/composer.lock b/composer.lock
index 191dd8c1..c1839dea 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "4c71764ce6f4c135070a2f7e7a0c62b6",
+ "content-hash": "c08938efb681d04751323b133c8d8199",
"packages": [
{
"name": "beberlei/doctrineextensions",
@@ -4525,16 +4525,16 @@
},
{
"name": "phpoffice/phpword",
- "version": "0.15.0",
+ "version": "0.17.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PHPWord.git",
- "reference": "dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f"
+ "reference": "b8346af548d399acd9e30fc76ab0c55c2fec03a5"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f",
- "reference": "dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f",
+ "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/b8346af548d399acd9e30fc76ab0c55c2fec03a5",
+ "reference": "b8346af548d399acd9e30fc76ab0c55c2fec03a5",
"shasum": ""
},
"require": {
@@ -4566,7 +4566,7 @@
"type": "library",
"extra": {
"branch-alias": {
- "dev-develop": "0.16-dev"
+ "dev-develop": "0.18-dev"
}
},
"autoload": {
@@ -4587,6 +4587,10 @@
"email": "me@gabrielbull.com",
"homepage": "http://gabrielbull.com/"
},
+ {
+ "name": "Franck Lefevre",
+ "homepage": "https://rootslabs.net/blog/"
+ },
{
"name": "Ivan Lanin",
"homepage": "http://ivan.lanin.org"
@@ -4595,10 +4599,6 @@
"name": "Roman Syroeshko",
"homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/"
},
- {
- "name": "Franck Lefevre",
- "homepage": "https://rootslabs.net/blog/"
- },
{
"name": "Antoine de Troostembergh"
}
@@ -4630,7 +4630,7 @@
"word",
"writer"
],
- "time": "2018-07-14T16:59:43+00:00"
+ "time": "2019-10-01T20:43:33+00:00"
},
{
"name": "psr/cache",
diff --git a/config/packages/doctrine.yaml b/config/packages/doctrine.yaml
index f4bcb7d1..d169b44c 100644
--- a/config/packages/doctrine.yaml
+++ b/config/packages/doctrine.yaml
@@ -28,7 +28,7 @@ doctrine:
entity_managers:
default:
connection: default
- naming_strategy: doctrine.orm.naming_strategy.underscore
+ naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true
mappings:
App:
diff --git a/config/packages/nelmio_api_doc.yaml b/config/packages/nelmio_api_doc.yaml
index 24c0be7e..981d336e 100644
--- a/config/packages/nelmio_api_doc.yaml
+++ b/config/packages/nelmio_api_doc.yaml
@@ -21,9 +21,9 @@ nelmio_api_doc:
- { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] }
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] }
- { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, Timesheet] }
- - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User] }
+ - { alias: UserEntity, type: App\Entity\User, groups: [Default, Entity, User, User_Entity] }
- { alias: UserCollection, type: App\Entity\User, groups: [Default, Collection, User] }
- - { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team] }
+ - { alias: TeamEntity, type: App\Entity\Team, groups: [Default, Entity, Team, Team_Entity] }
- { alias: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] }
- { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] }
areas:
diff --git a/config/serializer/App/Entity.Team.yml b/config/serializer/App/Entity.Team.yml
index 9b47f46c..e177e607 100644
--- a/config/serializer/App/Entity.Team.yml
+++ b/config/serializer/App/Entity.Team.yml
@@ -1,13 +1,17 @@
App\Entity\Team:
exclusion_policy: All
- custom_accessor_order: [id, name, users]
+ custom_accessor_order: [id, name, teamlead, users]
properties:
id:
include: true
name:
include: true
+ teamlead:
+ include: true
+ groups: [Team_Entity]
users:
- exclude: true
+ include: true
+ groups: [Team_Entity]
customers:
exclude: true
projects:
diff --git a/config/serializer/App/Entity.User.yml b/config/serializer/App/Entity.User.yml
index 8907f719..1bfdae5a 100644
--- a/config/serializer/App/Entity.User.yml
+++ b/config/serializer/App/Entity.User.yml
@@ -1,6 +1,6 @@
App\Entity\User:
exclusion_policy: All
- custom_accessor_order: [id, alias, title, avatar, language, timezone]
+ custom_accessor_order: [id, alias, title, avatar, language, timezone, teams]
properties:
id:
include: true
@@ -10,18 +10,21 @@ App\Entity\User:
groups: [Default]
title:
include: true
- groups: [Entity]
+ groups: [User_Entity]
avatar:
include: true
- groups: [Entity]
+ groups: [User_Entity]
+ teams:
+ include: true
+ groups: [User_Entity]
virtual_properties:
getLanguage:
serialized_name: language
exp: "object.getPreferenceValue('language') === null ? null : object.getPreferenceValue('language')"
type: string
- groups: [Entity]
+ groups: [User_Entity]
getTimezone:
serialized_name: timezone
exp: "object.getPreferenceValue('timezone') === null ? null : object.getPreferenceValue('timezone')"
type: string
- groups: [Entity]
+ groups: [User_Entity]
diff --git a/config/serializer/FOS/UserBundle/Model.User.yml b/config/serializer/FOS/UserBundle/Model.User.yml
index 6b28923f..8266de18 100644
--- a/config/serializer/FOS/UserBundle/Model.User.yml
+++ b/config/serializer/FOS/UserBundle/Model.User.yml
@@ -10,7 +10,7 @@ FOS\UserBundle\Model\User:
roles:
type: array
include: true
- groups: [Entity]
+ groups: [User_Entity]
groups:
exclude: true
email:
diff --git a/src/API/TagController.php b/src/API/TagController.php
index 16a7323d..04245f00 100644
--- a/src/API/TagController.php
+++ b/src/API/TagController.php
@@ -28,6 +28,8 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/**
* @RouteResource("Tag")
+ *
+ * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class TagController extends BaseApiController
{
diff --git a/src/API/TeamController.php b/src/API/TeamController.php
index 3d0cd02b..62b71315 100644
--- a/src/API/TeamController.php
+++ b/src/API/TeamController.php
@@ -24,6 +24,8 @@ use Symfony\Component\HttpFoundation\Response;
/**
* @RouteResource("Team")
+ *
+ * @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/
class TeamController extends BaseApiController
{
@@ -78,6 +80,8 @@ class TeamController extends BaseApiController
* @SWG\Schema(ref="#/definitions/TeamEntity"),
* )
*
+ * @Security("is_granted('view_team')")
+ *
* @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken")
*/
@@ -90,7 +94,7 @@ class TeamController extends BaseApiController
}
$view = new View($data, 200);
- $view->getContext()->setGroups(['Default', 'Entity', 'Team']);
+ $view->getContext()->setGroups(['Default', 'Entity', 'Team', 'Team_Entity']);
return $this->viewHandler->handle($view);
}
diff --git a/src/API/UserController.php b/src/API/UserController.php
index a6e0fa0d..7efc190d 100644
--- a/src/API/UserController.php
+++ b/src/API/UserController.php
@@ -133,7 +133,7 @@ class UserController extends BaseApiController
}
$view = new View($user, 200);
- $view->getContext()->setGroups(['Default', 'Entity', 'User']);
+ $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']);
return $this->viewHandler->handle($view);
}
@@ -155,7 +155,7 @@ class UserController extends BaseApiController
public function meAction(): Response
{
$view = new View($this->getUser(), 200);
- $view->getContext()->setGroups(['Default', 'Entity', 'User']);
+ $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']);
return $this->viewHandler->handle($view);
}
diff --git a/src/Constants.php b/src/Constants.php
index ff73a79f..7d1ab6d1 100644
--- a/src/Constants.php
+++ b/src/Constants.php
@@ -17,7 +17,7 @@ class Constants
/**
* The current release version
*/
- public const VERSION = '1.6.1';
+ public const VERSION = '1.6.2';
/**
* The current release status, either "stable" or "dev"
*/
diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php
index eae1bad2..496827d9 100644
--- a/src/Controller/InvoiceController.php
+++ b/src/Controller/InvoiceController.php
@@ -10,8 +10,11 @@
namespace App\Controller;
use App\Entity\InvoiceTemplate;
+use App\Event\InvoicePostRenderEvent;
+use App\Event\InvoicePreRenderEvent;
use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceToolbarForm;
+use App\Invoice\InvoiceFormatter;
use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceModel;
use App\Invoice\ServiceInvoice;
@@ -25,6 +28,7 @@ use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
+use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Controller used to create invoices and manage invoice templates.
@@ -32,26 +36,36 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/invoice")
* @Security("is_granted('view_invoice')")
*/
-class InvoiceController extends AbstractController
+final class InvoiceController extends AbstractController
{
/**
* @var ServiceInvoice
*/
- protected $service;
+ private $service;
/**
* @var InvoiceTemplateRepository
*/
- protected $invoiceRepository;
+ private $invoiceRepository;
/**
* @var UserDateTimeFactory
*/
- protected $dateTimeFactory;
+ private $dateTimeFactory;
+ /**
+ * @var InvoiceFormatter
+ */
+ private $formatter;
+ /**
+ * @var EventDispatcherInterface
+ */
+ private $dispatcher;
- public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, UserDateTimeFactory $dateTimeFactory)
+ public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher)
{
$this->service = $service;
$this->invoiceRepository = $invoice;
$this->dateTimeFactory = $dateTimeFactory;
+ $this->formatter = $formatter;
+ $this->dispatcher = $dispatcher;
}
/**
@@ -115,8 +129,14 @@ class InvoiceController extends AbstractController
$query->setEnd($end);
$query->setExported(InvoiceQuery::STATE_NOT_EXPORTED);
$query->setState(InvoiceQuery::STATE_STOPPED);
+ // limit access to data from teams
$query->setCurrentUser($this->getUser());
+ if (!$this->isGranted('view_other_timesheet')) {
+ // limit access to own data
+ $query->setUser($this->getUser());
+ }
+
return $query;
}
@@ -135,11 +155,15 @@ class InvoiceController extends AbstractController
foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
+ $this->dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
+
$response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) {
$this->markEntriesAsExported($entries);
}
+ $this->dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response));
+
return $response;
}
}
@@ -216,9 +240,10 @@ class InvoiceController extends AbstractController
*/
protected function prepareModel(InvoiceQuery $query): InvoiceModel
{
- $model = new InvoiceModel();
+ $model = new InvoiceModel($this->formatter);
$model
->setQuery($query)
+ ->setUser($this->getUser())
->setCustomer($query->getCustomer())
;
@@ -340,6 +365,7 @@ class InvoiceController extends AbstractController
return $this->createForm(InvoiceToolbarForm::class, $query, [
'action' => $this->generateUrl('invoice', []),
'method' => $method,
+ 'include_user' => $this->isGranted('view_other_timesheet'),
'attr' => [
'id' => 'invoice-print-form'
],
diff --git a/src/Controller/ProfileController.php b/src/Controller/ProfileController.php
index 30bebfef..e1a88264 100644
--- a/src/Controller/ProfileController.php
+++ b/src/Controller/ProfileController.php
@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Entity\User;
+use App\Entity\UserPreference;
use App\Event\PrepareUserEvent;
use App\Form\UserApiTokenType;
use App\Form\UserEditType;
@@ -20,6 +21,7 @@ use App\Form\UserTeamsType;
use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository;
use App\Voter\UserVoter;
+use Doctrine\Common\Collections\ArrayCollection;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface;
@@ -210,6 +212,13 @@ class ProfileController extends AbstractController
$event = new PrepareUserEvent($profile);
$this->dispatcher->dispatch($event);
+ /** @var \ArrayIterator $iterator */
+ $iterator = $profile->getPreferences()->getIterator();
+ $iterator->uasort(function (UserPreference $a, UserPreference $b) {
+ return ($a->getOrder() < $b->getOrder()) ? -1 : 1;
+ });
+ $profile->setPreferences(new ArrayCollection(iterator_to_array($iterator)));
+
$original = [];
foreach ($profile->getPreferences() as $preference) {
$original[$preference->getName()] = $preference;
diff --git a/src/Controller/TimesheetAbstractController.php b/src/Controller/TimesheetAbstractController.php
index c61cb316..d6eb064d 100644
--- a/src/Controller/TimesheetAbstractController.php
+++ b/src/Controller/TimesheetAbstractController.php
@@ -402,7 +402,7 @@ abstract class TimesheetAbstractController extends AbstractController
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
'method' => 'POST',
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
- 'include_user' => $this->includeUserInForms(),
+ 'include_user' => $this->includeUserInForms('multi'),
]);
}
@@ -426,7 +426,7 @@ abstract class TimesheetAbstractController extends AbstractController
'action' => $this->generateUrl($this->getCreateRoute()),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
- 'include_user' => $this->includeUserInForms(),
+ 'include_user' => $this->includeUserInForms('create'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
'allow_duration' => $mode->canEditDuration(),
@@ -450,7 +450,7 @@ abstract class TimesheetAbstractController extends AbstractController
]),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
- 'include_user' => $this->includeUserInForms(),
+ 'include_user' => $this->includeUserInForms('edit'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
'allow_duration' => $mode->canEditDuration(),
@@ -469,7 +469,7 @@ abstract class TimesheetAbstractController extends AbstractController
'page' => $query->getPage(),
]),
'method' => 'GET',
- 'include_user' => $this->includeUserInForms(),
+ 'include_user' => $this->includeUserInForms('toolbar'),
]);
}
@@ -493,7 +493,7 @@ abstract class TimesheetAbstractController extends AbstractController
return (bool) $this->getUser()->getPreferenceValue('timesheet.daily_stats', false);
}
- protected function includeUserInForms(): bool
+ protected function includeUserInForms(string $formName): bool
{
return false;
}
diff --git a/src/Controller/TimesheetTeamController.php b/src/Controller/TimesheetTeamController.php
index c67314cf..4ffcfcc6 100644
--- a/src/Controller/TimesheetTeamController.php
+++ b/src/Controller/TimesheetTeamController.php
@@ -118,9 +118,13 @@ class TimesheetTeamController extends TimesheetAbstractController
return TimesheetAdminEditForm::class;
}
- protected function includeUserInForms(): bool
+ protected function includeUserInForms(string $formName): bool
{
- return true;
+ if ($formName === 'toolbar') {
+ return true;
+ }
+
+ return $this->isGranted('edit_other_timesheet');
}
protected function getTimesheetRoute(): string
diff --git a/src/DataFixtures/InvoiceFixtures.php b/src/DataFixtures/InvoiceFixtures.php
index c75b2df8..dee25b10 100644
--- a/src/DataFixtures/InvoiceFixtures.php
+++ b/src/DataFixtures/InvoiceFixtures.php
@@ -69,7 +69,7 @@ class InvoiceFixtures extends Fixture
$paymentTerms_alt =
$faker->firstName . ', thank you very much. We really appreciate your business.' . PHP_EOL .
- 'Please send payments before the due date.I would like to thank you for your confidence and will gladly be there for you in the future.'
+ 'Please send payments before the due date. I would like to thank you for your confidence and will gladly be there for you in the future.'
;
$paymentTerms_de =
@@ -114,7 +114,6 @@ class InvoiceFixtures extends Fixture
protected function generateAddress(Generator $faker)
{
return
- 'Kimai Inc.' . PHP_EOL .
$faker->streetAddress . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode
;
diff --git a/src/DependencyInjection/AppExtension.php b/src/DependencyInjection/AppExtension.php
index 0aa5aea6..75eea4d7 100644
--- a/src/DependencyInjection/AppExtension.php
+++ b/src/DependencyInjection/AppExtension.php
@@ -45,6 +45,9 @@ class AppExtension extends Extension
$config['timesheet']['rounding'][$name]['days'] = implode(',', $settings['days']);
}
+ $config['invoice']['documents'] = array_merge($config['invoice']['documents'], $config['invoice']['defaults']);
+ unset($config['invoice']['defaults']);
+
// safe alternatives to %kernel.project_dir%
$container->setParameter('kimai.data_dir', $config['data_dir']);
$container->setParameter('kimai.plugin_dir', $config['plugin_dir']);
diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php
index a47c21b2..f180d2d2 100644
--- a/src/DependencyInjection/Configuration.php
+++ b/src/DependencyInjection/Configuration.php
@@ -211,14 +211,18 @@ class Configuration implements ConfigurationInterface
$node
->addDefaultsIfNotSet()
->children()
- ->arrayNode('documents')
- ->requiresAtLeastOneElement()
+ ->arrayNode('defaults')
->scalarPrototype()->end()
->defaultValue([
'var/invoices/',
'templates/invoice/renderer/'
])
->end()
+ ->arrayNode('documents')
+ ->requiresAtLeastOneElement()
+ ->scalarPrototype()->end()
+ ->defaultValue([])
+ ->end()
->end()
;
diff --git a/src/Entity/InvoiceTemplate.php b/src/Entity/InvoiceTemplate.php
index 5a22abe8..734c10bb 100644
--- a/src/Entity/InvoiceTemplate.php
+++ b/src/Entity/InvoiceTemplate.php
@@ -99,6 +99,7 @@ class InvoiceTemplate
*
* @ORM\Column(name="calculator", type="string", length=20, nullable=false)
* @Assert\NotBlank()
+ * @Assert\Length(max=20)
*/
private $calculator = 'default';
/**
@@ -106,6 +107,7 @@ class InvoiceTemplate
*
* @ORM\Column(name="number_generator", type="string", length=20, nullable=false)
* @Assert\NotBlank()
+ * @Assert\Length(max=20)
*/
private $numberGenerator = 'default';
@@ -114,6 +116,7 @@ class InvoiceTemplate
*
* @ORM\Column(name="renderer", type="string", length=20, nullable=false)
* @Assert\NotBlank()
+ * @Assert\Length(max=20)
*/
private $renderer = 'default';
diff --git a/src/Entity/UserPreference.php b/src/Entity/UserPreference.php
index 1682f210..d551e514 100644
--- a/src/Entity/UserPreference.php
+++ b/src/Entity/UserPreference.php
@@ -77,6 +77,10 @@ class UserPreference
* @var array
*/
private $options = [];
+ /**
+ * @var int
+ */
+ private $order = 1000;
/**
* @return int
@@ -246,4 +250,16 @@ class UserPreference
return $this->name;
}
+
+ public function getOrder(): int
+ {
+ return $this->order;
+ }
+
+ public function setOrder(int $order): UserPreference
+ {
+ $this->order = $order;
+
+ return $this;
+ }
}
diff --git a/src/Event/InvoicePostRenderEvent.php b/src/Event/InvoicePostRenderEvent.php
new file mode 100644
index 00000000..c861e1ff
--- /dev/null
+++ b/src/Event/InvoicePostRenderEvent.php
@@ -0,0 +1,64 @@
+model = $model;
+ $this->document = $document;
+ $this->renderer = $renderer;
+ $this->response = $response;
+ }
+
+ public function getModel(): InvoiceModel
+ {
+ return $this->model;
+ }
+
+ public function getDocument(): InvoiceDocument
+ {
+ return $this->document;
+ }
+
+ public function getRenderer(): RendererInterface
+ {
+ return $this->renderer;
+ }
+
+ public function getResponse(): Response
+ {
+ return $this->response;
+ }
+}
diff --git a/src/Event/InvoicePreRenderEvent.php b/src/Event/InvoicePreRenderEvent.php
new file mode 100644
index 00000000..5870a383
--- /dev/null
+++ b/src/Event/InvoicePreRenderEvent.php
@@ -0,0 +1,53 @@
+model = $model;
+ $this->document = $document;
+ $this->renderer = $renderer;
+ }
+
+ public function getModel(): InvoiceModel
+ {
+ return $this->model;
+ }
+
+ public function getDocument(): InvoiceDocument
+ {
+ return $this->document;
+ }
+
+ public function getRenderer(): RendererInterface
+ {
+ return $this->renderer;
+ }
+}
diff --git a/src/EventSubscriber/DashboardSubscriber.php b/src/EventSubscriber/DashboardSubscriber.php
index 20ef9389..ab026d2e 100644
--- a/src/EventSubscriber/DashboardSubscriber.php
+++ b/src/EventSubscriber/DashboardSubscriber.php
@@ -14,6 +14,10 @@ use App\Event\DashboardEvent;
use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository;
+use App\Repository\Query\ActivityQuery;
+use App\Repository\Query\CustomerQuery;
+use App\Repository\Query\ProjectQuery;
+use App\Repository\Query\UserQuery;
use App\Repository\UserRepository;
use App\Widget\Type\CompoundRow;
use App\Widget\Type\More;
@@ -82,8 +86,9 @@ class DashboardSubscriber implements EventSubscriberInterface
*/
public function onDashboardEvent(DashboardEvent $event)
{
+ $user = $event->getUser();
$section = new CompoundRow();
- $section->setTitle('ROLE_ADMIN');
+ $section->setTitle('');
$section->setOrder(100);
if ($this->security->isGranted('view_user')) {
@@ -91,7 +96,7 @@ class DashboardSubscriber implements EventSubscriberInterface
(new More())
->setId('userTotal')
->setTitle('stats.userTotal')
- ->setData($this->user->countUser())
+ ->setData($this->user->countUsersForQuery((new UserQuery())->setCurrentUser($user)))
->setOptions([
'route' => 'admin_user',
'icon' => 'user',
@@ -105,7 +110,7 @@ class DashboardSubscriber implements EventSubscriberInterface
(new More())
->setId('customerTotal')
->setTitle('stats.customerTotal')
- ->setData($this->customer->countCustomer())
+ ->setData($this->customer->countCustomersForQuery((new CustomerQuery())->setCurrentUser($user)))
->setOptions([
'route' => 'admin_customer',
'icon' => 'customer',
@@ -119,7 +124,7 @@ class DashboardSubscriber implements EventSubscriberInterface
(new More())
->setId('projectTotal')
->setTitle('stats.projectTotal')
- ->setData($this->project->countProject())
+ ->setData($this->project->countProjectsForQuery((new ProjectQuery())->setCurrentUser($user)))
->setOptions([
'route' => 'admin_project',
'icon' => 'project',
@@ -133,7 +138,7 @@ class DashboardSubscriber implements EventSubscriberInterface
(new More())
->setId('activityTotal')
->setTitle('stats.activityTotal')
- ->setData($this->activity->countActivity())
+ ->setData($this->activity->countActivitiesForQuery((new ActivityQuery())->setCurrentUser($user)))
->setOptions([
'route' => 'admin_activity',
'icon' => 'activity',
diff --git a/src/EventSubscriber/MenuSubscriber.php b/src/EventSubscriber/MenuSubscriber.php
index 57c64a42..45f304d8 100644
--- a/src/EventSubscriber/MenuSubscriber.php
+++ b/src/EventSubscriber/MenuSubscriber.php
@@ -72,7 +72,7 @@ final class MenuSubscriber implements EventSubscriberInterface
if ($auth->isGranted('view_invoice')) {
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('invoice'));
- $invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create']);
+ $invoice->setChildRoutes(['admin_invoice_template', 'admin_invoice_template_edit', 'admin_invoice_template_create', 'admin_invoice_template_copy']);
$menu->addItem($invoice);
}
diff --git a/src/EventSubscriber/UserPreferenceSubscriber.php b/src/EventSubscriber/UserPreferenceSubscriber.php
index 7e7e0aa5..04e59c1b 100644
--- a/src/EventSubscriber/UserPreferenceSubscriber.php
+++ b/src/EventSubscriber/UserPreferenceSubscriber.php
@@ -104,6 +104,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
(new UserPreference())
->setName(UserPreference::HOURLY_RATE)
->setValue(0)
+ ->setOrder(100)
->setType(MoneyType::class)
->setEnabled($enableHourlyRate)
->setOptions($hourlyRateOptions)
@@ -112,36 +113,43 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
(new UserPreference())
->setName(UserPreference::TIMEZONE)
->setValue($this->getDefaultTimezone())
+ ->setOrder(200)
->setType(TimezoneType::class),
(new UserPreference())
->setName(UserPreference::LOCALE)
->setValue($this->getDefaultLanguage())
+ ->setOrder(300)
->setType(LanguageType::class),
(new UserPreference())
->setName(UserPreference::SKIN)
->setValue($this->getDefaultTheme())
+ ->setOrder(400)
->setType(SkinType::class),
(new UserPreference())
->setName('theme.collapsed_sidebar')
->setValue(false)
+ ->setOrder(500)
->setType(CheckboxType::class),
(new UserPreference())
->setName('calendar.initial_view')
->setValue(CalendarViewType::DEFAULT_VIEW)
+ ->setOrder(600)
->setType(CalendarViewType::class),
(new UserPreference())
->setName('login.initial_view')
->setValue(InitialViewType::DEFAULT_VIEW)
+ ->setOrder(700)
->setType(InitialViewType::class),
(new UserPreference())
->setName('timesheet.daily_stats')
->setValue(false)
+ ->setOrder(800)
->setType(CheckboxType::class),
];
}
@@ -153,29 +161,22 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
{
$user = $event->getUser();
- $prefs = [];
- foreach ($user->getPreferences() as $preference) {
- $prefs[$preference->getName()] = $preference;
- }
-
$event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user));
$this->eventDispatcher->dispatch($event);
foreach ($event->getPreferences() as $preference) {
- /* @var UserPreference[] $prefs */
- if (isset($prefs[$preference->getName()])) {
- /* @var UserPreference $pref */
- $prefs[$preference->getName()]
+ $userPref = $user->getPreference($preference->getName());
+ if (null !== $userPref) {
+ $userPref
->setType($preference->getType())
->setConstraints($preference->getConstraints())
->setEnabled($preference->isEnabled())
->setOptions($preference->getOptions())
+ ->setOrder($preference->getOrder())
;
} else {
- $prefs[$preference->getName()] = $preference;
+ $user->addPreference($preference);
}
}
-
- $user->setPreferences(array_values($prefs));
}
}
diff --git a/src/Form/Toolbar/InvoiceToolbarForm.php b/src/Form/Toolbar/InvoiceToolbarForm.php
index ad70cc42..755a7991 100644
--- a/src/Form/Toolbar/InvoiceToolbarForm.php
+++ b/src/Form/Toolbar/InvoiceToolbarForm.php
@@ -28,7 +28,9 @@ class InvoiceToolbarForm extends AbstractToolbarForm
{
$this->addSearchTermInputField($builder);
$this->addTemplateChoice($builder);
- $this->addUsersChoice($builder);
+ if ($options['include_user']) {
+ $this->addUsersChoice($builder);
+ }
$this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder, true);
$this->addProjectChoice($builder);
@@ -64,6 +66,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
$resolver->setDefaults([
'data_class' => InvoiceQuery::class,
'csrf_protection' => false,
+ 'include_user' => true,
]);
}
}
diff --git a/src/Invoice/Calculator/AbstractMergedCalculator.php b/src/Invoice/Calculator/AbstractMergedCalculator.php
index 790401ba..dc191bee 100644
--- a/src/Invoice/Calculator/AbstractMergedCalculator.php
+++ b/src/Invoice/Calculator/AbstractMergedCalculator.php
@@ -13,6 +13,7 @@ use App\Entity\Timesheet;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceItemWithAmountInterface;
+use App\Invoice\InvoiceItemWithTypeInterface;
abstract class AbstractMergedCalculator extends AbstractCalculator
{
@@ -37,6 +38,25 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
if ($entry instanceof InvoiceItemWithAmountInterface) {
$amount = $entry->getAmount();
}
+
+ if ($entry instanceof InvoiceItemWithTypeInterface) {
+ $type = $entry->getInvoiceType();
+ $category = $entry->getInvoiceCategory();
+ } else {
+ $type = InvoiceItem::TYPE_TIMESHEET;
+ $category = InvoiceItem::CATEGORY_TIMESHEET_WORK;
+ }
+
+ if (null !== $invoiceItem->getType() && $type !== $invoiceItem->getType()) {
+ $type = InvoiceItem::TYPE_MIXED;
+ }
+ if (null !== $invoiceItem->getCategory() && $category !== $invoiceItem->getCategory()) {
+ $category = InvoiceItem::CATEGORY_MIXED;
+ }
+
+ $invoiceItem->setType($type);
+ $invoiceItem->setCategory($category);
+
$invoiceItem->setAmount($invoiceItem->getAmount() + $amount);
$invoiceItem->setUser($entry->getUser());
$invoiceItem->setRate($invoiceItem->getRate() + $entry->getRate());
diff --git a/src/Invoice/DefaultInvoiceFormatter.php b/src/Invoice/DefaultInvoiceFormatter.php
new file mode 100644
index 00000000..fc22456b
--- /dev/null
+++ b/src/Invoice/DefaultInvoiceFormatter.php
@@ -0,0 +1,104 @@
+translator = $translator;
+ $this->dateExtension = $dateExtension;
+ $this->extension = $extensions;
+ }
+
+ /**
+ * @param \DateTime $date
+ * @return mixed
+ */
+ public function getFormattedDateTime(\DateTime $date)
+ {
+ return $this->dateExtension->dateShort($date);
+ }
+
+ /**
+ * @param \DateTime $date
+ * @return mixed
+ */
+ public function getFormattedTime(\DateTime $date)
+ {
+ return $this->dateExtension->time($date);
+ }
+
+ /**
+ * @param int $amount
+ * @param string $currency
+ * @return string
+ */
+ public function getFormattedMoney($amount, $currency)
+ {
+ return $this->extension->money($amount, $currency);
+ }
+
+ /**
+ * @param \DateTime $date
+ * @return mixed
+ */
+ public function getFormattedMonthName(\DateTime $date)
+ {
+ return $this->translator->trans($this->dateExtension->monthName($date));
+ }
+
+ /**
+ * @param int $seconds
+ * @return mixed
+ */
+ public function getFormattedDuration($seconds)
+ {
+ return $this->extension->duration($seconds);
+ }
+
+ /**
+ * @param int $seconds
+ * @return mixed
+ */
+ public function getFormattedDecimalDuration($seconds)
+ {
+ return $this->extension->durationDecimal($seconds);
+ }
+
+ public function getCurrencySymbol(string $currency): string
+ {
+ return $this->extension->currency($currency);
+ }
+}
diff --git a/src/Invoice/InvoiceFormatter.php b/src/Invoice/InvoiceFormatter.php
new file mode 100644
index 00000000..38c81139
--- /dev/null
+++ b/src/Invoice/InvoiceFormatter.php
@@ -0,0 +1,55 @@
+type;
+ }
+
+ public function setType(string $type): InvoiceItem
+ {
+ $this->type = $type;
+
+ return $this;
+ }
+
+ public function getCategory(): ?string
+ {
+ return $this->category;
+ }
+
+ public function setCategory(string $category): InvoiceItem
+ {
+ $this->category = $category;
+
+ return $this;
+ }
}
diff --git a/src/Invoice/InvoiceItemWithTypeInterface.php b/src/Invoice/InvoiceItemWithTypeInterface.php
new file mode 100644
index 00000000..4cc6ffb8
--- /dev/null
+++ b/src/Invoice/InvoiceItemWithTypeInterface.php
@@ -0,0 +1,17 @@
+invoiceDate = new \DateTime();
+ $this->formatter = $formatter;
}
/**
@@ -183,4 +188,242 @@ class InvoiceModel
{
return $this->calculator;
}
+
+ /**
+ * Returns the user who is currently creating the invoice.
+ *
+ * @return User|null
+ */
+ public function getUser(): ?User
+ {
+ return $this->user;
+ }
+
+ public function setUser(User $user): InvoiceModel
+ {
+ $this->user = $user;
+
+ return $this;
+ }
+
+ public function getFormatter(): ?InvoiceFormatter
+ {
+ return $this->formatter;
+ }
+
+ public function toArray(): array
+ {
+ $model = $this;
+ $customer = $model->getCustomer();
+ $project = $model->getQuery()->getProject();
+ $activity = $model->getQuery()->getActivity();
+ $currency = $model->getCalculator()->getCurrency();
+ $tax = $model->getCalculator()->getTax();
+ $total = $model->getCalculator()->getTotal();
+ $subtotal = $model->getCalculator()->getSubtotal();
+ $formatter = $model->getFormatter();
+
+ $values = [
+ 'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
+ 'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
+ 'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
+ 'invoice.currency' => $currency,
+ 'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
+ 'invoice.vat' => $model->getCalculator()->getVat(),
+ 'invoice.tax' => $formatter->getFormattedMoney($tax, $currency),
+ 'invoice.tax_nc' => $formatter->getFormattedMoney($tax, null),
+ 'invoice.tax_plain' => $tax,
+ 'invoice.total_time' => $formatter->getFormattedDuration($model->getCalculator()->getTimeWorked()),
+ 'invoice.duration_decimal' => $formatter->getFormattedDecimalDuration($model->getCalculator()->getTimeWorked()),
+ 'invoice.total' => $formatter->getFormattedMoney($total, $currency),
+ 'invoice.total_nc' => $formatter->getFormattedMoney($total, null),
+ 'invoice.total_plain' => $total,
+ 'invoice.subtotal' => $formatter->getFormattedMoney($subtotal, $currency),
+ 'invoice.subtotal_nc' => $formatter->getFormattedMoney($subtotal, null),
+ 'invoice.subtotal_plain' => $subtotal,
+
+ 'template.name' => $model->getTemplate()->getName(),
+ 'template.company' => $model->getTemplate()->getCompany(),
+ 'template.address' => $model->getTemplate()->getAddress(),
+ 'template.title' => $model->getTemplate()->getTitle(),
+ 'template.payment_terms' => $model->getTemplate()->getPaymentTerms(),
+ 'template.due_days' => $model->getTemplate()->getDueDays(),
+ 'template.vat_id' => $model->getTemplate()->getVatId(),
+ 'template.contact' => $model->getTemplate()->getContact(),
+ 'template.payment_details' => $model->getTemplate()->getPaymentDetails(),
+
+ 'query.begin' => $formatter->getFormattedDateTime($model->getQuery()->getBegin()),
+ 'query.day' => $model->getQuery()->getBegin()->format('d'),
+ 'query.end' => $formatter->getFormattedDateTime($model->getQuery()->getEnd()),
+ 'query.month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()),
+ 'query.month_number' => $model->getQuery()->getBegin()->format('m'),
+ 'query.year' => $model->getQuery()->getBegin()->format('Y'),
+ ];
+
+ if (null !== $model->getUser()) {
+ $user = $model->getUser();
+
+ /** @var UserPreference $metaField */
+ foreach ($user->getPreferences() as $metaField) {
+ $values = array_merge($values, [
+ 'user.meta.' . $metaField->getName() => $metaField->getValue(),
+ ]);
+ }
+
+ $values = array_merge($values, [
+ 'user.name' => $user->getUsername(),
+ 'user.email' => $user->getEmail(),
+ 'user.title' => $user->getTitle(),
+ 'user.alias' => $user->getAlias(),
+ ]);
+ }
+
+ if (null !== $activity) {
+ $values = array_merge($values, [
+ 'activity.id' => $activity->getId(),
+ 'activity.name' => $activity->getName(),
+ 'activity.comment' => $activity->getComment(),
+ 'activity.fixed_rate' => $formatter->getFormattedMoney($activity->getFixedRate(), $currency),
+ 'activity.fixed_rate_nc' => $formatter->getFormattedMoney($activity->getFixedRate(), null),
+ 'activity.fixed_rate_plain' => $activity->getFixedRate(),
+ 'activity.hourly_rate' => $formatter->getFormattedMoney($activity->getHourlyRate(), $currency),
+ 'activity.hourly_rate_nc' => $formatter->getFormattedMoney($activity->getHourlyRate(), null),
+ 'activity.hourly_rate_plain' => $activity->getHourlyRate(),
+ ]);
+
+ foreach ($activity->getVisibleMetaFields() as $metaField) {
+ $values = array_merge($values, [
+ 'activity.meta.' . $metaField->getName() => $metaField->getValue(),
+ ]);
+ }
+ }
+
+ if (null !== $project) {
+ $values = array_merge($values, [
+ 'project.id' => $project->getId(),
+ 'project.name' => $project->getName(),
+ 'project.comment' => $project->getComment(),
+ 'project.order_number' => $project->getOrderNumber(),
+ 'project.order_date' => null !== $project->getOrderDate() ? $formatter->getFormattedDateTime($project->getOrderDate()) : '',
+ 'project.fixed_rate' => $formatter->getFormattedMoney($project->getFixedRate(), $currency),
+ 'project.fixed_rate_nc' => $formatter->getFormattedMoney($project->getFixedRate(), null),
+ 'project.fixed_rate_plain' => $project->getFixedRate(),
+ 'project.hourly_rate' => $formatter->getFormattedMoney($project->getHourlyRate(), $currency),
+ 'project.hourly_rate_nc' => $formatter->getFormattedMoney($project->getHourlyRate(), null),
+ 'project.hourly_rate_plain' => $project->getHourlyRate(),
+ ]);
+
+ foreach ($project->getVisibleMetaFields() as $metaField) {
+ $values = array_merge($values, [
+ 'project.meta.' . $metaField->getName() => $metaField->getValue(),
+ ]);
+ }
+ }
+
+ if (null !== $customer) {
+ $values = array_merge($values, [
+ 'customer.id' => $customer->getId(),
+ 'customer.address' => $customer->getAddress(),
+ 'customer.name' => $customer->getName(),
+ 'customer.contact' => $customer->getContact(),
+ 'customer.company' => $customer->getCompany(),
+ 'customer.vat' => $customer->getVatId(),
+ 'customer.number' => $customer->getNumber(),
+ 'customer.country' => $customer->getCountry(),
+ 'customer.homepage' => $customer->getHomepage(),
+ 'customer.comment' => $customer->getComment(),
+ 'customer.fixed_rate' => $formatter->getFormattedMoney($customer->getFixedRate(), $currency),
+ 'customer.fixed_rate_nc' => $formatter->getFormattedMoney($customer->getFixedRate(), null),
+ 'customer.fixed_rate_plain' => $customer->getFixedRate(),
+ 'customer.hourly_rate' => $formatter->getFormattedMoney($customer->getHourlyRate(), $currency),
+ 'customer.hourly_rate_nc' => $formatter->getFormattedMoney($customer->getHourlyRate(), null),
+ 'customer.hourly_rate_plain' => $customer->getHourlyRate(),
+ ]);
+
+ foreach ($customer->getVisibleMetaFields() as $metaField) {
+ $values = array_merge($values, [
+ 'customer.meta.' . $metaField->getName() => $metaField->getValue(),
+ ]);
+ }
+ }
+
+ return $values;
+ }
+
+ public function itemToArray(InvoiceItem $invoiceItem): array
+ {
+ $formatter = $this->getFormatter();
+
+ $rate = $invoiceItem->getRate();
+ $appliedRate = $invoiceItem->getHourlyRate();
+ $amount = $formatter->getFormattedDuration($invoiceItem->getDuration());
+ $description = $invoiceItem->getDescription();
+
+ if ($invoiceItem->isFixedRate()) {
+ $appliedRate = $invoiceItem->getFixedRate();
+ $amount = $invoiceItem->getAmount();
+ }
+
+ if (empty($description)) {
+ $description = $invoiceItem->getActivity()->getName();
+ }
+
+ $user = $invoiceItem->getUser();
+
+ // this should never happen!
+ if (empty($appliedRate)) {
+ $appliedRate = 0;
+ }
+
+ $activity = $invoiceItem->getActivity();
+ $project = $invoiceItem->getProject();
+ $customer = $project->getCustomer();
+ $currency = $customer->getCurrency();
+
+ $begin = $invoiceItem->getBegin();
+ $end = $invoiceItem->getEnd();
+
+ $values = [
+ 'entry.row' => '',
+ 'entry.description' => $description,
+ 'entry.amount' => $amount,
+ 'entry.type' => $invoiceItem->getType(),
+ 'entry.category' => $invoiceItem->getCategory(),
+ 'entry.rate' => $formatter->getFormattedMoney($appliedRate, $currency),
+ 'entry.rate_nc' => $formatter->getFormattedMoney($appliedRate, null),
+ 'entry.rate_plain' => $appliedRate,
+ 'entry.total' => $formatter->getFormattedMoney($rate, $currency),
+ 'entry.total_nc' => $formatter->getFormattedMoney($rate, null),
+ 'entry.total_plain' => $rate,
+ 'entry.currency' => $currency,
+ 'entry.duration' => $invoiceItem->getDuration(),
+ 'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($invoiceItem->getDuration()),
+ 'entry.duration_minutes' => number_format($invoiceItem->getDuration() / 60),
+ 'entry.begin' => $formatter->getFormattedDateTime($begin),
+ 'entry.begin_time' => $formatter->getFormattedTime($begin),
+ 'entry.begin_timestamp' => $begin->getTimestamp(),
+ 'entry.end' => $formatter->getFormattedDateTime($end),
+ 'entry.end_time' => $formatter->getFormattedTime($end),
+ 'entry.end_timestamp' => $end->getTimestamp(),
+ 'entry.date' => $formatter->getFormattedDateTime($begin),
+ 'entry.user_id' => $user->getId(),
+ 'entry.user_name' => $user->getUsername(),
+ 'entry.user_title' => $user->getTitle(),
+ 'entry.user_alias' => $user->getAlias(),
+ 'entry.activity' => $activity->getName(),
+ 'entry.activity_id' => $activity->getId(),
+ 'entry.project' => $project->getName(),
+ 'entry.project_id' => $project->getId(),
+ 'entry.customer' => $customer->getName(),
+ 'entry.customer_id' => $customer->getId(),
+ ];
+
+ foreach ($invoiceItem->getAdditionalFields() as $name => $value) {
+ $values = array_merge($values, [
+ 'entry.meta.' . $name => $value,
+ ]);
+ }
+
+ return $values;
+ }
}
diff --git a/src/Invoice/Renderer/AbstractRenderer.php b/src/Invoice/Renderer/AbstractRenderer.php
index 412d1867..1b9974cb 100644
--- a/src/Invoice/Renderer/AbstractRenderer.php
+++ b/src/Invoice/Renderer/AbstractRenderer.php
@@ -9,93 +9,54 @@
namespace App\Invoice\Renderer;
-use App\Twig\DateExtensions;
-use App\Twig\Extensions;
-use Symfony\Contracts\Translation\TranslatorInterface;
+use App\Entity\InvoiceDocument;
+use Symfony\Component\HttpFoundation\BinaryFileResponse;
+use Symfony\Component\HttpFoundation\ResponseHeaderBag;
+/**
+ * @internal
+ */
abstract class AbstractRenderer
{
- use RendererTrait;
-
/**
- * @var DateExtensions
+ * @return string[]
*/
- protected $dateExtension;
+ abstract protected function getFileExtensions();
/**
- * @var Extensions
- */
- protected $extension;
-
- /**
- * @var TranslatorInterface
- */
- protected $translator;
-
- /**
- * @param TranslatorInterface $translator
- * @param DateExtensions $dateExtension
- * @param Extensions $extensions
- */
- public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, Extensions $extensions)
- {
- $this->translator = $translator;
- $this->dateExtension = $dateExtension;
- $this->extension = $extensions;
- }
-
- /**
- * @param \DateTime $date
- * @return mixed
- */
- protected function getFormattedDateTime(\DateTime $date)
- {
- return $this->dateExtension->dateShort($date);
- }
-
- /**
- * @param \DateTime $date
- * @return mixed
- */
- protected function getFormattedTime(\DateTime $date)
- {
- return $this->dateExtension->time($date);
- }
-
- /**
- * @param int $amount
- * @param string $currency
* @return string
*/
- protected function getFormattedMoney($amount, $currency)
+ abstract protected function getContentType();
+
+ /**
+ * @param InvoiceDocument $document
+ * @return bool
+ */
+ public function supports(InvoiceDocument $document): bool
{
- return $this->extension->money($amount, $currency);
+ foreach ($this->getFileExtensions() as $extension) {
+ if (stripos($document->getFilename(), $extension) !== false) {
+ return true;
+ }
+ }
+
+ return false;
}
/**
- * @param \DateTime $date
- * @return mixed
+ * @param mixed $file
+ * @param string $filename
+ * @return BinaryFileResponse
*/
- protected function getFormattedMonthName(\DateTime $date)
+ protected function getFileResponse($file, $filename)
{
- return $this->translator->trans($this->dateExtension->monthName($date));
- }
+ $response = new BinaryFileResponse($file);
+ $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
- /**
- * @param int $seconds
- * @return mixed
- */
- protected function getFormattedDuration($seconds)
- {
- return $this->extension->duration($seconds);
- }
+ $response->headers->set('Content-Type', $this->getContentType());
+ $response->headers->set('Content-Disposition', $disposition);
+ $response->deleteFileAfterSend(true);
- /**
- * @param int $seconds
- * @return mixed
- */
- protected function getFormattedDecimalDuration($seconds)
- {
- return $this->extension->durationDecimal($seconds);
+ return $response;
}
}
diff --git a/src/Invoice/Renderer/AbstractSpreadsheetRenderer.php b/src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
index b6690d53..f9a1570b 100644
--- a/src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
+++ b/src/Invoice/Renderer/AbstractSpreadsheetRenderer.php
@@ -16,6 +16,9 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\Response;
+/**
+ * @internal
+ */
abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
{
/**
@@ -40,7 +43,7 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
$spreadsheet = IOFactory::load($document->getFilename());
$worksheet = $spreadsheet->getActiveSheet();
$entries = $model->getCalculator()->getEntries();
- $replacer = $this->modelToReplacer($model);
+ $sheetReplacer = $model->toArray();
$invoiceItemCount = count($entries);
if ($invoiceItemCount > 1) {
$this->addTemplateRows($worksheet, $invoiceItemCount);
@@ -57,26 +60,37 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
$entryRow = 0;
foreach ($worksheet->getRowIterator() as $row) {
- $invoiceItem = $entries[$entryRow];
$sheetValues = false;
foreach ($row->getCellIterator() as $cell) {
$value = $cell->getValue();
- if (stripos($value, '${entry.') !== false) {
- if ($sheetValues === false) {
- $sheetValues = $this->invoiceItemToArray($invoiceItem);
- }
- $searcher = str_replace('${', '', $value);
- $searcher = str_replace('}', '', $searcher);
- if (isset($sheetValues[$searcher])) {
- $cell->setValue($sheetValues[$searcher]);
- }
- } elseif (stripos($value, '${') !== false) {
- $searcher = str_replace('${', '', $value);
- $searcher = str_replace('}', '', $searcher);
- if (isset($replacer[$searcher])) {
- $cell->setValue($replacer[$searcher]);
- }
+ $replacer = null;
+ if (stripos($value, '${') === false) {
+ continue;
}
+
+ if (stripos($value, '${entry.') !== false) {
+ if ($sheetValues === false && isset($entries[$entryRow])) {
+ $sheetValues = $model->itemToArray($entries[$entryRow]);
+ }
+ $replacer = $sheetValues;
+ } elseif (stripos($value, '${') !== false) {
+ $replacer = $sheetReplacer;
+ }
+
+ if (empty($replacer)) {
+ continue;
+ }
+
+ // we can have mixed cell content, which makes it much more complicated
+ foreach ($replacer as $key => $content) {
+ $searchKey = '${' . $key . '}';
+ if (stripos($value, $searchKey) === false) {
+ continue;
+ }
+ $value = str_replace($searchKey, $content, $value);
+ }
+
+ $cell->setValue($value);
}
if ($sheetValues !== false && $entryRow < $invoiceItemCount - 1) {
@@ -105,7 +119,7 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
$value = $cell->getValue();
if (stripos($value, '${entry.') !== false) {
$startRow = $row->getRowIndex();
- $worksheet->insertNewRowBefore($row->getRowIndex(), $invoiceItemCount - 1);
+ $worksheet->insertNewRowBefore($startRow + 1, $invoiceItemCount - 1);
break 2;
}
@@ -123,15 +137,18 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
throw new \Exception('Invalid invoice document, no template row found.');
}
- // fill up all new rows with template values
- $templateRow = $invoiceItemCount + $startRow;
- $iterator = $worksheet->getRowIterator($templateRow - 1, $templateRow);
+ // fill up all new rows with template replacer
+ $templateRow = $startRow;
+ $iterator = $worksheet->getRowIterator($templateRow, $templateRow + 1);
+
$templateColumns = [];
- foreach ($iterator->current()->getCellIterator() as $cell) {
+
+ $tmpRow = $iterator->current();
+ foreach ($tmpRow->getCellIterator() as $cell) {
$templateColumns[$cell->getColumn()] = $cell->getValue();
}
- $iterator = $worksheet->getRowIterator($startRow, $templateRow - 2);
+ $iterator = $worksheet->getRowIterator($startRow, $startRow + $invoiceItemCount - 1);
foreach ($iterator as $row) {
foreach ($row->getCellIterator() as $cell) {
$cell->setValue($templateColumns[$cell->getColumn()]);
diff --git a/src/Invoice/Renderer/CsvRenderer.php b/src/Invoice/Renderer/CsvRenderer.php
index cc4a7d6e..0e703d17 100644
--- a/src/Invoice/Renderer/CsvRenderer.php
+++ b/src/Invoice/Renderer/CsvRenderer.php
@@ -13,7 +13,7 @@ use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
-class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
+final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string[]
diff --git a/src/Invoice/Renderer/DocxRenderer.php b/src/Invoice/Renderer/DocxRenderer.php
index b219fdf3..07779d6f 100644
--- a/src/Invoice/Renderer/DocxRenderer.php
+++ b/src/Invoice/Renderer/DocxRenderer.php
@@ -19,7 +19,7 @@ use PhpOffice\PhpWord\TemplateProcessor;
use Symfony\Component\HttpFoundation\File\Stream;
use Symfony\Component\HttpFoundation\Response;
-class DocxRenderer extends AbstractRenderer implements RendererInterface
+final class DocxRenderer extends AbstractRenderer implements RendererInterface
{
/**
* @param InvoiceDocument $document
@@ -35,7 +35,7 @@ class DocxRenderer extends AbstractRenderer implements RendererInterface
$xmlEscaper = new Xml();
$template = new TemplateProcessor($document->getFilename());
- foreach ($this->modelToReplacer($model) as $search => $replace) {
+ foreach ($model->toArray() as $search => $replace) {
$replace = $xmlEscaper->escape($replace);
$replace = str_replace(PHP_EOL, '', $replace);
@@ -45,12 +45,18 @@ class DocxRenderer extends AbstractRenderer implements RendererInterface
try {
$template->cloneRow('entry.description', count($model->getCalculator()->getEntries()));
} catch (OfficeException $ex) {
- $template->cloneRow('entry.row', count($model->getCalculator()->getEntries()));
+ try {
+ $template->cloneRow('entry.row', count($model->getCalculator()->getEntries()));
+ } catch (OfficeException $ex) {
+ @trigger_error(
+ sprintf('Invoice document (%s) did not contain a clone row, was that on purpose?', $document->getFilename())
+ );
+ }
}
$i = 1;
foreach ($model->getCalculator()->getEntries() as $entry) {
- $values = $this->invoiceItemToArray($entry);
+ $values = $model->itemToArray($entry);
foreach ($values as $search => $replace) {
$replace = $xmlEscaper->escape($replace);
$replace = str_replace(PHP_EOL, '', $replace);
diff --git a/src/Invoice/Renderer/OdsRenderer.php b/src/Invoice/Renderer/OdsRenderer.php
index 3dde4dbd..8b145fdb 100644
--- a/src/Invoice/Renderer/OdsRenderer.php
+++ b/src/Invoice/Renderer/OdsRenderer.php
@@ -13,7 +13,7 @@ use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
-class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
+final class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string[]
diff --git a/src/Invoice/Renderer/RendererTrait.php b/src/Invoice/Renderer/RendererTrait.php
index 38722ba8..91cd0179 100644
--- a/src/Invoice/Renderer/RendererTrait.php
+++ b/src/Invoice/Renderer/RendererTrait.php
@@ -9,181 +9,28 @@
namespace App\Invoice\Renderer;
-use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel;
-use Symfony\Component\HttpFoundation\BinaryFileResponse;
-use Symfony\Component\HttpFoundation\ResponseHeaderBag;
trait RendererTrait
{
/**
- * @return string[]
+ * @var InvoiceModel
*/
- abstract protected function getFileExtensions();
-
- /**
- * @return string
- */
- abstract protected function getContentType();
-
- /**
- * @param InvoiceDocument $document
- * @return bool
- */
- public function supports(InvoiceDocument $document): bool
- {
- foreach ($this->getFileExtensions() as $extension) {
- if (stripos($document->getFilename(), $extension) !== false) {
- return true;
- }
- }
-
- return false;
- }
-
- /**
- * @param \DateTime $date
- * @return mixed
- */
- abstract protected function getFormattedDateTime(\DateTime $date);
-
- /**
- * @param \DateTime $date
- * @return mixed
- */
- abstract protected function getFormattedTime(\DateTime $date);
-
- /**
- * @param int $amount
- * @param string|null $currency
- * @return mixed
- */
- abstract protected function getFormattedMoney($amount, $currency);
-
- /**
- * @param \DateTime $date
- * @return mixed
- */
- abstract protected function getFormattedMonthName(\DateTime $date);
-
- /**
- * @param int $seconds
- * @return mixed
- */
- abstract protected function getFormattedDuration($seconds);
-
- /**
- * @param int $seconds
- * @return mixed
- */
- abstract protected function getFormattedDecimalDuration($seconds);
+ private $model;
/**
+ * @deprecated since 1.6.2 - will be removed with 2.0
* @param InvoiceModel $model
* @return array
*/
protected function modelToReplacer(InvoiceModel $model)
{
- $customer = $model->getCustomer();
- $project = $model->getQuery()->getProject();
- $activity = $model->getQuery()->getActivity();
- $currency = $model->getCalculator()->getCurrency();
- $tax = $model->getCalculator()->getTax();
- $total = $model->getCalculator()->getTotal();
- $subtotal = $model->getCalculator()->getSubtotal();
+ @trigger_error('modelToReplacer() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
- $values = [
- 'invoice.due_date' => $this->getFormattedDateTime($model->getDueDate()),
- 'invoice.date' => $this->getFormattedDateTime($model->getInvoiceDate()),
- 'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
- 'invoice.currency' => $currency,
- 'invoice.vat' => $model->getCalculator()->getVat(),
- 'invoice.tax' => $this->getFormattedMoney($tax, $currency),
- 'invoice.tax_nc' => $this->getFormattedMoney($tax, null),
- 'invoice.total_time' => $this->getFormattedDuration($model->getCalculator()->getTimeWorked()),
- 'invoice.duration_decimal' => $this->getFormattedDecimalDuration($model->getCalculator()->getTimeWorked()),
- 'invoice.total' => $this->getFormattedMoney($total, $currency),
- 'invoice.total_nc' => $this->getFormattedMoney($total, null),
- 'invoice.subtotal' => $this->getFormattedMoney($subtotal, $currency),
- 'invoice.subtotal_nc' => $this->getFormattedMoney($subtotal, null),
+ $this->model = $model;
- 'template.name' => $model->getTemplate()->getName(),
- 'template.company' => $model->getTemplate()->getCompany(),
- 'template.address' => $model->getTemplate()->getAddress(),
- 'template.title' => $model->getTemplate()->getTitle(),
- 'template.payment_terms' => $model->getTemplate()->getPaymentTerms(),
- 'template.due_days' => $model->getTemplate()->getDueDays(),
- 'template.vat_id' => $model->getTemplate()->getVatId(),
- 'template.contact' => $model->getTemplate()->getContact(),
- 'template.payment_details' => $model->getTemplate()->getPaymentDetails(),
-
- 'query.begin' => $this->getFormattedDateTime($model->getQuery()->getBegin()),
- 'query.day' => $model->getQuery()->getBegin()->format('d'),
- 'query.end' => $this->getFormattedDateTime($model->getQuery()->getEnd()),
- 'query.month' => $this->getFormattedMonthName($model->getQuery()->getBegin()),
- 'query.month_number' => $model->getQuery()->getBegin()->format('m'),
- 'query.year' => $model->getQuery()->getBegin()->format('Y'),
- ];
-
- if (null !== $activity) {
- $values = array_merge($values, [
- 'activity.id' => $activity->getId(),
- 'activity.name' => $activity->getName(),
- 'activity.comment' => $activity->getComment(),
- 'activity.fixed_rate' => $activity->getFixedRate(),
- 'activity.hourly_rate' => $activity->getHourlyRate(),
- ]);
-
- foreach ($activity->getVisibleMetaFields() as $metaField) {
- $values = array_merge($values, [
- 'activity.meta.' . $metaField->getName() => $metaField->getValue(),
- ]);
- }
- }
-
- if (null !== $project) {
- $values = array_merge($values, [
- 'project.id' => $project->getId(),
- 'project.name' => $project->getName(),
- 'project.comment' => $project->getComment(),
- 'project.order_number' => $project->getOrderNumber(),
- 'project.order_date' => null !== $project->getOrderDate() ? $this->getFormattedDateTime($project->getOrderDate()) : '',
- 'project.fixed_rate' => $project->getFixedRate(),
- 'project.hourly_rate' => $project->getHourlyRate(),
- ]);
-
- foreach ($project->getVisibleMetaFields() as $metaField) {
- $values = array_merge($values, [
- 'project.meta.' . $metaField->getName() => $metaField->getValue(),
- ]);
- }
- }
-
- if (null !== $customer) {
- $values = array_merge($values, [
- 'customer.id' => $customer->getId(),
- 'customer.address' => $customer->getAddress(),
- 'customer.name' => $customer->getName(),
- 'customer.contact' => $customer->getContact(),
- 'customer.company' => $customer->getCompany(),
- 'customer.vat' => $customer->getVatId(),
- 'customer.number' => $customer->getNumber(),
- 'customer.country' => $customer->getCountry(),
- 'customer.homepage' => $customer->getHomepage(),
- 'customer.comment' => $customer->getComment(),
- 'customer.fixed_rate' => $customer->getFixedRate(),
- 'customer.hourly_rate' => $customer->getHourlyRate(),
- ]);
-
- foreach ($customer->getVisibleMetaFields() as $metaField) {
- $values = array_merge($values, [
- 'customer.meta.' . $metaField->getName() => $metaField->getValue(),
- ]);
- }
- }
-
- return $values;
+ return $model->toArray();
}
/**
@@ -193,94 +40,18 @@ trait RendererTrait
{
@trigger_error('timesheetToArray() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
- return $this->invoiceItemToArray($invoiceItem);
- }
-
- protected function invoiceItemToArray(InvoiceItem $invoiceItem): array
- {
- $rate = $invoiceItem->getRate();
- $hourlyRate = $invoiceItem->getHourlyRate();
- $amount = $this->getFormattedDuration($invoiceItem->getDuration());
- $description = $invoiceItem->getDescription();
-
- if ($invoiceItem->isFixedRate()) {
- $hourlyRate = $invoiceItem->getFixedRate();
- $amount = $invoiceItem->getAmount();
- }
-
- if (empty($description)) {
- $description = $invoiceItem->getActivity()->getName();
- }
-
- $user = $invoiceItem->getUser();
-
- // this should never happen!
- if (empty($hourlyRate)) {
- $hourlyRate = 0;
- }
-
- $activity = $invoiceItem->getActivity();
- $project = $invoiceItem->getProject();
- $customer = $project->getCustomer();
- $currency = $customer->getCurrency();
-
- $begin = $invoiceItem->getBegin();
- $end = $invoiceItem->getEnd();
-
- $values = [
- 'entry.row' => '',
- 'entry.description' => $description,
- 'entry.amount' => $amount,
- 'entry.rate' => $this->getFormattedMoney($hourlyRate, $currency),
- 'entry.rate_nc' => $this->getFormattedMoney($hourlyRate, null),
- 'entry.total' => $this->getFormattedMoney($rate, $currency),
- 'entry.total_nc' => $this->getFormattedMoney($rate, null),
- 'entry.currency' => $currency,
- 'entry.duration' => $invoiceItem->getDuration(),
- 'entry.duration_decimal' => $this->getFormattedDecimalDuration($invoiceItem->getDuration()),
- 'entry.duration_minutes' => number_format($invoiceItem->getDuration() / 60),
- 'entry.begin' => $this->getFormattedDateTime($begin),
- 'entry.begin_time' => $this->getFormattedTime($begin),
- 'entry.begin_timestamp' => $begin->getTimestamp(),
- 'entry.end' => $this->getFormattedDateTime($end),
- 'entry.end_time' => $this->getFormattedTime($end),
- 'entry.end_timestamp' => $end->getTimestamp(),
- 'entry.date' => $this->getFormattedDateTime($begin),
- 'entry.user_id' => $user->getId(),
- 'entry.user_name' => $user->getUsername(),
- 'entry.user_title' => $user->getTitle(),
- 'entry.user_alias' => $user->getAlias(),
- 'entry.activity' => $activity->getName(),
- 'entry.activity_id' => $activity->getId(),
- 'entry.project' => $project->getName(),
- 'entry.project_id' => $project->getId(),
- 'entry.customer' => $customer->getName(),
- 'entry.customer_id' => $customer->getId(),
- ];
-
- foreach ($invoiceItem->getAdditionalFields() as $name => $value) {
- $values = array_merge($values, [
- 'entry.meta.' . $name => $value,
- ]);
- }
-
- return $values;
+ return $this->model->itemToArray($invoiceItem);
}
/**
- * @param mixed $file
- * @param string $filename
- * @return BinaryFileResponse
+ * @deprecated since 1.6.2 - will be removed with 2.0
+ * @param InvoiceItem $invoiceItem
+ * @return array
*/
- protected function getFileResponse($file, $filename)
+ protected function invoiceItemToArray(InvoiceItem $invoiceItem): array
{
- $response = new BinaryFileResponse($file);
- $disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
+ @trigger_error('invoiceItemToArray() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
- $response->headers->set('Content-Type', $this->getContentType());
- $response->headers->set('Content-Disposition', $disposition);
- $response->deleteFileAfterSend(true);
-
- return $response;
+ return $this->model->itemToArray($invoiceItem);
}
}
diff --git a/src/Invoice/Renderer/XlsxRenderer.php b/src/Invoice/Renderer/XlsxRenderer.php
index be2cc8c4..9a0bd84e 100644
--- a/src/Invoice/Renderer/XlsxRenderer.php
+++ b/src/Invoice/Renderer/XlsxRenderer.php
@@ -13,7 +13,7 @@ use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
-class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
+final class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string[]
diff --git a/src/Repository/ActivityRepository.php b/src/Repository/ActivityRepository.php
index 8f5c8a1d..f250d078 100644
--- a/src/Repository/ActivityRepository.php
+++ b/src/Repository/ActivityRepository.php
@@ -346,6 +346,18 @@ class ActivityRepository extends EntityRepository
return $qb;
}
+ public function countActivitiesForQuery(ActivityQuery $query): int
+ {
+ $qb = $this->getQueryBuilderForQuery($query);
+ $qb
+ ->resetDQLPart('select')
+ ->resetDQLPart('orderBy')
+ ->select($qb->expr()->countDistinct('a.id'))
+ ;
+
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
+
public function getPagerfantaForQuery(ActivityQuery $query): Pagerfanta
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -357,14 +369,7 @@ class ActivityRepository extends EntityRepository
protected function getPaginatorForQuery(ActivityQuery $query): PaginatorInterface
{
- $qb = $this->getQueryBuilderForQuery($query);
- $qb
- ->resetDQLPart('select')
- ->resetDQLPart('orderBy')
- ->select($qb->expr()->countDistinct('a.id'))
- ;
- $counter = (int) $qb->getQuery()->getSingleScalarResult();
-
+ $counter = $this->countActivitiesForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new ActivityLoader($qb->getEntityManager()), $qb, $counter);
diff --git a/src/Repository/CustomerRepository.php b/src/Repository/CustomerRepository.php
index 70d41202..2ccfc0ba 100644
--- a/src/Repository/CustomerRepository.php
+++ b/src/Repository/CustomerRepository.php
@@ -272,7 +272,7 @@ class CustomerRepository extends EntityRepository
return $paginator;
}
- protected function getPaginatorForQuery(CustomerQuery $query): PaginatorInterface
+ public function countCustomersForQuery(CustomerQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
@@ -280,8 +280,13 @@ class CustomerRepository extends EntityRepository
->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('c.id'))
;
- $counter = (int) $qb->getQuery()->getSingleScalarResult();
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
+
+ protected function getPaginatorForQuery(CustomerQuery $query): PaginatorInterface
+ {
+ $counter = $this->countCustomersForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new CustomerLoader($qb->getEntityManager()), $qb, $counter);
diff --git a/src/Repository/ProjectRepository.php b/src/Repository/ProjectRepository.php
index 40f3dc8e..c2e1b191 100644
--- a/src/Repository/ProjectRepository.php
+++ b/src/Repository/ProjectRepository.php
@@ -283,6 +283,18 @@ class ProjectRepository extends EntityRepository
return $qb;
}
+ public function countProjectsForQuery(ProjectQuery $query): int
+ {
+ $qb = $this->getQueryBuilderForQuery($query);
+ $qb
+ ->resetDQLPart('select')
+ ->resetDQLPart('orderBy')
+ ->select($qb->expr()->countDistinct('p.id'))
+ ;
+
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
+
public function getPagerfantaForQuery(ProjectQuery $query): Pagerfanta
{
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -294,14 +306,7 @@ class ProjectRepository extends EntityRepository
private function getPaginatorForQuery(ProjectQuery $query): PaginatorInterface
{
- $qb = $this->getQueryBuilderForQuery($query);
- $qb
- ->resetDQLPart('select')
- ->resetDQLPart('orderBy')
- ->select($qb->expr()->countDistinct('p.id'))
- ;
- $counter = (int) $qb->getQuery()->getSingleScalarResult();
-
+ $counter = $this->countProjectsForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new ProjectLoader($qb->getEntityManager()), $qb, $counter);
diff --git a/src/Repository/TimesheetRepository.php b/src/Repository/TimesheetRepository.php
index 9a811207..d5d0ce98 100644
--- a/src/Repository/TimesheetRepository.php
+++ b/src/Repository/TimesheetRepository.php
@@ -640,6 +640,10 @@ class TimesheetRepository extends EntityRepository
$currentUser = $query->getCurrentUser();
if (!$currentUser->isSuperAdmin() && !$currentUser->isAdmin()) {
+ // make sure that the user himself is in the list of users, if he is part of a team
+ // if teams are used and the user is not a teamlead, the list of users would be empty and then leading to NOT limit the select by user IDs
+ $user[] = $currentUser;
+
foreach ($currentUser->getTeams() as $team) {
if ($currentUser->isTeamleadOf($team)) {
$query->addTeam($team);
diff --git a/src/Repository/UserRepository.php b/src/Repository/UserRepository.php
index 0e413220..a83db1b7 100644
--- a/src/Repository/UserRepository.php
+++ b/src/Repository/UserRepository.php
@@ -236,7 +236,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
return $paginator;
}
- protected function getPaginatorForQuery(UserQuery $query): PaginatorInterface
+ public function countUsersForQuery(UserQuery $query): int
{
$qb = $this->getQueryBuilderForQuery($query);
$qb
@@ -244,8 +244,13 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('u.id'))
;
- $counter = (int) $qb->getQuery()->getSingleScalarResult();
+ return (int) $qb->getQuery()->getSingleScalarResult();
+ }
+
+ protected function getPaginatorForQuery(UserQuery $query): PaginatorInterface
+ {
+ $counter = $this->countUsersForQuery($query);
$qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new UserLoader($qb->getEntityManager()), $qb, $counter);
diff --git a/src/Validator/Constraints/TimesheetValidator.php b/src/Validator/Constraints/TimesheetValidator.php
index 6b0e064e..dc008c95 100644
--- a/src/Validator/Constraints/TimesheetValidator.php
+++ b/src/Validator/Constraints/TimesheetValidator.php
@@ -119,12 +119,16 @@ class TimesheetValidator extends ConstraintValidator
->addViolation();
}
- if (false === $this->configuration->isAllowFutureTimes() && time() < $timesheet->getBegin()->getTimestamp()) {
- $context->buildViolation('The begin date cannot be in the future.')
- ->atPath('begin')
- ->setTranslationDomain('validators')
- ->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR)
- ->addViolation();
+ if (false === $this->configuration->isAllowFutureTimes()) {
+ // allow configured default rounding time + 1 minute - see #1295
+ $allowedDiff = ($this->configuration->getDefaultRoundingBegin() * 60) + 60;
+ if ((time() + $allowedDiff) < $timesheet->getBegin()->getTimestamp()) {
+ $context->buildViolation('The begin date cannot be in the future.')
+ ->atPath('begin')
+ ->setTranslationDomain('validators')
+ ->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR)
+ ->addViolation();
+ }
}
}
diff --git a/templates/invoice/index.html.twig b/templates/invoice/index.html.twig
index 5071b0d1..5641d53a 100644
--- a/templates/invoice/index.html.twig
+++ b/templates/invoice/index.html.twig
@@ -37,7 +37,9 @@
{{ form_row(form.customer) }}
{{ form_row(form.project) }}
{{ form_row(form.activity) }}
- {{ form_row(form.users) }}
+ {% if form.users is defined %}
+ {{ form_row(form.users) }}
+ {% endif %}
{{ form_row(form.tags) }}
{{ form_row(form.exported) }}
{{ form_row(form.template) }}
diff --git a/templates/invoice/renderer/company.docx b/templates/invoice/renderer/company.docx
index d2c07619..50381146 100644
Binary files a/templates/invoice/renderer/company.docx and b/templates/invoice/renderer/company.docx differ
diff --git a/templates/invoice/renderer/default.html.twig b/templates/invoice/renderer/default.html.twig
index 253c07df..30594cff 100644
--- a/templates/invoice/renderer/default.html.twig
+++ b/templates/invoice/renderer/default.html.twig
@@ -110,7 +110,7 @@
| {{ model.calculator.tax|money(model.calculator.currency) }} |
- |
+ |
{{ 'invoice.total'|trans }}
|
@@ -134,7 +134,7 @@
|
- {{ rate|money(model.calculator.currency) }} |
- {{ duration }} |
- {{ entry.rate|money(model.calculator.currency) }} |
+ {{ rate|money(model.calculator.currency) }} |
+ {{ duration }} |
+ {{ entry.rate|money(model.calculator.currency) }} |
{% endfor %}
@@ -120,9 +113,6 @@
{{ model.template.paymentTerms|nl2br|md2html }}
- {% if signature is not empty %}
-
- {% endif %}
{% endif %}
+ {{ model.template.company }}
{{ model.template.address|nl2br }}
diff --git a/tests/API/ConfigurationControllerTest.php b/tests/API/ConfigurationControllerTest.php
index 7cf56cb6..9db000fc 100644
--- a/tests/API/ConfigurationControllerTest.php
+++ b/tests/API/ConfigurationControllerTest.php
@@ -16,7 +16,7 @@ use App\Entity\User;
*/
class ConfigurationControllerTest extends APIControllerBaseTest
{
- public function testI18nIsSecure()
+ public function testIsSecure()
{
$this->assertUrlIsSecured('/api/config/i18n');
}
diff --git a/tests/API/StatusControllerTest.php b/tests/API/StatusControllerTest.php
index 94514798..5451d0b5 100644
--- a/tests/API/StatusControllerTest.php
+++ b/tests/API/StatusControllerTest.php
@@ -20,6 +20,7 @@ class StatusControllerTest extends APIControllerBaseTest
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/ping');
+ $this->assertUrlIsSecured('/api/version');
}
public function testPing()
diff --git a/tests/API/TagControllerTest.php b/tests/API/TagControllerTest.php
index 1967eced..82af3a08 100644
--- a/tests/API/TagControllerTest.php
+++ b/tests/API/TagControllerTest.php
@@ -31,6 +31,11 @@ class TagControllerTest extends APIControllerBaseTest
$this->importFixture($em, $fixture);
}
+ public function testIsSecure()
+ {
+ $this->assertUrlIsSecured('/api/tags');
+ }
+
public function testGetCollection()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
diff --git a/tests/API/TeamControllerTest.php b/tests/API/TeamControllerTest.php
index 1257bd48..5366d0fa 100644
--- a/tests/API/TeamControllerTest.php
+++ b/tests/API/TeamControllerTest.php
@@ -30,6 +30,8 @@ class TeamControllerTest extends APIControllerBaseTest
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/teams');
+ $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/teams');
+ $this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/api/teams');
}
public function testGetCollection()
@@ -56,7 +58,7 @@ class TeamControllerTest extends APIControllerBaseTest
public function testNotFound()
{
- $this->assertEntityNotFound(User::ROLE_USER, '/api/teams/3');
+ $this->assertEntityNotFound(User::ROLE_ADMIN, '/api/teams/3');
}
public function testDeleteActionWithUnknownTeam()
@@ -80,7 +82,7 @@ class TeamControllerTest extends APIControllerBaseTest
$this->assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
$this->assertEmpty($client->getResponse()->getContent());
- $this->assertEntityNotFound(User::ROLE_USER, '/api/teams/' . $id);
+ $this->assertEntityNotFound(User::ROLE_ADMIN, '/api/teams/' . $id);
}
protected function assertStructure(array $result, $full = true)
@@ -90,7 +92,9 @@ class TeamControllerTest extends APIControllerBaseTest
];
if ($full) {
- $expectedKeys = array_merge($expectedKeys, []);
+ $expectedKeys = array_merge($expectedKeys, [
+ 'teamlead', 'users'
+ ]);
}
$actual = array_keys($result);
diff --git a/tests/API/UserControllerTest.php b/tests/API/UserControllerTest.php
index 09a808aa..672f5f47 100644
--- a/tests/API/UserControllerTest.php
+++ b/tests/API/UserControllerTest.php
@@ -19,6 +19,8 @@ class UserControllerTest extends APIControllerBaseTest
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/users');
+ $this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/users');
+ $this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/api/users');
$this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/api/users');
}
@@ -118,7 +120,7 @@ class UserControllerTest extends APIControllerBaseTest
if ($full) {
$expectedKeys = array_merge(
$expectedKeys,
- ['title', 'avatar', 'roles', 'language', 'timezone']
+ ['title', 'avatar', 'teams', 'roles', 'language', 'timezone']
);
}
diff --git a/tests/Controller/ExportControllerTest.php b/tests/Controller/ExportControllerTest.php
index 9e9bbcb8..6a04c168 100644
--- a/tests/Controller/ExportControllerTest.php
+++ b/tests/Controller/ExportControllerTest.php
@@ -9,6 +9,7 @@
namespace App\Tests\Controller;
+use App\Entity\Team;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Tests\DataFixtures\TimesheetFixtures;
@@ -35,15 +36,79 @@ class ExportControllerTest extends ControllerBaseTest
$this->assertHasNoEntriesWithFilter($client);
}
- public function testIndexActionWithEntries()
+ public function testIndexActionWithEntriesAndTeams()
+ {
+ $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
+ $em = $client->getContainer()->get('doctrine.orm.entity_manager');
+
+ $teamlead = $this->getUserByRole($em, User::ROLE_TEAMLEAD);
+ $user = $this->getUserByRole($em, User::ROLE_USER);
+ /** @var Team $team */
+ $team = new Team();
+ $team->setName('fooo');
+ $team->setTeamLead($teamlead);
+ $team->addUser($user);
+ $em->persist($team);
+ $em->persist($user);
+ $em->persist($teamlead);
+ $em->flush();
+
+ $user = $this->getUserByRole($em, User::ROLE_USER);
+
+ $begin = new \DateTime('first day of this month');
+ $fixture = new TimesheetFixtures();
+ $fixture
+ ->setUser($user)
+ ->setAmount(20)
+ ->setStartDate($begin)
+ ->setCallback(function (Timesheet $timesheet) use ($team, $em) {
+ $team->addProject($timesheet->getProject());
+ $em->persist($team);
+ })
+ ;
+ $this->importFixture($em, $fixture);
+
+ $teamlead = $this->getUserByRole($em, User::ROLE_TEAMLEAD);
+
+ $fixture = new TimesheetFixtures();
+ $fixture
+ ->setUser($teamlead)
+ ->setAmount(2)
+ ->setStartDate($begin)
+ ;
+ $this->importFixture($em, $fixture);
+ $em->flush();
+
+ $this->request($client, '/export/?preview=');
+ $this->assertTrue($client->getResponse()->isSuccessful());
+
+ // make sure all existing records are displayed
+ $this->assertHasDataTable($client);
+ $this->assertDataTableRowCount($client, 'datatable_export', 22);
+
+ // assert export type buttons are available
+ $expected = ['csv', 'html', 'pdf', 'xlsx'];
+ $node = $client->getCrawler()->filter('#export-buttons button');
+ $this->assertEquals(count($expected), $node->count());
+ /** @var \DOMElement $button */
+ foreach ($node->getIterator() as $button) {
+ $type = $button->getAttribute('data-type');
+ $this->assertContains($type, $expected);
+ }
+ }
+
+ public function testIndexActionWithEntriesForTeamleadDoesNotShowUserWithoutTeam()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$begin = new \DateTime('first day of this month');
+ $user = $this->getUserByRole($em, User::ROLE_USER);
+
+ // these should be ignored, becuase teamlead and user do NOT share a team!
$fixture = new TimesheetFixtures();
$fixture
- ->setUser($this->getUserByRole($em, User::ROLE_USER))
+ ->setUser($user)
->setAmount(20)
->setStartDate($begin)
;
@@ -52,9 +117,25 @@ class ExportControllerTest extends ControllerBaseTest
$this->request($client, '/export/?preview=');
$this->assertTrue($client->getResponse()->isSuccessful());
+ // make sure all existing records are displayed
+ $this->assertHasNoEntriesWithFilter($client);
+
+ $teamlead = $this->getUserByRole($em, User::ROLE_TEAMLEAD);
+
+ $fixture = new TimesheetFixtures();
+ $fixture
+ ->setUser($teamlead)
+ ->setAmount(2)
+ ->setStartDate($begin)
+ ;
+ $this->importFixture($em, $fixture);
+
+ $this->request($client, '/export/?preview=');
+ $this->assertTrue($client->getResponse()->isSuccessful());
+
// make sure all existing records are displayed
$this->assertHasDataTable($client);
- $this->assertDataTableRowCount($client, 'datatable_export', 20);
+ $this->assertDataTableRowCount($client, 'datatable_export', 2);
// assert export type buttons are available
$expected = ['csv', 'html', 'pdf', 'xlsx'];
@@ -102,7 +183,7 @@ class ExportControllerTest extends ControllerBaseTest
public function testExportAction()
{
- $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
+ $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
diff --git a/tests/Controller/TimesheetTeamControllerTest.php b/tests/Controller/TimesheetTeamControllerTest.php
index 22d2fb74..9bdb5df3 100644
--- a/tests/Controller/TimesheetTeamControllerTest.php
+++ b/tests/Controller/TimesheetTeamControllerTest.php
@@ -125,7 +125,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
public function testExportAction()
{
- $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
+ $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TimesheetFixtures();
diff --git a/tests/DependencyInjection/ConfigurationTest.php b/tests/DependencyInjection/ConfigurationTest.php
index f0242942..29d476f5 100644
--- a/tests/DependencyInjection/ConfigurationTest.php
+++ b/tests/DependencyInjection/ConfigurationTest.php
@@ -244,6 +244,8 @@ class ConfigurationTest extends TestCase
],
'invoice' => [
'documents' => [
+ ],
+ 'defaults' => [
0 => 'var/invoices/',
1 => 'templates/invoice/renderer/',
],
diff --git a/tests/Event/InvoicePostRenderEventTest.php b/tests/Event/InvoicePostRenderEventTest.php
new file mode 100644
index 00000000..7f911e47
--- /dev/null
+++ b/tests/Event/InvoicePostRenderEventTest.php
@@ -0,0 +1,39 @@
+getModel());
+ self::assertSame($document, $sut->getDocument());
+ self::assertSame($renderer, $sut->getRenderer());
+ self::assertSame($response, $sut->getResponse());
+ }
+}
diff --git a/tests/Event/InvoicePreRenderEventTest.php b/tests/Event/InvoicePreRenderEventTest.php
new file mode 100644
index 00000000..6fe95965
--- /dev/null
+++ b/tests/Event/InvoicePreRenderEventTest.php
@@ -0,0 +1,36 @@
+getModel());
+ self::assertSame($document, $sut->getDocument());
+ self::assertSame($renderer, $sut->getRenderer());
+ }
+}
diff --git a/tests/EventSubscriber/DashboardSubscriberTest.php b/tests/EventSubscriber/DashboardSubscriberTest.php
index 42dc8060..bc8067fb 100644
--- a/tests/EventSubscriber/DashboardSubscriberTest.php
+++ b/tests/EventSubscriber/DashboardSubscriberTest.php
@@ -75,16 +75,16 @@ class DashboardSubscriberTest extends TestCase
$authMock->method('isGranted')->willReturn($isAdmin);
$userMock = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock();
- $userMock->method('countUser')->willReturn($userCount);
+ $userMock->method('countUsersForQuery')->willReturn($userCount);
$projectMock = $this->getMockBuilder(ProjectRepository::class)->disableOriginalConstructor()->getMock();
- $projectMock->method('countProject')->willReturn($projectCount);
+ $projectMock->method('countProjectsForQuery')->willReturn($projectCount);
$activityMock = $this->getMockBuilder(ActivityRepository::class)->disableOriginalConstructor()->getMock();
- $activityMock->method('countActivity')->willReturn($activityCount);
+ $activityMock->method('countActivitiesForQuery')->willReturn($activityCount);
$customerMock = $this->getMockBuilder(CustomerRepository::class)->disableOriginalConstructor()->getMock();
- $customerMock->method('countCustomer')->willReturn($customerCount);
+ $customerMock->method('countCustomersForQuery')->willReturn($customerCount);
return new DashboardSubscriber($authMock, $userMock, $activityMock, $projectMock, $customerMock);
}
diff --git a/tests/Invoice/Calculator/AbstractCalculatorTest.php b/tests/Invoice/Calculator/AbstractCalculatorTest.php
index 4af297db..4043f69a 100644
--- a/tests/Invoice/Calculator/AbstractCalculatorTest.php
+++ b/tests/Invoice/Calculator/AbstractCalculatorTest.php
@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
+use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase;
abstract class AbstractCalculatorTest extends TestCase
@@ -28,7 +29,7 @@ abstract class AbstractCalculatorTest extends TestCase
$template = new InvoiceTemplate();
$query = new InvoiceQuery();
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setQuery($query);
@@ -80,7 +81,7 @@ abstract class AbstractCalculatorTest extends TestCase
->setActivity($activity)
->setProject($project);
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries([$timesheet]);
diff --git a/tests/Invoice/Calculator/ActivityInvoiceCalculatorTest.php b/tests/Invoice/Calculator/ActivityInvoiceCalculatorTest.php
index dc7f7629..036eca0a 100644
--- a/tests/Invoice/Calculator/ActivityInvoiceCalculatorTest.php
+++ b/tests/Invoice/Calculator/ActivityInvoiceCalculatorTest.php
@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\Calculator\ActivityInvoiceCalculator;
use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
+use App\Tests\Invoice\DebugFormatter;
/**
* @covers \App\Invoice\Calculator\ActivityInvoiceCalculator
@@ -105,7 +106,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery();
$query->setActivity($activity1);
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
diff --git a/tests/Invoice/Calculator/DateInvoiceCalculatorTest.php b/tests/Invoice/Calculator/DateInvoiceCalculatorTest.php
index e92f96fc..1e8a95d2 100644
--- a/tests/Invoice/Calculator/DateInvoiceCalculatorTest.php
+++ b/tests/Invoice/Calculator/DateInvoiceCalculatorTest.php
@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\Calculator\DateInvoiceCalculator;
use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
+use App\Tests\Invoice\DebugFormatter;
use DateTime;
/**
@@ -106,7 +107,7 @@ class DateInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery();
$query->setProject($project1);
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
diff --git a/tests/Invoice/Calculator/DefaultCalculatorTest.php b/tests/Invoice/Calculator/DefaultCalculatorTest.php
index fda1ffe3..c003e2e2 100644
--- a/tests/Invoice/Calculator/DefaultCalculatorTest.php
+++ b/tests/Invoice/Calculator/DefaultCalculatorTest.php
@@ -16,6 +16,7 @@ use App\Entity\Timesheet;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
+use App\Tests\Invoice\DebugFormatter;
/**
* @covers \App\Invoice\Calculator\DefaultCalculator
@@ -57,7 +58,7 @@ class DefaultCalculatorTest extends AbstractCalculatorTest
$entries = [$timesheet, $timesheet2, $timesheet3];
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
diff --git a/tests/Invoice/Calculator/ProjectInvoiceCalculatorTest.php b/tests/Invoice/Calculator/ProjectInvoiceCalculatorTest.php
index a5889491..0117668d 100644
--- a/tests/Invoice/Calculator/ProjectInvoiceCalculatorTest.php
+++ b/tests/Invoice/Calculator/ProjectInvoiceCalculatorTest.php
@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\Calculator\ProjectInvoiceCalculator;
use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
+use App\Tests\Invoice\DebugFormatter;
use DateTime;
/**
@@ -106,7 +107,7 @@ class ProjectInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery();
$query->setProject($project1);
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
diff --git a/tests/Invoice/Calculator/ShortInvoiceCalculatorTest.php b/tests/Invoice/Calculator/ShortInvoiceCalculatorTest.php
index 4c124065..c571c7de 100644
--- a/tests/Invoice/Calculator/ShortInvoiceCalculatorTest.php
+++ b/tests/Invoice/Calculator/ShortInvoiceCalculatorTest.php
@@ -19,6 +19,7 @@ use App\Invoice\Calculator\ShortInvoiceCalculator;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
+use App\Tests\Invoice\DebugFormatter;
/**
* @covers \App\Invoice\Calculator\ShortInvoiceCalculator
@@ -86,7 +87,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery();
$query->setActivity($activity);
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
@@ -167,7 +168,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery();
$query->setActivity($activity);
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
@@ -246,7 +247,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery();
$query->setActivity($activity);
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
diff --git a/tests/Invoice/Calculator/UserInvoiceCalculatorTest.php b/tests/Invoice/Calculator/UserInvoiceCalculatorTest.php
index 42bfbb42..e9e9e546 100644
--- a/tests/Invoice/Calculator/UserInvoiceCalculatorTest.php
+++ b/tests/Invoice/Calculator/UserInvoiceCalculatorTest.php
@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\Calculator\UserInvoiceCalculator;
use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
+use App\Tests\Invoice\DebugFormatter;
/**
* @covers \App\Invoice\Calculator\UserInvoiceCalculator
@@ -104,7 +105,7 @@ class UserInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery();
$query->setActivity($activity);
- $model = new InvoiceModel();
+ $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
diff --git a/tests/Invoice/DebugFormatter.php b/tests/Invoice/DebugFormatter.php
new file mode 100644
index 00000000..8ae04258
--- /dev/null
+++ b/tests/Invoice/DebugFormatter.php
@@ -0,0 +1,79 @@
+format('d.m.Y');
+ }
+
+ /**
+ * @param \DateTime $date
+ * @return mixed
+ */
+ public function getFormattedTime(\DateTime $date)
+ {
+ return $date->format('H:i');
+ }
+
+ /**
+ * @param mixed $amount
+ * @param string|null $currency
+ * @return mixed
+ */
+ public function getFormattedMoney($amount, $currency)
+ {
+ if (null !== $currency) {
+ return $amount . ' ' . $currency;
+ }
+
+ return $amount;
+ }
+
+ /**
+ * @param \DateTime $date
+ * @return mixed
+ */
+ public function getFormattedMonthName(\DateTime $date)
+ {
+ return $date->format('m');
+ }
+
+ /**
+ * @param mixed $seconds
+ * @return mixed
+ */
+ public function getFormattedDuration($seconds)
+ {
+ return $seconds;
+ }
+
+ /**
+ * @param mixed $seconds
+ * @return mixed
+ */
+ public function getFormattedDecimalDuration($seconds)
+ {
+ return $seconds;
+ }
+
+ public function getCurrencySymbol(string $currency): string
+ {
+ return $currency;
+ }
+}
diff --git a/tests/Invoice/InvoiceItemTest.php b/tests/Invoice/InvoiceItemTest.php
index 7378b594..a556d00d 100644
--- a/tests/Invoice/InvoiceItemTest.php
+++ b/tests/Invoice/InvoiceItemTest.php
@@ -35,5 +35,7 @@ class InvoiceItemTest extends TestCase
self::assertNull($sut->getUser());
self::assertNull($sut->getDescription());
self::assertEquals(0, $sut->getDuration());
+ self::assertNull($sut->getCategory());
+ self::assertNull($sut->getType());
}
}
diff --git a/tests/Invoice/InvoiceModelTest.php b/tests/Invoice/InvoiceModelTest.php
index 04975e45..a58d9deb 100644
--- a/tests/Invoice/InvoiceModelTest.php
+++ b/tests/Invoice/InvoiceModelTest.php
@@ -25,7 +25,8 @@ class InvoiceModelTest extends TestCase
{
public function testEmptyObject()
{
- $sut = new InvoiceModel();
+ $formatter = new DebugFormatter();
+ $sut = new InvoiceModel($formatter);
self::assertNull($sut->getQuery());
self::assertNull($sut->getCustomer());
@@ -38,11 +39,13 @@ class InvoiceModelTest extends TestCase
self::assertNull($sut->getTemplate());
self::assertInstanceOf(\DateTime::class, $sut->getInvoiceDate());
+
+ self::assertSame($formatter, $sut->getFormatter());
}
public function testSetter()
{
- $sut = new InvoiceModel();
+ $sut = new InvoiceModel(new DebugFormatter());
$query = new InvoiceQuery();
self::assertInstanceOf(InvoiceModel::class, $sut->setQuery($query));
diff --git a/tests/Invoice/NumberGenerator/DateNumberGeneratorTest.php b/tests/Invoice/NumberGenerator/DateNumberGeneratorTest.php
index c5dade09..99cc3811 100644
--- a/tests/Invoice/NumberGenerator/DateNumberGeneratorTest.php
+++ b/tests/Invoice/NumberGenerator/DateNumberGeneratorTest.php
@@ -11,6 +11,7 @@ namespace App\Tests\Invoice\NumberGenerator;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator;
+use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase;
/**
@@ -21,7 +22,7 @@ class DateNumberGeneratorTest extends TestCase
public function testGetInvoiceNumber()
{
$sut = new DateNumberGenerator();
- $sut->setModel(new InvoiceModel());
+ $sut->setModel(new InvoiceModel(new DebugFormatter()));
$this->assertEquals(date('ymd'), $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId());
diff --git a/tests/Invoice/Renderer/DebugRenderer.php b/tests/Invoice/Renderer/DebugRenderer.php
index 9c61dd81..d8b06e53 100644
--- a/tests/Invoice/Renderer/DebugRenderer.php
+++ b/tests/Invoice/Renderer/DebugRenderer.php
@@ -11,14 +11,12 @@ namespace App\Tests\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceModel;
-use App\Invoice\Renderer\RendererTrait;
+use App\Invoice\Renderer\AbstractRenderer;
use App\Invoice\RendererInterface;
use Symfony\Component\HttpFoundation\Response;
-class DebugRenderer implements RendererInterface
+class DebugRenderer extends AbstractRenderer implements RendererInterface
{
- use RendererTrait;
-
/**
* @return string[]
*/
@@ -35,60 +33,6 @@ class DebugRenderer implements RendererInterface
return 'array';
}
- /**
- * @param \DateTime $date
- * @return mixed
- */
- protected function getFormattedDateTime(\DateTime $date)
- {
- return $date->format('d.m.Y');
- }
-
- /**
- * @param \DateTime $date
- * @return mixed
- */
- protected function getFormattedTime(\DateTime $date)
- {
- return $date->format('H:i');
- }
-
- /**
- * @param mixed $amount
- * @return mixed
- */
- protected function getFormattedMoney($amount)
- {
- return $amount;
- }
-
- /**
- * @param \DateTime $date
- * @return mixed
- */
- protected function getFormattedMonthName(\DateTime $date)
- {
- return $date->format('m');
- }
-
- /**
- * @param mixed $seconds
- * @return mixed
- */
- protected function getFormattedDuration($seconds)
- {
- return $seconds;
- }
-
- /**
- * @param mixed $seconds
- * @return mixed
- */
- protected function getFormattedDecimalDuration($seconds)
- {
- return $seconds;
- }
-
/**
* Render the given InvoiceDocument with the data from the InvoiceModel into a stupid array for testing only.
*
@@ -99,12 +43,12 @@ class DebugRenderer implements RendererInterface
public function render(InvoiceDocument $document, InvoiceModel $model): Response
{
$result = [
- 'model' => $this->modelToReplacer($model),
+ 'model' => $model->toArray(),
'entries' => [],
];
foreach ($model->getCalculator()->getEntries() as $entry) {
- $result['entries'][] = $this->timesheetToArray($entry);
+ $result['entries'][] = $model->itemToArray($entry);
}
return new Response(json_encode($result));
diff --git a/tests/Invoice/Renderer/DebugRendererTest.php b/tests/Invoice/Renderer/DebugRendererTest.php
index 72e5e5bc..9208d64b 100644
--- a/tests/Invoice/Renderer/DebugRendererTest.php
+++ b/tests/Invoice/Renderer/DebugRendererTest.php
@@ -58,15 +58,19 @@ class DebugRendererTest extends TestCase
'invoice.date',
'invoice.number',
'invoice.currency',
+ 'invoice.currency_symbol',
'invoice.vat',
'invoice.tax',
'invoice.tax_nc',
+ 'invoice.tax_plain',
'invoice.total_time',
'invoice.duration_decimal',
'invoice.total',
'invoice.total_nc',
+ 'invoice.total_plain',
'invoice.subtotal',
'invoice.subtotal_nc',
+ 'invoice.subtotal_plain',
'template.name',
'template.company',
'template.address',
@@ -93,14 +97,28 @@ class DebugRendererTest extends TestCase
'customer.homepage',
'customer.comment',
'customer.fixed_rate',
+ 'customer.fixed_rate_nc',
+ 'customer.fixed_rate_plain',
'customer.hourly_rate',
+ 'customer.hourly_rate_nc',
+ 'customer.hourly_rate_plain',
'customer.meta.foo-customer',
'activity.id',
'activity.name',
'activity.comment',
'activity.fixed_rate',
+ 'activity.fixed_rate_nc',
+ 'activity.fixed_rate_plain',
'activity.hourly_rate',
+ 'activity.hourly_rate_nc',
+ 'activity.hourly_rate_plain',
'activity.meta.foo-activity',
+ 'user.alias',
+ 'user.email',
+ 'user.name',
+ 'user.title',
+ 'user.meta.hello',
+ 'user.meta.kitty',
];
if ($hasProject) {
@@ -111,7 +129,11 @@ class DebugRendererTest extends TestCase
'project.order_date',
'project.order_number',
'project.fixed_rate',
+ 'project.fixed_rate_nc',
+ 'project.fixed_rate_plain',
'project.hourly_rate',
+ 'project.hourly_rate_nc',
+ 'project.hourly_rate_plain',
'project.meta.foo-project',
]);
}
@@ -131,8 +153,10 @@ class DebugRendererTest extends TestCase
'entry.amount',
'entry.rate',
'entry.rate_nc',
+ 'entry.rate_plain',
'entry.total',
'entry.total_nc',
+ 'entry.total_plain',
'entry.currency',
'entry.duration',
'entry.duration_decimal',
@@ -154,6 +178,8 @@ class DebugRendererTest extends TestCase
'entry.customer',
'entry.project_id',
'entry.customer_id',
+ 'entry.category',
+ 'entry.type',
];
$keys = array_merge($keys, $metaFields);
diff --git a/tests/Invoice/Renderer/RendererTestTrait.php b/tests/Invoice/Renderer/RendererTestTrait.php
index e3dc4d27..f759ce0f 100644
--- a/tests/Invoice/Renderer/RendererTestTrait.php
+++ b/tests/Invoice/Renderer/RendererTestTrait.php
@@ -21,7 +21,10 @@ use App\Entity\ProjectMeta;
use App\Entity\Timesheet;
use App\Entity\TimesheetMeta;
use App\Entity\User;
+use App\Entity\UserPreference;
use App\Invoice\Calculator\DefaultCalculator;
+use App\Invoice\DefaultInvoiceFormatter;
+use App\Invoice\InvoiceFormatter;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\AbstractRenderer;
@@ -65,6 +68,11 @@ trait RendererTestTrait
* @return AbstractRenderer
*/
protected function getAbstractRenderer(string $classname)
+ {
+ return new $classname();
+ }
+
+ protected function getFormatter(): InvoiceFormatter
{
$requestStack = new RequestStack();
$languages = [
@@ -85,11 +93,19 @@ trait RendererTestTrait
$dateExtension = new DateExtensions($localeSettings);
$extensions = new Extensions($localeSettings);
- return new $classname($translator, $dateExtension, $extensions);
+ return new DefaultInvoiceFormatter($translator, $dateExtension, $extensions);
}
protected function getInvoiceModel(): InvoiceModel
{
+ $user = new User();
+ $user->setUsername('one-user');
+ $user->setTitle('user title');
+ $user->setAlias('genious alias');
+ $user->setEmail('fantastic@four');
+ $user->addPreference((new UserPreference())->setName('kitty')->setValue('kat'));
+ $user->addPreference((new UserPreference())->setName('hello')->setValue('world'));
+
$customer = new Customer();
$customer->setCurrency('EUR');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
@@ -185,11 +201,12 @@ trait RendererTestTrait
$query->setEnd(new \DateTime());
$query->setProject($project);
- $model = new InvoiceModel();
+ $model = new InvoiceModel($this->getFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
$model->setQuery($query);
+ $model->setUser($user);
$calculator = new DefaultCalculator();
$calculator->setModel($model);
@@ -206,6 +223,14 @@ trait RendererTestTrait
protected function getInvoiceModelOneEntry(): InvoiceModel
{
+ $user = new User();
+ $user->setUsername('one-user');
+ $user->setTitle('user title');
+ $user->setAlias('genious alias');
+ $user->setEmail('fantastic@four');
+ $user->addPreference((new UserPreference())->setName('kitty')->setValue('kat'));
+ $user->addPreference((new UserPreference())->setName('hello')->setValue('world'));
+
$customer = new Customer();
$customer->setCurrency('USD');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
@@ -248,11 +273,12 @@ trait RendererTestTrait
$query->setBegin(new \DateTime());
$query->setEnd(new \DateTime());
- $model = new InvoiceModel();
+ $model = new InvoiceModel($this->getFormatter());
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
$model->setQuery($query);
+ $model->setUser($user);
$calculator = new DefaultCalculator();
$calculator->setModel($model);
diff --git a/tests/Model/InvoiceModelTest.php b/tests/Model/InvoiceModelTest.php
deleted file mode 100644
index 0d8260f8..00000000
--- a/tests/Model/InvoiceModelTest.php
+++ /dev/null
@@ -1,65 +0,0 @@
-assertNull($sut->getNumberGenerator());
- $this->assertNull($sut->getCalculator());
- $this->assertNull($sut->getTemplate());
- $this->assertNull($sut->getCustomer());
- $this->assertNull($sut->getQuery());
- $this->assertNull($sut->getDueDate());
- $this->assertEmpty($sut->getEntries());
- $this->assertIsArray($sut->getEntries());
- $this->assertInstanceOf(\DateTime::class, $sut->getInvoiceDate());
- }
-
- public function testSetter()
- {
- $sut = new InvoiceModel();
-
- $sut->setTemplate((new InvoiceTemplate())->setDueDays(10));
- $sut->setCustomer(new Customer());
- $sut->setQuery(new InvoiceQuery());
- $sut->setEntries([new Timesheet()]);
- $sut->setNumberGenerator(new DateNumberGenerator());
- $sut->setCalculator(new DefaultCalculator());
-
- $this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGenerator());
- $this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculator());
- $this->assertInstanceOf(InvoiceTemplate::class, $sut->getTemplate());
- $this->assertInstanceOf(Customer::class, $sut->getCustomer());
- $this->assertInstanceOf(InvoiceQuery::class, $sut->getQuery());
- $this->assertInstanceOf(\DateTime::class, $sut->getDueDate());
- $this->assertInstanceOf(Timesheet::class, $sut->getEntries()[0]);
- $this->assertInstanceOf(\DateTime::class, $sut->getInvoiceDate());
-
- $this->assertEquals(
- (new \DateTime('+10 days'))->format('Y-m-d'),
- $sut->getDueDate()->format('Y-m-d')
- );
- }
-}
diff --git a/tests/Validator/Constraints/TimesheetValidatorTest.php b/tests/Validator/Constraints/TimesheetValidatorTest.php
index 36e9d9b2..13014b2b 100644
--- a/tests/Validator/Constraints/TimesheetValidatorTest.php
+++ b/tests/Validator/Constraints/TimesheetValidatorTest.php
@@ -38,6 +38,11 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
'rules' => [
'allow_future_times' => false,
],
+ 'rounding' => [
+ 'default' => [
+ 'begin' => 1
+ ]
+ ]
]);
$service = (new TrackingModeServiceFactory($this))->create('default');