Release 1.6.2 (#1289)

* include user teams in user entity
* prevent unauthorized access via API
* improve teamlead permission handling in team timesheets
* add team data to user entity
* add security tests
* highlight menu for invoice template copy
* unified handling of invoice data across all templates
* access to the current users data in invoice templates
* permission improvement in invoice form
* allow to skip record rows
* allow to add new invoice locations without overwriting the global ones
* allow to order user preferences
* change permission for normal users with access to view_other_timesheets
* properly validate invoice template field length
* allow to replace multiple variables in cell values text
* upgraded office invoice template
* doctrine deprecation fix
* upgrade phpoffice/phpword
* fix future begin check for default rounding rules
* dashboard widget counter: respect visibility and teams - fixes #1161
* fix future begin check for default rounding rules
* added new events for pre and post invoice rendering
* fix permission issue for users without team seeing all records
* prevent error in spreadsheet renderer for empty invoices
This commit is contained in:
Kevin Papst
2019-12-02 10:57:03 +01:00
committed by GitHub
parent 47414cfd0e
commit 984c852ab6
78 changed files with 1279 additions and 625 deletions

View File

@@ -28,7 +28,7 @@ It is built with modern technologies such as Symfony, Bootstrap, RESTful API, Do
### About ### 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: 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, 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. 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. > 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. 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. 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.

View File

@@ -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. 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. 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).** **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)). - 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. - 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). - 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 ### Developer

View File

@@ -32,8 +32,8 @@
"nelmio/api-doc-bundle": "^3.2", "nelmio/api-doc-bundle": "^3.2",
"nelmio/cors-bundle": "^1.5", "nelmio/cors-bundle": "^1.5",
"ocramius/proxy-manager": "^2.1.1", "ocramius/proxy-manager": "^2.1.1",
"phpoffice/phpspreadsheet": "^1.4", "phpoffice/phpspreadsheet": "^1.10",
"phpoffice/phpword": "^0.15.0", "phpoffice/phpword": "^0.17",
"psr/log": "^1.1", "psr/log": "^1.1",
"sensio/framework-extra-bundle": "^5.2", "sensio/framework-extra-bundle": "^5.2",
"symfony/asset": "^4.0", "symfony/asset": "^4.0",

22
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "4c71764ce6f4c135070a2f7e7a0c62b6", "content-hash": "c08938efb681d04751323b133c8d8199",
"packages": [ "packages": [
{ {
"name": "beberlei/doctrineextensions", "name": "beberlei/doctrineextensions",
@@ -4525,16 +4525,16 @@
}, },
{ {
"name": "phpoffice/phpword", "name": "phpoffice/phpword",
"version": "0.15.0", "version": "0.17.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/PHPOffice/PHPWord.git", "url": "https://github.com/PHPOffice/PHPWord.git",
"reference": "dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f" "reference": "b8346af548d399acd9e30fc76ab0c55c2fec03a5"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f", "url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/b8346af548d399acd9e30fc76ab0c55c2fec03a5",
"reference": "dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f", "reference": "b8346af548d399acd9e30fc76ab0c55c2fec03a5",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4566,7 +4566,7 @@
"type": "library", "type": "library",
"extra": { "extra": {
"branch-alias": { "branch-alias": {
"dev-develop": "0.16-dev" "dev-develop": "0.18-dev"
} }
}, },
"autoload": { "autoload": {
@@ -4587,6 +4587,10 @@
"email": "me@gabrielbull.com", "email": "me@gabrielbull.com",
"homepage": "http://gabrielbull.com/" "homepage": "http://gabrielbull.com/"
}, },
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net/blog/"
},
{ {
"name": "Ivan Lanin", "name": "Ivan Lanin",
"homepage": "http://ivan.lanin.org" "homepage": "http://ivan.lanin.org"
@@ -4595,10 +4599,6 @@
"name": "Roman Syroeshko", "name": "Roman Syroeshko",
"homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/" "homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/"
}, },
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net/blog/"
},
{ {
"name": "Antoine de Troostembergh" "name": "Antoine de Troostembergh"
} }
@@ -4630,7 +4630,7 @@
"word", "word",
"writer" "writer"
], ],
"time": "2018-07-14T16:59:43+00:00" "time": "2019-10-01T20:43:33+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",

View File

@@ -28,7 +28,7 @@ doctrine:
entity_managers: entity_managers:
default: default:
connection: default connection: default
naming_strategy: doctrine.orm.naming_strategy.underscore naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
auto_mapping: true auto_mapping: true
mappings: mappings:
App: App:

View File

@@ -21,9 +21,9 @@ nelmio_api_doc:
- { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] } - { alias: TimesheetMeta, type: App\Entity\TimesheetMeta, groups: [Default, Timesheet] }
- { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] } - { alias: TimesheetCollection, type: App\Entity\Timesheet, groups: [Default, Collection, Timesheet] }
- { alias: TimesheetSubCollection, type: App\Entity\Timesheet, groups: [Default, Subresource, 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: 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: TeamCollection, type: App\Entity\Team, groups: [Default, Collection, Team] }
- { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] } - { alias: I18nConfig, type: App\API\Model\I18n, groups: [Default] }
areas: areas:

View File

@@ -1,13 +1,17 @@
App\Entity\Team: App\Entity\Team:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, name, users] custom_accessor_order: [id, name, teamlead, users]
properties: properties:
id: id:
include: true include: true
name: name:
include: true include: true
teamlead:
include: true
groups: [Team_Entity]
users: users:
exclude: true include: true
groups: [Team_Entity]
customers: customers:
exclude: true exclude: true
projects: projects:

View File

@@ -1,6 +1,6 @@
App\Entity\User: App\Entity\User:
exclusion_policy: All exclusion_policy: All
custom_accessor_order: [id, alias, title, avatar, language, timezone] custom_accessor_order: [id, alias, title, avatar, language, timezone, teams]
properties: properties:
id: id:
include: true include: true
@@ -10,18 +10,21 @@ App\Entity\User:
groups: [Default] groups: [Default]
title: title:
include: true include: true
groups: [Entity] groups: [User_Entity]
avatar: avatar:
include: true include: true
groups: [Entity] groups: [User_Entity]
teams:
include: true
groups: [User_Entity]
virtual_properties: virtual_properties:
getLanguage: getLanguage:
serialized_name: language serialized_name: language
exp: "object.getPreferenceValue('language') === null ? null : object.getPreferenceValue('language')" exp: "object.getPreferenceValue('language') === null ? null : object.getPreferenceValue('language')"
type: string type: string
groups: [Entity] groups: [User_Entity]
getTimezone: getTimezone:
serialized_name: timezone serialized_name: timezone
exp: "object.getPreferenceValue('timezone') === null ? null : object.getPreferenceValue('timezone')" exp: "object.getPreferenceValue('timezone') === null ? null : object.getPreferenceValue('timezone')"
type: string type: string
groups: [Entity] groups: [User_Entity]

View File

@@ -10,7 +10,7 @@ FOS\UserBundle\Model\User:
roles: roles:
type: array<string> type: array<string>
include: true include: true
groups: [Entity] groups: [User_Entity]
groups: groups:
exclude: true exclude: true
email: email:

View File

@@ -28,6 +28,8 @@ use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
/** /**
* @RouteResource("Tag") * @RouteResource("Tag")
*
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/ */
class TagController extends BaseApiController class TagController extends BaseApiController
{ {

View File

@@ -24,6 +24,8 @@ use Symfony\Component\HttpFoundation\Response;
/** /**
* @RouteResource("Team") * @RouteResource("Team")
*
* @Security("is_granted('IS_AUTHENTICATED_REMEMBERED')")
*/ */
class TeamController extends BaseApiController class TeamController extends BaseApiController
{ {
@@ -78,6 +80,8 @@ class TeamController extends BaseApiController
* @SWG\Schema(ref="#/definitions/TeamEntity"), * @SWG\Schema(ref="#/definitions/TeamEntity"),
* ) * )
* *
* @Security("is_granted('view_team')")
*
* @ApiSecurity(name="apiUser") * @ApiSecurity(name="apiUser")
* @ApiSecurity(name="apiToken") * @ApiSecurity(name="apiToken")
*/ */
@@ -90,7 +94,7 @@ class TeamController extends BaseApiController
} }
$view = new View($data, 200); $view = new View($data, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'Team']); $view->getContext()->setGroups(['Default', 'Entity', 'Team', 'Team_Entity']);
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }

View File

@@ -133,7 +133,7 @@ class UserController extends BaseApiController
} }
$view = new View($user, 200); $view = new View($user, 200);
$view->getContext()->setGroups(['Default', 'Entity', 'User']); $view->getContext()->setGroups(['Default', 'Entity', 'User', 'User_Entity']);
return $this->viewHandler->handle($view); return $this->viewHandler->handle($view);
} }
@@ -155,7 +155,7 @@ class UserController extends BaseApiController
public function meAction(): Response public function meAction(): Response
{ {
$view = new View($this->getUser(), 200); $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); return $this->viewHandler->handle($view);
} }

View File

@@ -17,7 +17,7 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '1.6.1'; public const VERSION = '1.6.2';
/** /**
* The current release status, either "stable" or "dev" * The current release status, either "stable" or "dev"
*/ */

View File

@@ -10,8 +10,11 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Event\InvoicePostRenderEvent;
use App\Event\InvoicePreRenderEvent;
use App\Form\InvoiceTemplateForm; use App\Form\InvoiceTemplateForm;
use App\Form\Toolbar\InvoiceToolbarForm; use App\Form\Toolbar\InvoiceToolbarForm;
use App\Invoice\InvoiceFormatter;
use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Invoice\ServiceInvoice; use App\Invoice\ServiceInvoice;
@@ -25,6 +28,7 @@ use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route; use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/** /**
* Controller used to create invoices and manage invoice templates. * Controller used to create invoices and manage invoice templates.
@@ -32,26 +36,36 @@ use Symfony\Component\Routing\Annotation\Route;
* @Route(path="/invoice") * @Route(path="/invoice")
* @Security("is_granted('view_invoice')") * @Security("is_granted('view_invoice')")
*/ */
class InvoiceController extends AbstractController final class InvoiceController extends AbstractController
{ {
/** /**
* @var ServiceInvoice * @var ServiceInvoice
*/ */
protected $service; private $service;
/** /**
* @var InvoiceTemplateRepository * @var InvoiceTemplateRepository
*/ */
protected $invoiceRepository; private $invoiceRepository;
/** /**
* @var UserDateTimeFactory * @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->service = $service;
$this->invoiceRepository = $invoice; $this->invoiceRepository = $invoice;
$this->dateTimeFactory = $dateTimeFactory; $this->dateTimeFactory = $dateTimeFactory;
$this->formatter = $formatter;
$this->dispatcher = $dispatcher;
} }
/** /**
@@ -115,8 +129,14 @@ class InvoiceController extends AbstractController
$query->setEnd($end); $query->setEnd($end);
$query->setExported(InvoiceQuery::STATE_NOT_EXPORTED); $query->setExported(InvoiceQuery::STATE_NOT_EXPORTED);
$query->setState(InvoiceQuery::STATE_STOPPED); $query->setState(InvoiceQuery::STATE_STOPPED);
// limit access to data from teams
$query->setCurrentUser($this->getUser()); $query->setCurrentUser($this->getUser());
if (!$this->isGranted('view_other_timesheet')) {
// limit access to own data
$query->setUser($this->getUser());
}
return $query; return $query;
} }
@@ -135,11 +155,15 @@ class InvoiceController extends AbstractController
foreach ($this->service->getRenderer() as $renderer) { foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) { if ($renderer->supports($document)) {
$this->dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
$response = $renderer->render($document, $model); $response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) { if ($query->isMarkAsExported()) {
$this->markEntriesAsExported($entries); $this->markEntriesAsExported($entries);
} }
$this->dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response));
return $response; return $response;
} }
} }
@@ -216,9 +240,10 @@ class InvoiceController extends AbstractController
*/ */
protected function prepareModel(InvoiceQuery $query): InvoiceModel protected function prepareModel(InvoiceQuery $query): InvoiceModel
{ {
$model = new InvoiceModel(); $model = new InvoiceModel($this->formatter);
$model $model
->setQuery($query) ->setQuery($query)
->setUser($this->getUser())
->setCustomer($query->getCustomer()) ->setCustomer($query->getCustomer())
; ;
@@ -340,6 +365,7 @@ class InvoiceController extends AbstractController
return $this->createForm(InvoiceToolbarForm::class, $query, [ return $this->createForm(InvoiceToolbarForm::class, $query, [
'action' => $this->generateUrl('invoice', []), 'action' => $this->generateUrl('invoice', []),
'method' => $method, 'method' => $method,
'include_user' => $this->isGranted('view_other_timesheet'),
'attr' => [ 'attr' => [
'id' => 'invoice-print-form' 'id' => 'invoice-print-form'
], ],

View File

@@ -10,6 +10,7 @@
namespace App\Controller; namespace App\Controller;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference;
use App\Event\PrepareUserEvent; use App\Event\PrepareUserEvent;
use App\Form\UserApiTokenType; use App\Form\UserApiTokenType;
use App\Form\UserEditType; use App\Form\UserEditType;
@@ -20,6 +21,7 @@ use App\Form\UserTeamsType;
use App\Repository\TeamRepository; use App\Repository\TeamRepository;
use App\Repository\TimesheetRepository; use App\Repository\TimesheetRepository;
use App\Voter\UserVoter; use App\Voter\UserVoter;
use Doctrine\Common\Collections\ArrayCollection;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security; use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\EventDispatcher\EventDispatcherInterface; use Symfony\Component\EventDispatcher\EventDispatcherInterface;
use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormInterface;
@@ -210,6 +212,13 @@ class ProfileController extends AbstractController
$event = new PrepareUserEvent($profile); $event = new PrepareUserEvent($profile);
$this->dispatcher->dispatch($event); $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 = []; $original = [];
foreach ($profile->getPreferences() as $preference) { foreach ($profile->getPreferences() as $preference) {
$original[$preference->getName()] = $preference; $original[$preference->getName()] = $preference;

View File

@@ -402,7 +402,7 @@ abstract class TimesheetAbstractController extends AbstractController
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []), 'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
'method' => 'POST', 'method' => 'POST',
'include_exported' => $this->isGranted($this->getPermissionEditExport()), '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()), 'action' => $this->generateUrl($this->getCreateRoute()),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry), 'include_exported' => $this->isGranted('edit_export', $entry),
'include_user' => $this->includeUserInForms(), 'include_user' => $this->includeUserInForms('create'),
'allow_begin_datetime' => $mode->canEditBegin(), 'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(), 'allow_end_datetime' => $mode->canEditEnd(),
'allow_duration' => $mode->canEditDuration(), 'allow_duration' => $mode->canEditDuration(),
@@ -450,7 +450,7 @@ abstract class TimesheetAbstractController extends AbstractController
]), ]),
'include_rate' => $this->isGranted('edit_rate', $entry), 'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry), 'include_exported' => $this->isGranted('edit_export', $entry),
'include_user' => $this->includeUserInForms(), 'include_user' => $this->includeUserInForms('edit'),
'allow_begin_datetime' => $mode->canEditBegin(), 'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(), 'allow_end_datetime' => $mode->canEditEnd(),
'allow_duration' => $mode->canEditDuration(), 'allow_duration' => $mode->canEditDuration(),
@@ -469,7 +469,7 @@ abstract class TimesheetAbstractController extends AbstractController
'page' => $query->getPage(), 'page' => $query->getPage(),
]), ]),
'method' => 'GET', '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); return (bool) $this->getUser()->getPreferenceValue('timesheet.daily_stats', false);
} }
protected function includeUserInForms(): bool protected function includeUserInForms(string $formName): bool
{ {
return false; return false;
} }

View File

@@ -118,9 +118,13 @@ class TimesheetTeamController extends TimesheetAbstractController
return TimesheetAdminEditForm::class; 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 protected function getTimesheetRoute(): string

View File

@@ -69,7 +69,7 @@ class InvoiceFixtures extends Fixture
$paymentTerms_alt = $paymentTerms_alt =
$faker->firstName . ', thank you very much. We really appreciate your business.' . PHP_EOL . $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 = $paymentTerms_de =
@@ -114,7 +114,6 @@ class InvoiceFixtures extends Fixture
protected function generateAddress(Generator $faker) protected function generateAddress(Generator $faker)
{ {
return return
'Kimai Inc.' . PHP_EOL .
$faker->streetAddress . PHP_EOL . $faker->streetAddress . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode $faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode
; ;

View File

@@ -45,6 +45,9 @@ class AppExtension extends Extension
$config['timesheet']['rounding'][$name]['days'] = implode(',', $settings['days']); $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% // safe alternatives to %kernel.project_dir%
$container->setParameter('kimai.data_dir', $config['data_dir']); $container->setParameter('kimai.data_dir', $config['data_dir']);
$container->setParameter('kimai.plugin_dir', $config['plugin_dir']); $container->setParameter('kimai.plugin_dir', $config['plugin_dir']);

View File

@@ -211,14 +211,18 @@ class Configuration implements ConfigurationInterface
$node $node
->addDefaultsIfNotSet() ->addDefaultsIfNotSet()
->children() ->children()
->arrayNode('documents') ->arrayNode('defaults')
->requiresAtLeastOneElement()
->scalarPrototype()->end() ->scalarPrototype()->end()
->defaultValue([ ->defaultValue([
'var/invoices/', 'var/invoices/',
'templates/invoice/renderer/' 'templates/invoice/renderer/'
]) ])
->end() ->end()
->arrayNode('documents')
->requiresAtLeastOneElement()
->scalarPrototype()->end()
->defaultValue([])
->end()
->end() ->end()
; ;

View File

@@ -99,6 +99,7 @@ class InvoiceTemplate
* *
* @ORM\Column(name="calculator", type="string", length=20, nullable=false) * @ORM\Column(name="calculator", type="string", length=20, nullable=false)
* @Assert\NotBlank() * @Assert\NotBlank()
* @Assert\Length(max=20)
*/ */
private $calculator = 'default'; private $calculator = 'default';
/** /**
@@ -106,6 +107,7 @@ class InvoiceTemplate
* *
* @ORM\Column(name="number_generator", type="string", length=20, nullable=false) * @ORM\Column(name="number_generator", type="string", length=20, nullable=false)
* @Assert\NotBlank() * @Assert\NotBlank()
* @Assert\Length(max=20)
*/ */
private $numberGenerator = 'default'; private $numberGenerator = 'default';
@@ -114,6 +116,7 @@ class InvoiceTemplate
* *
* @ORM\Column(name="renderer", type="string", length=20, nullable=false) * @ORM\Column(name="renderer", type="string", length=20, nullable=false)
* @Assert\NotBlank() * @Assert\NotBlank()
* @Assert\Length(max=20)
*/ */
private $renderer = 'default'; private $renderer = 'default';

View File

@@ -77,6 +77,10 @@ class UserPreference
* @var array * @var array
*/ */
private $options = []; private $options = [];
/**
* @var int
*/
private $order = 1000;
/** /**
* @return int * @return int
@@ -246,4 +250,16 @@ class UserPreference
return $this->name; return $this->name;
} }
public function getOrder(): int
{
return $this->order;
}
public function setOrder(int $order): UserPreference
{
$this->order = $order;
return $this;
}
} }

View File

@@ -0,0 +1,64 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceModel;
use App\Invoice\RendererInterface;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\Event;
final class InvoicePostRenderEvent extends Event
{
/**
* @var InvoiceModel
*/
private $model;
/**
* @var InvoiceDocument
*/
private $document;
/**
* @var RendererInterface
*/
private $renderer;
/**
* @var Response
*/
private $response;
public function __construct(InvoiceModel $model, InvoiceDocument $document, RendererInterface $renderer, Response $response)
{
$this->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;
}
}

View File

@@ -0,0 +1,53 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceModel;
use App\Invoice\RendererInterface;
use Symfony\Contracts\EventDispatcher\Event;
final class InvoicePreRenderEvent extends Event
{
/**
* @var InvoiceModel
*/
private $model;
/**
* @var InvoiceDocument
*/
private $document;
/**
* @var RendererInterface
*/
private $renderer;
public function __construct(InvoiceModel $model, InvoiceDocument $document, RendererInterface $renderer)
{
$this->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;
}
}

View File

@@ -14,6 +14,10 @@ use App\Event\DashboardEvent;
use App\Repository\ActivityRepository; use App\Repository\ActivityRepository;
use App\Repository\CustomerRepository; use App\Repository\CustomerRepository;
use App\Repository\ProjectRepository; 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\Repository\UserRepository;
use App\Widget\Type\CompoundRow; use App\Widget\Type\CompoundRow;
use App\Widget\Type\More; use App\Widget\Type\More;
@@ -82,8 +86,9 @@ class DashboardSubscriber implements EventSubscriberInterface
*/ */
public function onDashboardEvent(DashboardEvent $event) public function onDashboardEvent(DashboardEvent $event)
{ {
$user = $event->getUser();
$section = new CompoundRow(); $section = new CompoundRow();
$section->setTitle('ROLE_ADMIN'); $section->setTitle('');
$section->setOrder(100); $section->setOrder(100);
if ($this->security->isGranted('view_user')) { if ($this->security->isGranted('view_user')) {
@@ -91,7 +96,7 @@ class DashboardSubscriber implements EventSubscriberInterface
(new More()) (new More())
->setId('userTotal') ->setId('userTotal')
->setTitle('stats.userTotal') ->setTitle('stats.userTotal')
->setData($this->user->countUser()) ->setData($this->user->countUsersForQuery((new UserQuery())->setCurrentUser($user)))
->setOptions([ ->setOptions([
'route' => 'admin_user', 'route' => 'admin_user',
'icon' => 'user', 'icon' => 'user',
@@ -105,7 +110,7 @@ class DashboardSubscriber implements EventSubscriberInterface
(new More()) (new More())
->setId('customerTotal') ->setId('customerTotal')
->setTitle('stats.customerTotal') ->setTitle('stats.customerTotal')
->setData($this->customer->countCustomer()) ->setData($this->customer->countCustomersForQuery((new CustomerQuery())->setCurrentUser($user)))
->setOptions([ ->setOptions([
'route' => 'admin_customer', 'route' => 'admin_customer',
'icon' => 'customer', 'icon' => 'customer',
@@ -119,7 +124,7 @@ class DashboardSubscriber implements EventSubscriberInterface
(new More()) (new More())
->setId('projectTotal') ->setId('projectTotal')
->setTitle('stats.projectTotal') ->setTitle('stats.projectTotal')
->setData($this->project->countProject()) ->setData($this->project->countProjectsForQuery((new ProjectQuery())->setCurrentUser($user)))
->setOptions([ ->setOptions([
'route' => 'admin_project', 'route' => 'admin_project',
'icon' => 'project', 'icon' => 'project',
@@ -133,7 +138,7 @@ class DashboardSubscriber implements EventSubscriberInterface
(new More()) (new More())
->setId('activityTotal') ->setId('activityTotal')
->setTitle('stats.activityTotal') ->setTitle('stats.activityTotal')
->setData($this->activity->countActivity()) ->setData($this->activity->countActivitiesForQuery((new ActivityQuery())->setCurrentUser($user)))
->setOptions([ ->setOptions([
'route' => 'admin_activity', 'route' => 'admin_activity',
'icon' => 'activity', 'icon' => 'activity',

View File

@@ -72,7 +72,7 @@ final class MenuSubscriber implements EventSubscriberInterface
if ($auth->isGranted('view_invoice')) { if ($auth->isGranted('view_invoice')) {
$invoice = new MenuItemModel('invoice', 'menu.invoice', 'invoice', [], $this->getIcon('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); $menu->addItem($invoice);
} }

View File

@@ -104,6 +104,7 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
(new UserPreference()) (new UserPreference())
->setName(UserPreference::HOURLY_RATE) ->setName(UserPreference::HOURLY_RATE)
->setValue(0) ->setValue(0)
->setOrder(100)
->setType(MoneyType::class) ->setType(MoneyType::class)
->setEnabled($enableHourlyRate) ->setEnabled($enableHourlyRate)
->setOptions($hourlyRateOptions) ->setOptions($hourlyRateOptions)
@@ -112,36 +113,43 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
(new UserPreference()) (new UserPreference())
->setName(UserPreference::TIMEZONE) ->setName(UserPreference::TIMEZONE)
->setValue($this->getDefaultTimezone()) ->setValue($this->getDefaultTimezone())
->setOrder(200)
->setType(TimezoneType::class), ->setType(TimezoneType::class),
(new UserPreference()) (new UserPreference())
->setName(UserPreference::LOCALE) ->setName(UserPreference::LOCALE)
->setValue($this->getDefaultLanguage()) ->setValue($this->getDefaultLanguage())
->setOrder(300)
->setType(LanguageType::class), ->setType(LanguageType::class),
(new UserPreference()) (new UserPreference())
->setName(UserPreference::SKIN) ->setName(UserPreference::SKIN)
->setValue($this->getDefaultTheme()) ->setValue($this->getDefaultTheme())
->setOrder(400)
->setType(SkinType::class), ->setType(SkinType::class),
(new UserPreference()) (new UserPreference())
->setName('theme.collapsed_sidebar') ->setName('theme.collapsed_sidebar')
->setValue(false) ->setValue(false)
->setOrder(500)
->setType(CheckboxType::class), ->setType(CheckboxType::class),
(new UserPreference()) (new UserPreference())
->setName('calendar.initial_view') ->setName('calendar.initial_view')
->setValue(CalendarViewType::DEFAULT_VIEW) ->setValue(CalendarViewType::DEFAULT_VIEW)
->setOrder(600)
->setType(CalendarViewType::class), ->setType(CalendarViewType::class),
(new UserPreference()) (new UserPreference())
->setName('login.initial_view') ->setName('login.initial_view')
->setValue(InitialViewType::DEFAULT_VIEW) ->setValue(InitialViewType::DEFAULT_VIEW)
->setOrder(700)
->setType(InitialViewType::class), ->setType(InitialViewType::class),
(new UserPreference()) (new UserPreference())
->setName('timesheet.daily_stats') ->setName('timesheet.daily_stats')
->setValue(false) ->setValue(false)
->setOrder(800)
->setType(CheckboxType::class), ->setType(CheckboxType::class),
]; ];
} }
@@ -153,29 +161,22 @@ class UserPreferenceSubscriber implements EventSubscriberInterface
{ {
$user = $event->getUser(); $user = $event->getUser();
$prefs = [];
foreach ($user->getPreferences() as $preference) {
$prefs[$preference->getName()] = $preference;
}
$event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user)); $event = new UserPreferenceEvent($user, $this->getDefaultPreferences($user));
$this->eventDispatcher->dispatch($event); $this->eventDispatcher->dispatch($event);
foreach ($event->getPreferences() as $preference) { foreach ($event->getPreferences() as $preference) {
/* @var UserPreference[] $prefs */ $userPref = $user->getPreference($preference->getName());
if (isset($prefs[$preference->getName()])) { if (null !== $userPref) {
/* @var UserPreference $pref */ $userPref
$prefs[$preference->getName()]
->setType($preference->getType()) ->setType($preference->getType())
->setConstraints($preference->getConstraints()) ->setConstraints($preference->getConstraints())
->setEnabled($preference->isEnabled()) ->setEnabled($preference->isEnabled())
->setOptions($preference->getOptions()) ->setOptions($preference->getOptions())
->setOrder($preference->getOrder())
; ;
} else { } else {
$prefs[$preference->getName()] = $preference; $user->addPreference($preference);
} }
} }
$user->setPreferences(array_values($prefs));
} }
} }

View File

@@ -28,7 +28,9 @@ class InvoiceToolbarForm extends AbstractToolbarForm
{ {
$this->addSearchTermInputField($builder); $this->addSearchTermInputField($builder);
$this->addTemplateChoice($builder); $this->addTemplateChoice($builder);
$this->addUsersChoice($builder); if ($options['include_user']) {
$this->addUsersChoice($builder);
}
$this->addDateRangeChoice($builder); $this->addDateRangeChoice($builder);
$this->addCustomerChoice($builder, true); $this->addCustomerChoice($builder, true);
$this->addProjectChoice($builder); $this->addProjectChoice($builder);
@@ -64,6 +66,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
$resolver->setDefaults([ $resolver->setDefaults([
'data_class' => InvoiceQuery::class, 'data_class' => InvoiceQuery::class,
'csrf_protection' => false, 'csrf_protection' => false,
'include_user' => true,
]); ]);
} }
} }

View File

@@ -13,6 +13,7 @@ use App\Entity\Timesheet;
use App\Invoice\InvoiceItem; use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceItemInterface; use App\Invoice\InvoiceItemInterface;
use App\Invoice\InvoiceItemWithAmountInterface; use App\Invoice\InvoiceItemWithAmountInterface;
use App\Invoice\InvoiceItemWithTypeInterface;
abstract class AbstractMergedCalculator extends AbstractCalculator abstract class AbstractMergedCalculator extends AbstractCalculator
{ {
@@ -37,6 +38,25 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
if ($entry instanceof InvoiceItemWithAmountInterface) { if ($entry instanceof InvoiceItemWithAmountInterface) {
$amount = $entry->getAmount(); $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->setAmount($invoiceItem->getAmount() + $amount);
$invoiceItem->setUser($entry->getUser()); $invoiceItem->setUser($entry->getUser());
$invoiceItem->setRate($invoiceItem->getRate() + $entry->getRate()); $invoiceItem->setRate($invoiceItem->getRate() + $entry->getRate());

View File

@@ -0,0 +1,104 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use Symfony\Contracts\Translation\TranslatorInterface;
final class DefaultInvoiceFormatter implements InvoiceFormatter
{
/**
* @var DateExtensions
*/
private $dateExtension;
/**
* @var Extensions
*/
private $extension;
/**
* @var TranslatorInterface
*/
private $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
*/
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);
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
/**
* @internal this is subject to change
*/
interface InvoiceFormatter
{
/**
* @param \DateTime $date
* @return mixed
*/
public function getFormattedDateTime(\DateTime $date);
/**
* @param \DateTime $date
* @return mixed
*/
public function getFormattedTime(\DateTime $date);
/**
* @param int $amount
* @param string|null $currency
* @return mixed
*/
public function getFormattedMoney($amount, $currency);
/**
* @param \DateTime $date
* @return mixed
*/
public function getFormattedMonthName(\DateTime $date);
/**
* @param int $seconds
* @return mixed
*/
public function getFormattedDuration($seconds);
/**
* @param int $seconds
* @return mixed
*/
public function getFormattedDecimalDuration($seconds);
public function getCurrencySymbol(string $currency): string;
}

View File

@@ -18,6 +18,11 @@ use App\Entity\User;
*/ */
final class InvoiceItem final class InvoiceItem
{ {
public const TYPE_TIMESHEET = 'timesheet';
public const CATEGORY_TIMESHEET_WORK = 'work';
public const TYPE_MIXED = 'mixed';
public const CATEGORY_MIXED = 'mixed';
/** /**
* @var float * @var float
*/ */
@@ -66,6 +71,14 @@ final class InvoiceItem
* @var array * @var array
*/ */
private $additionalFields = []; private $additionalFields = [];
/**
* @var string
*/
private $type;
/**
* @var string
*/
private $category;
public function addAdditionalField(string $name, ?string $value): InvoiceItem public function addAdditionalField(string $name, ?string $value): InvoiceItem
{ {
@@ -215,4 +228,28 @@ final class InvoiceItem
return $this; return $this;
} }
public function getType(): ?string
{
return $this->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;
}
} }

View File

@@ -0,0 +1,17 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
interface InvoiceItemWithTypeInterface
{
public function getInvoiceType(): string;
public function getInvoiceCategory(): string;
}

View File

@@ -11,52 +11,57 @@ namespace App\Invoice;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
/** /**
* InvoiceModel is the ONLY value that a RendererInterface receives for generating the invoice, * InvoiceModel is the ONLY value that a RendererInterface receives for generating the invoice,
* besides the InvoiceDocument which is used as a "template". * besides the InvoiceDocument which is used as a "template".
*/ */
class InvoiceModel final class InvoiceModel
{ {
/** /**
* @var Customer|null * @var Customer|null
*/ */
protected $customer; private $customer;
/** /**
* @var InvoiceQuery * @var InvoiceQuery
*/ */
protected $query; private $query;
/** /**
* @var InvoiceItemInterface[] * @var InvoiceItemInterface[]
*/ */
protected $entries = []; private $entries = [];
/** /**
* @var InvoiceTemplate * @var InvoiceTemplate
*/ */
protected $template; private $template;
/** /**
* @var CalculatorInterface * @var CalculatorInterface
*/ */
protected $calculator; private $calculator;
/** /**
* @var NumberGeneratorInterface * @var NumberGeneratorInterface
*/ */
protected $generator; private $generator;
/** /**
* @var \DateTime * @var \DateTime
*/ */
protected $invoiceDate; private $invoiceDate;
/**
* @var User
*/
private $user;
/**
* @var InvoiceFormatter
*/
private $formatter;
public function __construct() public function __construct(InvoiceFormatter $formatter)
{ {
$this->invoiceDate = new \DateTime(); $this->invoiceDate = new \DateTime();
$this->formatter = $formatter;
} }
/** /**
@@ -183,4 +188,242 @@ class InvoiceModel
{ {
return $this->calculator; 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;
}
} }

View File

@@ -9,93 +9,54 @@
namespace App\Invoice\Renderer; namespace App\Invoice\Renderer;
use App\Twig\DateExtensions; use App\Entity\InvoiceDocument;
use App\Twig\Extensions; use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Component\HttpFoundation\ResponseHeaderBag;
/**
* @internal
*/
abstract class AbstractRenderer 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 * @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 * @param mixed $file
* @return mixed * @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);
/** $response->headers->set('Content-Type', $this->getContentType());
* @param int $seconds $response->headers->set('Content-Disposition', $disposition);
* @return mixed $response->deleteFileAfterSend(true);
*/
protected function getFormattedDuration($seconds)
{
return $this->extension->duration($seconds);
}
/** return $response;
* @param int $seconds
* @return mixed
*/
protected function getFormattedDecimalDuration($seconds)
{
return $this->extension->durationDecimal($seconds);
} }
} }

View File

@@ -16,6 +16,9 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
/**
* @internal
*/
abstract class AbstractSpreadsheetRenderer extends AbstractRenderer abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
{ {
/** /**
@@ -40,7 +43,7 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
$spreadsheet = IOFactory::load($document->getFilename()); $spreadsheet = IOFactory::load($document->getFilename());
$worksheet = $spreadsheet->getActiveSheet(); $worksheet = $spreadsheet->getActiveSheet();
$entries = $model->getCalculator()->getEntries(); $entries = $model->getCalculator()->getEntries();
$replacer = $this->modelToReplacer($model); $sheetReplacer = $model->toArray();
$invoiceItemCount = count($entries); $invoiceItemCount = count($entries);
if ($invoiceItemCount > 1) { if ($invoiceItemCount > 1) {
$this->addTemplateRows($worksheet, $invoiceItemCount); $this->addTemplateRows($worksheet, $invoiceItemCount);
@@ -57,26 +60,37 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
$entryRow = 0; $entryRow = 0;
foreach ($worksheet->getRowIterator() as $row) { foreach ($worksheet->getRowIterator() as $row) {
$invoiceItem = $entries[$entryRow];
$sheetValues = false; $sheetValues = false;
foreach ($row->getCellIterator() as $cell) { foreach ($row->getCellIterator() as $cell) {
$value = $cell->getValue(); $value = $cell->getValue();
if (stripos($value, '${entry.') !== false) { $replacer = null;
if ($sheetValues === false) { if (stripos($value, '${') === false) {
$sheetValues = $this->invoiceItemToArray($invoiceItem); continue;
}
$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]);
}
} }
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) { if ($sheetValues !== false && $entryRow < $invoiceItemCount - 1) {
@@ -105,7 +119,7 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
$value = $cell->getValue(); $value = $cell->getValue();
if (stripos($value, '${entry.') !== false) { if (stripos($value, '${entry.') !== false) {
$startRow = $row->getRowIndex(); $startRow = $row->getRowIndex();
$worksheet->insertNewRowBefore($row->getRowIndex(), $invoiceItemCount - 1); $worksheet->insertNewRowBefore($startRow + 1, $invoiceItemCount - 1);
break 2; break 2;
} }
@@ -123,15 +137,18 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
throw new \Exception('Invalid invoice document, no template row found.'); throw new \Exception('Invalid invoice document, no template row found.');
} }
// fill up all new rows with template values // fill up all new rows with template replacer
$templateRow = $invoiceItemCount + $startRow; $templateRow = $startRow;
$iterator = $worksheet->getRowIterator($templateRow - 1, $templateRow); $iterator = $worksheet->getRowIterator($templateRow, $templateRow + 1);
$templateColumns = []; $templateColumns = [];
foreach ($iterator->current()->getCellIterator() as $cell) {
$tmpRow = $iterator->current();
foreach ($tmpRow->getCellIterator() as $cell) {
$templateColumns[$cell->getColumn()] = $cell->getValue(); $templateColumns[$cell->getColumn()] = $cell->getValue();
} }
$iterator = $worksheet->getRowIterator($startRow, $templateRow - 2); $iterator = $worksheet->getRowIterator($startRow, $startRow + $invoiceItemCount - 1);
foreach ($iterator as $row) { foreach ($iterator as $row) {
foreach ($row->getCellIterator() as $cell) { foreach ($row->getCellIterator() as $cell) {
$cell->setValue($templateColumns[$cell->getColumn()]); $cell->setValue($templateColumns[$cell->getColumn()]);

View File

@@ -13,7 +13,7 @@ use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface final class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{ {
/** /**
* @return string[] * @return string[]

View File

@@ -19,7 +19,7 @@ use PhpOffice\PhpWord\TemplateProcessor;
use Symfony\Component\HttpFoundation\File\Stream; use Symfony\Component\HttpFoundation\File\Stream;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class DocxRenderer extends AbstractRenderer implements RendererInterface final class DocxRenderer extends AbstractRenderer implements RendererInterface
{ {
/** /**
* @param InvoiceDocument $document * @param InvoiceDocument $document
@@ -35,7 +35,7 @@ class DocxRenderer extends AbstractRenderer implements RendererInterface
$xmlEscaper = new Xml(); $xmlEscaper = new Xml();
$template = new TemplateProcessor($document->getFilename()); $template = new TemplateProcessor($document->getFilename());
foreach ($this->modelToReplacer($model) as $search => $replace) { foreach ($model->toArray() as $search => $replace) {
$replace = $xmlEscaper->escape($replace); $replace = $xmlEscaper->escape($replace);
$replace = str_replace(PHP_EOL, '</w:t><w:br /><w:t xml:space="preserve">', $replace); $replace = str_replace(PHP_EOL, '</w:t><w:br /><w:t xml:space="preserve">', $replace);
@@ -45,12 +45,18 @@ class DocxRenderer extends AbstractRenderer implements RendererInterface
try { try {
$template->cloneRow('entry.description', count($model->getCalculator()->getEntries())); $template->cloneRow('entry.description', count($model->getCalculator()->getEntries()));
} catch (OfficeException $ex) { } 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; $i = 1;
foreach ($model->getCalculator()->getEntries() as $entry) { foreach ($model->getCalculator()->getEntries() as $entry) {
$values = $this->invoiceItemToArray($entry); $values = $model->itemToArray($entry);
foreach ($values as $search => $replace) { foreach ($values as $search => $replace) {
$replace = $xmlEscaper->escape($replace); $replace = $xmlEscaper->escape($replace);
$replace = str_replace(PHP_EOL, '</w:t><w:br /><w:t xml:space="preserve">', $replace); $replace = str_replace(PHP_EOL, '</w:t><w:br /><w:t xml:space="preserve">', $replace);

View File

@@ -13,7 +13,7 @@ use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface final class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{ {
/** /**
* @return string[] * @return string[]

View File

@@ -9,181 +9,28 @@
namespace App\Invoice\Renderer; namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceItem; use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
trait RendererTrait trait RendererTrait
{ {
/** /**
* @return string[] * @var InvoiceModel
*/ */
abstract protected function getFileExtensions(); private $model;
/**
* @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);
/** /**
* @deprecated since 1.6.2 - will be removed with 2.0
* @param InvoiceModel $model * @param InvoiceModel $model
* @return array * @return array
*/ */
protected function modelToReplacer(InvoiceModel $model) protected function modelToReplacer(InvoiceModel $model)
{ {
$customer = $model->getCustomer(); @trigger_error('modelToReplacer() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$project = $model->getQuery()->getProject();
$activity = $model->getQuery()->getActivity();
$currency = $model->getCalculator()->getCurrency();
$tax = $model->getCalculator()->getTax();
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$values = [ $this->model = $model;
'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),
'template.name' => $model->getTemplate()->getName(), return $model->toArray();
'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;
} }
/** /**
@@ -193,94 +40,18 @@ trait RendererTrait
{ {
@trigger_error('timesheetToArray() is deprecated and will be removed with 2.0', E_USER_DEPRECATED); @trigger_error('timesheetToArray() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
return $this->invoiceItemToArray($invoiceItem); return $this->model->itemToArray($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;
} }
/** /**
* @param mixed $file * @deprecated since 1.6.2 - will be removed with 2.0
* @param string $filename * @param InvoiceItem $invoiceItem
* @return BinaryFileResponse * @return array
*/ */
protected function getFileResponse($file, $filename) protected function invoiceItemToArray(InvoiceItem $invoiceItem): array
{ {
$response = new BinaryFileResponse($file); @trigger_error('invoiceItemToArray() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $this->getContentType()); return $this->model->itemToArray($invoiceItem);
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
} }
} }

View File

@@ -13,7 +13,7 @@ use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Spreadsheet;
class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface final class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{ {
/** /**
* @return string[] * @return string[]

View File

@@ -346,6 +346,18 @@ class ActivityRepository extends EntityRepository
return $qb; 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 public function getPagerfantaForQuery(ActivityQuery $query): Pagerfanta
{ {
$paginator = new Pagerfanta($this->getPaginatorForQuery($query)); $paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -357,14 +369,7 @@ class ActivityRepository extends EntityRepository
protected function getPaginatorForQuery(ActivityQuery $query): PaginatorInterface protected function getPaginatorForQuery(ActivityQuery $query): PaginatorInterface
{ {
$qb = $this->getQueryBuilderForQuery($query); $counter = $this->countActivitiesForQuery($query);
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('a.id'))
;
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query); $qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new ActivityLoader($qb->getEntityManager()), $qb, $counter); return new LoaderPaginator(new ActivityLoader($qb->getEntityManager()), $qb, $counter);

View File

@@ -272,7 +272,7 @@ class CustomerRepository extends EntityRepository
return $paginator; return $paginator;
} }
protected function getPaginatorForQuery(CustomerQuery $query): PaginatorInterface public function countCustomersForQuery(CustomerQuery $query): int
{ {
$qb = $this->getQueryBuilderForQuery($query); $qb = $this->getQueryBuilderForQuery($query);
$qb $qb
@@ -280,8 +280,13 @@ class CustomerRepository extends EntityRepository
->resetDQLPart('orderBy') ->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('c.id')) ->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); $qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new CustomerLoader($qb->getEntityManager()), $qb, $counter); return new LoaderPaginator(new CustomerLoader($qb->getEntityManager()), $qb, $counter);

View File

@@ -283,6 +283,18 @@ class ProjectRepository extends EntityRepository
return $qb; 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 public function getPagerfantaForQuery(ProjectQuery $query): Pagerfanta
{ {
$paginator = new Pagerfanta($this->getPaginatorForQuery($query)); $paginator = new Pagerfanta($this->getPaginatorForQuery($query));
@@ -294,14 +306,7 @@ class ProjectRepository extends EntityRepository
private function getPaginatorForQuery(ProjectQuery $query): PaginatorInterface private function getPaginatorForQuery(ProjectQuery $query): PaginatorInterface
{ {
$qb = $this->getQueryBuilderForQuery($query); $counter = $this->countProjectsForQuery($query);
$qb
->resetDQLPart('select')
->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('p.id'))
;
$counter = (int) $qb->getQuery()->getSingleScalarResult();
$qb = $this->getQueryBuilderForQuery($query); $qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new ProjectLoader($qb->getEntityManager()), $qb, $counter); return new LoaderPaginator(new ProjectLoader($qb->getEntityManager()), $qb, $counter);

View File

@@ -640,6 +640,10 @@ class TimesheetRepository extends EntityRepository
$currentUser = $query->getCurrentUser(); $currentUser = $query->getCurrentUser();
if (!$currentUser->isSuperAdmin() && !$currentUser->isAdmin()) { 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) { foreach ($currentUser->getTeams() as $team) {
if ($currentUser->isTeamleadOf($team)) { if ($currentUser->isTeamleadOf($team)) {
$query->addTeam($team); $query->addTeam($team);

View File

@@ -236,7 +236,7 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
return $paginator; return $paginator;
} }
protected function getPaginatorForQuery(UserQuery $query): PaginatorInterface public function countUsersForQuery(UserQuery $query): int
{ {
$qb = $this->getQueryBuilderForQuery($query); $qb = $this->getQueryBuilderForQuery($query);
$qb $qb
@@ -244,8 +244,13 @@ class UserRepository extends EntityRepository implements UserLoaderInterface
->resetDQLPart('orderBy') ->resetDQLPart('orderBy')
->select($qb->expr()->countDistinct('u.id')) ->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); $qb = $this->getQueryBuilderForQuery($query);
return new LoaderPaginator(new UserLoader($qb->getEntityManager()), $qb, $counter); return new LoaderPaginator(new UserLoader($qb->getEntityManager()), $qb, $counter);

View File

@@ -119,12 +119,16 @@ class TimesheetValidator extends ConstraintValidator
->addViolation(); ->addViolation();
} }
if (false === $this->configuration->isAllowFutureTimes() && time() < $timesheet->getBegin()->getTimestamp()) { if (false === $this->configuration->isAllowFutureTimes()) {
$context->buildViolation('The begin date cannot be in the future.') // allow configured default rounding time + 1 minute - see #1295
->atPath('begin') $allowedDiff = ($this->configuration->getDefaultRoundingBegin() * 60) + 60;
->setTranslationDomain('validators') if ((time() + $allowedDiff) < $timesheet->getBegin()->getTimestamp()) {
->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR) $context->buildViolation('The begin date cannot be in the future.')
->addViolation(); ->atPath('begin')
->setTranslationDomain('validators')
->setCode(TimesheetConstraint::BEGIN_IN_FUTURE_ERROR)
->addViolation();
}
} }
} }

View File

@@ -37,7 +37,9 @@
{{ form_row(form.customer) }} {{ form_row(form.customer) }}
{{ form_row(form.project) }} {{ form_row(form.project) }}
{{ form_row(form.activity) }} {{ 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.tags) }}
{{ form_row(form.exported) }} {{ form_row(form.exported) }}
{{ form_row(form.template) }} {{ form_row(form.template) }}

View File

@@ -110,7 +110,7 @@
<td class="text-right">{{ model.calculator.tax|money(model.calculator.currency) }}</td> <td class="text-right">{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr> </tr>
<tr> <tr>
<td colspan="4" class="text-right"> <td colspan="4" class="text-right text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong> <strong>{{ 'invoice.total'|trans }}</strong>
</td> </td>
<td class="text-right"> <td class="text-right">
@@ -134,7 +134,7 @@
<footer class="footer"> <footer class="footer">
<p> <p>
<strong>{{ 'label.address'|trans }}</strong>: {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }} <strong>{{ 'label.address'|trans }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br> <br>
<strong>{{ 'label.invoice_bank_account'|trans }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }} <strong>{{ 'label.invoice_bank_account'|trans }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br> <br>

View File

@@ -4,18 +4,11 @@
{% block invoice %} {% block invoice %}
{% set signature = null %}
{# You can overwrite these settings in your config/packages/local.yaml under the key "twig.globals.company" - previously '/build/images/signature.png' #}
{% set company = company|default({signature: null}) %}
{% if company.signature is defined and company.signature is not empty %}
{% set signature = company.signature %}
{% endif %}
<div class="row" id="freelancer-invoice"> <div class="row" id="freelancer-invoice">
<div class="col-xs-12"> <div class="col-xs-12">
<header> <header>
<address> <address>
<p contenteditable="true">{{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}</p> <p contenteditable="true">{{ model.template.company }} &ndash; {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}</p>
</address> </address>
</header> </header>
<article class="address"> <article class="address">
@@ -93,9 +86,9 @@
{{ entry.activity.name }} / {{ entry.project.name }} {{ entry.activity.name }} / {{ entry.project.name }}
{% endif %} {% endif %}
</td> </td>
<td class="text-right">{{ rate|money(model.calculator.currency) }}</td> <td class="text-right text-nowrap">{{ rate|money(model.calculator.currency) }}</td>
<td class="text-right">{{ duration }}</td> <td class="text-right text-nowrap">{{ duration }}</td>
<td class="text-right">{{ entry.rate|money(model.calculator.currency) }}</td> <td class="text-right text-nowrap">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -120,9 +113,6 @@
<div contenteditable="true"> <div contenteditable="true">
{{ model.template.paymentTerms|nl2br|md2html }} {{ model.template.paymentTerms|nl2br|md2html }}
</div> </div>
{% if signature is not empty %}
<img src="{{ signature }}">
{% endif %}
</article> </article>
{% endif %} {% endif %}
<footer class="footer"> <footer class="footer">
@@ -132,6 +122,7 @@
<strong>{{ 'label.address'|trans }}</strong> <strong>{{ 'label.address'|trans }}</strong>
</p> </p>
<p> <p>
{{ model.template.company }}<br>
{{ model.template.address|nl2br }} {{ model.template.address|nl2br }}
</p> </p>
</div> </div>

View File

@@ -16,7 +16,7 @@ use App\Entity\User;
*/ */
class ConfigurationControllerTest extends APIControllerBaseTest class ConfigurationControllerTest extends APIControllerBaseTest
{ {
public function testI18nIsSecure() public function testIsSecure()
{ {
$this->assertUrlIsSecured('/api/config/i18n'); $this->assertUrlIsSecured('/api/config/i18n');
} }

View File

@@ -20,6 +20,7 @@ class StatusControllerTest extends APIControllerBaseTest
public function testIsSecure() public function testIsSecure()
{ {
$this->assertUrlIsSecured('/api/ping'); $this->assertUrlIsSecured('/api/ping');
$this->assertUrlIsSecured('/api/version');
} }
public function testPing() public function testPing()

View File

@@ -31,6 +31,11 @@ class TagControllerTest extends APIControllerBaseTest
$this->importFixture($em, $fixture); $this->importFixture($em, $fixture);
} }
public function testIsSecure()
{
$this->assertUrlIsSecured('/api/tags');
}
public function testGetCollection() public function testGetCollection()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);

View File

@@ -30,6 +30,8 @@ class TeamControllerTest extends APIControllerBaseTest
public function testIsSecure() public function testIsSecure()
{ {
$this->assertUrlIsSecured('/api/teams'); $this->assertUrlIsSecured('/api/teams');
$this->assertUrlIsSecuredForRole(User::ROLE_USER, '/api/teams');
$this->assertUrlIsSecuredForRole(User::ROLE_TEAMLEAD, '/api/teams');
} }
public function testGetCollection() public function testGetCollection()
@@ -56,7 +58,7 @@ class TeamControllerTest extends APIControllerBaseTest
public function testNotFound() public function testNotFound()
{ {
$this->assertEntityNotFound(User::ROLE_USER, '/api/teams/3'); $this->assertEntityNotFound(User::ROLE_ADMIN, '/api/teams/3');
} }
public function testDeleteActionWithUnknownTeam() public function testDeleteActionWithUnknownTeam()
@@ -80,7 +82,7 @@ class TeamControllerTest extends APIControllerBaseTest
$this->assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode()); $this->assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
$this->assertEmpty($client->getResponse()->getContent()); $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) protected function assertStructure(array $result, $full = true)
@@ -90,7 +92,9 @@ class TeamControllerTest extends APIControllerBaseTest
]; ];
if ($full) { if ($full) {
$expectedKeys = array_merge($expectedKeys, []); $expectedKeys = array_merge($expectedKeys, [
'teamlead', 'users'
]);
} }
$actual = array_keys($result); $actual = array_keys($result);

View File

@@ -19,6 +19,8 @@ class UserControllerTest extends APIControllerBaseTest
public function testIsSecure() public function testIsSecure()
{ {
$this->assertUrlIsSecured('/api/users'); $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'); $this->assertUrlIsSecuredForRole(User::ROLE_ADMIN, '/api/users');
} }
@@ -118,7 +120,7 @@ class UserControllerTest extends APIControllerBaseTest
if ($full) { if ($full) {
$expectedKeys = array_merge( $expectedKeys = array_merge(
$expectedKeys, $expectedKeys,
['title', 'avatar', 'roles', 'language', 'timezone'] ['title', 'avatar', 'teams', 'roles', 'language', 'timezone']
); );
} }

View File

@@ -9,6 +9,7 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Entity\Team;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;
@@ -35,15 +36,79 @@ class ExportControllerTest extends ControllerBaseTest
$this->assertHasNoEntriesWithFilter($client); $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); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
$begin = new \DateTime('first day of this month'); $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 = new TimesheetFixtures();
$fixture $fixture
->setUser($this->getUserByRole($em, User::ROLE_USER)) ->setUser($user)
->setAmount(20) ->setAmount(20)
->setStartDate($begin) ->setStartDate($begin)
; ;
@@ -52,9 +117,25 @@ class ExportControllerTest extends ControllerBaseTest
$this->request($client, '/export/?preview='); $this->request($client, '/export/?preview=');
$this->assertTrue($client->getResponse()->isSuccessful()); $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 // make sure all existing records are displayed
$this->assertHasDataTable($client); $this->assertHasDataTable($client);
$this->assertDataTableRowCount($client, 'datatable_export', 20); $this->assertDataTableRowCount($client, 'datatable_export', 2);
// assert export type buttons are available // assert export type buttons are available
$expected = ['csv', 'html', 'pdf', 'xlsx']; $expected = ['csv', 'html', 'pdf', 'xlsx'];
@@ -102,7 +183,7 @@ class ExportControllerTest extends ControllerBaseTest
public function testExportAction() public function testExportAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
/** @var EntityManager $em */ /** @var EntityManager $em */
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');

View File

@@ -125,7 +125,7 @@ class TimesheetTeamControllerTest extends ControllerBaseTest
public function testExportAction() public function testExportAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$em = $client->getContainer()->get('doctrine.orm.entity_manager'); $em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new TimesheetFixtures(); $fixture = new TimesheetFixtures();

View File

@@ -244,6 +244,8 @@ class ConfigurationTest extends TestCase
], ],
'invoice' => [ 'invoice' => [
'documents' => [ 'documents' => [
],
'defaults' => [
0 => 'var/invoices/', 0 => 'var/invoices/',
1 => 'templates/invoice/renderer/', 1 => 'templates/invoice/renderer/',
], ],

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\InvoiceDocument;
use App\Event\InvoicePostRenderEvent;
use App\Invoice\InvoiceModel;
use App\Tests\Invoice\DebugFormatter;
use App\Tests\Invoice\Renderer\DebugRenderer;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;
/**
* @covers \App\Event\InvoicePostRenderEvent
*/
class InvoicePostRenderEventTest extends TestCase
{
public function testDefaultValues()
{
$model = new InvoiceModel(new DebugFormatter());
$document = new InvoiceDocument(new \SplFileInfo(__FILE__));
$renderer = new DebugRenderer();
$response = new Response();
$sut = new InvoicePostRenderEvent($model, $document, $renderer, $response);
self::assertSame($model, $sut->getModel());
self::assertSame($document, $sut->getDocument());
self::assertSame($renderer, $sut->getRenderer());
self::assertSame($response, $sut->getResponse());
}
}

View File

@@ -0,0 +1,36 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Event;
use App\Entity\InvoiceDocument;
use App\Event\InvoicePreRenderEvent;
use App\Invoice\InvoiceModel;
use App\Tests\Invoice\DebugFormatter;
use App\Tests\Invoice\Renderer\DebugRenderer;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Event\InvoicePreRenderEvent
*/
class InvoicePreRenderEventTest extends TestCase
{
public function testDefaultValues()
{
$model = new InvoiceModel(new DebugFormatter());
$document = new InvoiceDocument(new \SplFileInfo(__FILE__));
$renderer = new DebugRenderer();
$sut = new InvoicePreRenderEvent($model, $document, $renderer);
self::assertSame($model, $sut->getModel());
self::assertSame($document, $sut->getDocument());
self::assertSame($renderer, $sut->getRenderer());
}
}

View File

@@ -75,16 +75,16 @@ class DashboardSubscriberTest extends TestCase
$authMock->method('isGranted')->willReturn($isAdmin); $authMock->method('isGranted')->willReturn($isAdmin);
$userMock = $this->getMockBuilder(UserRepository::class)->disableOriginalConstructor()->getMock(); $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 = $this->getMockBuilder(ProjectRepository::class)->disableOriginalConstructor()->getMock();
$projectMock->method('countProject')->willReturn($projectCount); $projectMock->method('countProjectsForQuery')->willReturn($projectCount);
$activityMock = $this->getMockBuilder(ActivityRepository::class)->disableOriginalConstructor()->getMock(); $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 = $this->getMockBuilder(CustomerRepository::class)->disableOriginalConstructor()->getMock();
$customerMock->method('countCustomer')->willReturn($customerCount); $customerMock->method('countCustomersForQuery')->willReturn($customerCount);
return new DashboardSubscriber($authMock, $userMock, $activityMock, $projectMock, $customerMock); return new DashboardSubscriber($authMock, $userMock, $activityMock, $projectMock, $customerMock);
} }

View File

@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\CalculatorInterface; use App\Invoice\CalculatorInterface;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
abstract class AbstractCalculatorTest extends TestCase abstract class AbstractCalculatorTest extends TestCase
@@ -28,7 +29,7 @@ abstract class AbstractCalculatorTest extends TestCase
$template = new InvoiceTemplate(); $template = new InvoiceTemplate();
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setQuery($query); $model->setQuery($query);
@@ -80,7 +81,7 @@ abstract class AbstractCalculatorTest extends TestCase
->setActivity($activity) ->setActivity($activity)
->setProject($project); ->setProject($project);
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries([$timesheet]); $model->setEntries([$timesheet]);

View File

@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\Calculator\ActivityInvoiceCalculator; use App\Invoice\Calculator\ActivityInvoiceCalculator;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
/** /**
* @covers \App\Invoice\Calculator\ActivityInvoiceCalculator * @covers \App\Invoice\Calculator\ActivityInvoiceCalculator
@@ -105,7 +106,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setActivity($activity1); $query->setActivity($activity1);
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);

View File

@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\Calculator\DateInvoiceCalculator; use App\Invoice\Calculator\DateInvoiceCalculator;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
use DateTime; use DateTime;
/** /**
@@ -106,7 +107,7 @@ class DateInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setProject($project1); $query->setProject($project1);
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);

View File

@@ -16,6 +16,7 @@ use App\Entity\Timesheet;
use App\Invoice\Calculator\DefaultCalculator; use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
/** /**
* @covers \App\Invoice\Calculator\DefaultCalculator * @covers \App\Invoice\Calculator\DefaultCalculator
@@ -57,7 +58,7 @@ class DefaultCalculatorTest extends AbstractCalculatorTest
$entries = [$timesheet, $timesheet2, $timesheet3]; $entries = [$timesheet, $timesheet2, $timesheet3];
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);

View File

@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\Calculator\ProjectInvoiceCalculator; use App\Invoice\Calculator\ProjectInvoiceCalculator;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
use DateTime; use DateTime;
/** /**
@@ -106,7 +107,7 @@ class ProjectInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setProject($project1); $query->setProject($project1);
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);

View File

@@ -19,6 +19,7 @@ use App\Invoice\Calculator\ShortInvoiceCalculator;
use App\Invoice\InvoiceItem; use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
/** /**
* @covers \App\Invoice\Calculator\ShortInvoiceCalculator * @covers \App\Invoice\Calculator\ShortInvoiceCalculator
@@ -86,7 +87,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setActivity($activity); $query->setActivity($activity);
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);
@@ -167,7 +168,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setActivity($activity); $query->setActivity($activity);
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);
@@ -246,7 +247,7 @@ class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setActivity($activity); $query->setActivity($activity);
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);

View File

@@ -18,6 +18,7 @@ use App\Entity\User;
use App\Invoice\Calculator\UserInvoiceCalculator; use App\Invoice\Calculator\UserInvoiceCalculator;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use App\Tests\Invoice\DebugFormatter;
/** /**
* @covers \App\Invoice\Calculator\UserInvoiceCalculator * @covers \App\Invoice\Calculator\UserInvoiceCalculator
@@ -104,7 +105,7 @@ class UserInvoiceCalculatorTest extends AbstractCalculatorTest
$query = new InvoiceQuery(); $query = new InvoiceQuery();
$query->setActivity($activity); $query->setActivity($activity);
$model = new InvoiceModel(); $model = new InvoiceModel(new DebugFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);

View File

@@ -0,0 +1,79 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice;
use App\Invoice\InvoiceFormatter;
class DebugFormatter implements InvoiceFormatter
{
/**
* @param \DateTime $date
* @return mixed
*/
public function getFormattedDateTime(\DateTime $date)
{
return $date->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;
}
}

View File

@@ -35,5 +35,7 @@ class InvoiceItemTest extends TestCase
self::assertNull($sut->getUser()); self::assertNull($sut->getUser());
self::assertNull($sut->getDescription()); self::assertNull($sut->getDescription());
self::assertEquals(0, $sut->getDuration()); self::assertEquals(0, $sut->getDuration());
self::assertNull($sut->getCategory());
self::assertNull($sut->getType());
} }
} }

View File

@@ -25,7 +25,8 @@ class InvoiceModelTest extends TestCase
{ {
public function testEmptyObject() public function testEmptyObject()
{ {
$sut = new InvoiceModel(); $formatter = new DebugFormatter();
$sut = new InvoiceModel($formatter);
self::assertNull($sut->getQuery()); self::assertNull($sut->getQuery());
self::assertNull($sut->getCustomer()); self::assertNull($sut->getCustomer());
@@ -38,11 +39,13 @@ class InvoiceModelTest extends TestCase
self::assertNull($sut->getTemplate()); self::assertNull($sut->getTemplate());
self::assertInstanceOf(\DateTime::class, $sut->getInvoiceDate()); self::assertInstanceOf(\DateTime::class, $sut->getInvoiceDate());
self::assertSame($formatter, $sut->getFormatter());
} }
public function testSetter() public function testSetter()
{ {
$sut = new InvoiceModel(); $sut = new InvoiceModel(new DebugFormatter());
$query = new InvoiceQuery(); $query = new InvoiceQuery();
self::assertInstanceOf(InvoiceModel::class, $sut->setQuery($query)); self::assertInstanceOf(InvoiceModel::class, $sut->setQuery($query));

View File

@@ -11,6 +11,7 @@ namespace App\Tests\Invoice\NumberGenerator;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Tests\Invoice\DebugFormatter;
use PHPUnit\Framework\TestCase; use PHPUnit\Framework\TestCase;
/** /**
@@ -21,7 +22,7 @@ class DateNumberGeneratorTest extends TestCase
public function testGetInvoiceNumber() public function testGetInvoiceNumber()
{ {
$sut = new DateNumberGenerator(); $sut = new DateNumberGenerator();
$sut->setModel(new InvoiceModel()); $sut->setModel(new InvoiceModel(new DebugFormatter()));
$this->assertEquals(date('ymd'), $sut->getInvoiceNumber()); $this->assertEquals(date('ymd'), $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId()); $this->assertEquals('default', $sut->getId());

View File

@@ -11,14 +11,12 @@ namespace App\Tests\Invoice\Renderer;
use App\Entity\InvoiceDocument; use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Invoice\Renderer\RendererTrait; use App\Invoice\Renderer\AbstractRenderer;
use App\Invoice\RendererInterface; use App\Invoice\RendererInterface;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
class DebugRenderer implements RendererInterface class DebugRenderer extends AbstractRenderer implements RendererInterface
{ {
use RendererTrait;
/** /**
* @return string[] * @return string[]
*/ */
@@ -35,60 +33,6 @@ class DebugRenderer implements RendererInterface
return 'array'; 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. * 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 public function render(InvoiceDocument $document, InvoiceModel $model): Response
{ {
$result = [ $result = [
'model' => $this->modelToReplacer($model), 'model' => $model->toArray(),
'entries' => [], 'entries' => [],
]; ];
foreach ($model->getCalculator()->getEntries() as $entry) { foreach ($model->getCalculator()->getEntries() as $entry) {
$result['entries'][] = $this->timesheetToArray($entry); $result['entries'][] = $model->itemToArray($entry);
} }
return new Response(json_encode($result)); return new Response(json_encode($result));

View File

@@ -58,15 +58,19 @@ class DebugRendererTest extends TestCase
'invoice.date', 'invoice.date',
'invoice.number', 'invoice.number',
'invoice.currency', 'invoice.currency',
'invoice.currency_symbol',
'invoice.vat', 'invoice.vat',
'invoice.tax', 'invoice.tax',
'invoice.tax_nc', 'invoice.tax_nc',
'invoice.tax_plain',
'invoice.total_time', 'invoice.total_time',
'invoice.duration_decimal', 'invoice.duration_decimal',
'invoice.total', 'invoice.total',
'invoice.total_nc', 'invoice.total_nc',
'invoice.total_plain',
'invoice.subtotal', 'invoice.subtotal',
'invoice.subtotal_nc', 'invoice.subtotal_nc',
'invoice.subtotal_plain',
'template.name', 'template.name',
'template.company', 'template.company',
'template.address', 'template.address',
@@ -93,14 +97,28 @@ class DebugRendererTest extends TestCase
'customer.homepage', 'customer.homepage',
'customer.comment', 'customer.comment',
'customer.fixed_rate', 'customer.fixed_rate',
'customer.fixed_rate_nc',
'customer.fixed_rate_plain',
'customer.hourly_rate', 'customer.hourly_rate',
'customer.hourly_rate_nc',
'customer.hourly_rate_plain',
'customer.meta.foo-customer', 'customer.meta.foo-customer',
'activity.id', 'activity.id',
'activity.name', 'activity.name',
'activity.comment', 'activity.comment',
'activity.fixed_rate', 'activity.fixed_rate',
'activity.fixed_rate_nc',
'activity.fixed_rate_plain',
'activity.hourly_rate', 'activity.hourly_rate',
'activity.hourly_rate_nc',
'activity.hourly_rate_plain',
'activity.meta.foo-activity', 'activity.meta.foo-activity',
'user.alias',
'user.email',
'user.name',
'user.title',
'user.meta.hello',
'user.meta.kitty',
]; ];
if ($hasProject) { if ($hasProject) {
@@ -111,7 +129,11 @@ class DebugRendererTest extends TestCase
'project.order_date', 'project.order_date',
'project.order_number', 'project.order_number',
'project.fixed_rate', 'project.fixed_rate',
'project.fixed_rate_nc',
'project.fixed_rate_plain',
'project.hourly_rate', 'project.hourly_rate',
'project.hourly_rate_nc',
'project.hourly_rate_plain',
'project.meta.foo-project', 'project.meta.foo-project',
]); ]);
} }
@@ -131,8 +153,10 @@ class DebugRendererTest extends TestCase
'entry.amount', 'entry.amount',
'entry.rate', 'entry.rate',
'entry.rate_nc', 'entry.rate_nc',
'entry.rate_plain',
'entry.total', 'entry.total',
'entry.total_nc', 'entry.total_nc',
'entry.total_plain',
'entry.currency', 'entry.currency',
'entry.duration', 'entry.duration',
'entry.duration_decimal', 'entry.duration_decimal',
@@ -154,6 +178,8 @@ class DebugRendererTest extends TestCase
'entry.customer', 'entry.customer',
'entry.project_id', 'entry.project_id',
'entry.customer_id', 'entry.customer_id',
'entry.category',
'entry.type',
]; ];
$keys = array_merge($keys, $metaFields); $keys = array_merge($keys, $metaFields);

View File

@@ -21,7 +21,10 @@ use App\Entity\ProjectMeta;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Entity\TimesheetMeta; use App\Entity\TimesheetMeta;
use App\Entity\User; use App\Entity\User;
use App\Entity\UserPreference;
use App\Invoice\Calculator\DefaultCalculator; use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\DefaultInvoiceFormatter;
use App\Invoice\InvoiceFormatter;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator; use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\AbstractRenderer; use App\Invoice\Renderer\AbstractRenderer;
@@ -65,6 +68,11 @@ trait RendererTestTrait
* @return AbstractRenderer * @return AbstractRenderer
*/ */
protected function getAbstractRenderer(string $classname) protected function getAbstractRenderer(string $classname)
{
return new $classname();
}
protected function getFormatter(): InvoiceFormatter
{ {
$requestStack = new RequestStack(); $requestStack = new RequestStack();
$languages = [ $languages = [
@@ -85,11 +93,19 @@ trait RendererTestTrait
$dateExtension = new DateExtensions($localeSettings); $dateExtension = new DateExtensions($localeSettings);
$extensions = new Extensions($localeSettings); $extensions = new Extensions($localeSettings);
return new $classname($translator, $dateExtension, $extensions); return new DefaultInvoiceFormatter($translator, $dateExtension, $extensions);
} }
protected function getInvoiceModel(): InvoiceModel 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 = new Customer();
$customer->setCurrency('EUR'); $customer->setCurrency('EUR');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true)); $customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
@@ -185,11 +201,12 @@ trait RendererTestTrait
$query->setEnd(new \DateTime()); $query->setEnd(new \DateTime());
$query->setProject($project); $query->setProject($project);
$model = new InvoiceModel(); $model = new InvoiceModel($this->getFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);
$model->setQuery($query); $model->setQuery($query);
$model->setUser($user);
$calculator = new DefaultCalculator(); $calculator = new DefaultCalculator();
$calculator->setModel($model); $calculator->setModel($model);
@@ -206,6 +223,14 @@ trait RendererTestTrait
protected function getInvoiceModelOneEntry(): InvoiceModel 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 = new Customer();
$customer->setCurrency('USD'); $customer->setCurrency('USD');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true)); $customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
@@ -248,11 +273,12 @@ trait RendererTestTrait
$query->setBegin(new \DateTime()); $query->setBegin(new \DateTime());
$query->setEnd(new \DateTime()); $query->setEnd(new \DateTime());
$model = new InvoiceModel(); $model = new InvoiceModel($this->getFormatter());
$model->setCustomer($customer); $model->setCustomer($customer);
$model->setTemplate($template); $model->setTemplate($template);
$model->setEntries($entries); $model->setEntries($entries);
$model->setQuery($query); $model->setQuery($query);
$model->setUser($user);
$calculator = new DefaultCalculator(); $calculator = new DefaultCalculator();
$calculator->setModel($model); $calculator->setModel($model);

View File

@@ -1,65 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\InvoiceModel;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Repository\Query\InvoiceQuery;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\InvoiceModel
*/
class InvoiceModelTest extends TestCase
{
public function testDefaultValues()
{
$sut = new InvoiceModel();
$this->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')
);
}
}

View File

@@ -38,6 +38,11 @@ class TimesheetValidatorTest extends ConstraintValidatorTestCase
'rules' => [ 'rules' => [
'allow_future_times' => false, 'allow_future_times' => false,
], ],
'rounding' => [
'default' => [
'begin' => 1
]
]
]); ]);
$service = (new TrackingModeServiceFactory($this))->create('default'); $service = (new TrackingModeServiceFactory($this))->create('default');