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