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,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user