added invoice archive & configurable invoice numbers (#1541)
This commit is contained in:
@@ -10,6 +10,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
|
||||
use Symfony\Component\Translation\DataCollectorTranslator;
|
||||
use Symfony\Contracts\Service\ServiceSubscriberInterface;
|
||||
@@ -20,13 +21,6 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
*/
|
||||
abstract class AbstractController extends BaseAbstractController implements ServiceSubscriberInterface
|
||||
{
|
||||
public const FLASH_SUCCESS = 'success';
|
||||
public const FLASH_WARNING = 'warning';
|
||||
public const FLASH_ERROR = 'error';
|
||||
|
||||
public const DOMAIN_FLASH = 'flashmessages';
|
||||
public const DOMAIN_ERROR = 'exceptions';
|
||||
|
||||
/**
|
||||
* @deprecated since 1.6, will be removed with 2.0
|
||||
*/
|
||||
@@ -40,6 +34,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
return $this->container->get('translator');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LoggerInterface $logger
|
||||
*/
|
||||
private function getLogger()
|
||||
{
|
||||
return $this->container->get('logger');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User|null
|
||||
*/
|
||||
@@ -56,7 +58,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
*/
|
||||
protected function flashSuccess($translationKey, $parameter = [])
|
||||
{
|
||||
$this->addFlashTranslated(self::FLASH_SUCCESS, $translationKey, $parameter);
|
||||
$this->addFlashTranslated('success', $translationKey, $parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +69,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
*/
|
||||
protected function flashWarning($translationKey, $parameter = [])
|
||||
{
|
||||
$this->addFlashTranslated(self::FLASH_WARNING, $translationKey, $parameter);
|
||||
$this->addFlashTranslated('warning', $translationKey, $parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +80,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
*/
|
||||
protected function flashError($translationKey, $parameter = [])
|
||||
{
|
||||
$this->addFlashTranslated(self::FLASH_ERROR, $translationKey, $parameter);
|
||||
$this->addFlashTranslated('error', $translationKey, $parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,22 +94,28 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
{
|
||||
if (!empty($parameter)) {
|
||||
foreach ($parameter as $key => $value) {
|
||||
$parameter[$key] = $this->getTranslator()->trans($value, [], self::DOMAIN_FLASH);
|
||||
$parameter[$key] = $this->getTranslator()->trans($value, [], 'flashmessages');
|
||||
}
|
||||
$message = $this->getTranslator()->trans(
|
||||
$message,
|
||||
$parameter,
|
||||
self::DOMAIN_FLASH
|
||||
'flashmessages'
|
||||
);
|
||||
}
|
||||
|
||||
$this->addFlash($type, $message);
|
||||
}
|
||||
|
||||
protected function logException(\Exception $ex)
|
||||
{
|
||||
$this->getLogger()->critical($ex->getMessage());
|
||||
}
|
||||
|
||||
public static function getSubscribedServices()
|
||||
{
|
||||
return array_merge(parent::getSubscribedServices(), [
|
||||
'translator' => TranslatorInterface::class
|
||||
'translator' => TranslatorInterface::class,
|
||||
'logger' => LoggerInterface::class
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,21 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Event\InvoicePostRenderEvent;
|
||||
use App\Event\InvoicePreRenderEvent;
|
||||
use App\Export\ExportItemInterface;
|
||||
use App\Form\InvoiceDocumentUploadForm;
|
||||
use App\Form\InvoiceTemplateForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarSimpleForm;
|
||||
use App\Invoice\InvoiceFormatter;
|
||||
use App\Invoice\InvoiceItemInterface;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
@@ -48,7 +52,7 @@ final class InvoiceController extends AbstractController
|
||||
/**
|
||||
* @var InvoiceTemplateRepository
|
||||
*/
|
||||
private $invoiceRepository;
|
||||
private $templateRepository;
|
||||
/**
|
||||
* @var UserDateTimeFactory
|
||||
*/
|
||||
@@ -61,11 +65,16 @@ final class InvoiceController extends AbstractController
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var InvoiceRepository
|
||||
*/
|
||||
private $invoiceRepository;
|
||||
|
||||
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
$this->service = $service;
|
||||
$this->invoiceRepository = $invoice;
|
||||
$this->templateRepository = $templateRepository;
|
||||
$this->invoiceRepository = $invoiceRepository;
|
||||
$this->dateTimeFactory = $dateTimeFactory;
|
||||
$this->formatter = $formatter;
|
||||
$this->dispatcher = $dispatcher;
|
||||
@@ -75,9 +84,9 @@ final class InvoiceController extends AbstractController
|
||||
* @Route(path="/", name="invoice", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
public function indexAction(Request $request): Response
|
||||
public function indexAction(Request $request, SystemConfiguration $configuration): Response
|
||||
{
|
||||
if (!$this->invoiceRepository->hasTemplate()) {
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
if ($this->isGranted('manage_invoice_template')) {
|
||||
return $this->redirectToRoute('admin_invoice_template_create');
|
||||
}
|
||||
@@ -88,16 +97,27 @@ final class InvoiceController extends AbstractController
|
||||
$entries = [];
|
||||
|
||||
$query = $this->getDefaultQuery();
|
||||
$form = $this->getToolbarForm($query, 'GET');
|
||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||
$form->setData($query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($this->isGranted('create_invoice')) {
|
||||
if ($form->isValid()) {
|
||||
/** @var SubmitButton $createButton */
|
||||
$createButton = $form->get('create');
|
||||
if ($createButton->isClicked()) {
|
||||
return $this->renderInvoice($query);
|
||||
try {
|
||||
/** @var SubmitButton $createButton */
|
||||
$createButton = $form->get('create');
|
||||
if ($createButton->isClicked()) {
|
||||
return $this->renderInvoice($query, true);
|
||||
}
|
||||
|
||||
/** @var SubmitButton $printButton */
|
||||
$printButton = $form->get('print');
|
||||
if ($printButton->isClicked()) {
|
||||
return $this->renderInvoice($query, false);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$this->logException($ex);
|
||||
$this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
|
||||
}
|
||||
|
||||
/** @var SubmitButton $previewButton */
|
||||
@@ -144,7 +164,7 @@ final class InvoiceController extends AbstractController
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function renderInvoice(InvoiceQuery $query)
|
||||
protected function renderInvoice(InvoiceQuery $query, bool $saveInvoice = false)
|
||||
{
|
||||
$entries = $this->getEntries($query);
|
||||
$model = $this->prepareModel($query);
|
||||
@@ -162,11 +182,28 @@ final class InvoiceController extends AbstractController
|
||||
$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));
|
||||
if ($saveInvoice) {
|
||||
if ($query->isMarkAsExported()) {
|
||||
$this->markEntriesAsExported($entries);
|
||||
}
|
||||
|
||||
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$invoiceFilename = $this->service->saveGeneratedInvoice($event);
|
||||
|
||||
$invoice = new Invoice();
|
||||
$invoice->setModel($model);
|
||||
$invoice->setFilename($invoiceFilename);
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('history_invoice')) {
|
||||
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -180,7 +217,81 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InvoiceItemInterface[] $entries
|
||||
* @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET"})
|
||||
* @Security("is_granted('history_invoice')")
|
||||
*/
|
||||
public function changeStatusAction(Invoice $invoice, string $status): Response
|
||||
{
|
||||
if (!in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) {
|
||||
throw $this->createNotFoundException('Unknwon invoice status');
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case Invoice::STATUS_NEW:
|
||||
$invoice->setIsNew();
|
||||
break;
|
||||
|
||||
case Invoice::STATUS_PENDING:
|
||||
$invoice->setIsPending();
|
||||
break;
|
||||
|
||||
case Invoice::STATUS_PAID:
|
||||
$invoice->setIsPaid();
|
||||
break;
|
||||
}
|
||||
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
|
||||
* @Security("is_granted('history_invoice')")
|
||||
*/
|
||||
public function downloadAction(Invoice $invoice): Response
|
||||
{
|
||||
$file = $this->service->getInvoiceFile($invoice);
|
||||
|
||||
if (null === $file) {
|
||||
throw $this->createNotFoundException(
|
||||
sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
|
||||
);
|
||||
}
|
||||
|
||||
return $this->file($file->getRealPath(), $file->getBasename());
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"})
|
||||
* @Security("is_granted('history_invoice')")
|
||||
*/
|
||||
public function showInvoicesAction(Request $request, int $page): Response
|
||||
{
|
||||
$invoice = null;
|
||||
|
||||
if (null !== ($id = $request->get('id'))) {
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
}
|
||||
|
||||
$query = new InvoiceQuery();
|
||||
$query->setOrderBy('date');
|
||||
$query->setPage($page);
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
$invoices = $this->invoiceRepository->getPagerfantaForQuery($query);
|
||||
|
||||
return $this->render('invoice/listing.html.twig', [
|
||||
'entries' => $invoices,
|
||||
'query' => $query,
|
||||
'download' => $invoice,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportItemInterface[] $entries
|
||||
*/
|
||||
private function markEntriesAsExported(iterable $entries)
|
||||
{
|
||||
@@ -197,7 +308,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
/**
|
||||
* @param InvoiceQuery $query
|
||||
* @return InvoiceItemInterface[]
|
||||
* @return ExportItemInterface[]
|
||||
*/
|
||||
protected function getEntries(InvoiceQuery $query): array
|
||||
{
|
||||
@@ -246,6 +357,7 @@ final class InvoiceController extends AbstractController
|
||||
{
|
||||
$model = new InvoiceModel($this->formatter);
|
||||
$model
|
||||
->setInvoiceDate($this->dateTimeFactory->createDateTime())
|
||||
->setQuery($query)
|
||||
->setUser($this->getUser())
|
||||
->setCustomer($query->getCustomer())
|
||||
@@ -276,7 +388,7 @@ final class InvoiceController extends AbstractController
|
||||
*/
|
||||
public function listTemplateAction(): Response
|
||||
{
|
||||
$templates = $this->invoiceRepository->getPagerfantaForQuery(new BaseQuery());
|
||||
$templates = $this->templateRepository->getPagerfantaForQuery(new BaseQuery());
|
||||
|
||||
return $this->render('invoice/templates.html.twig', [
|
||||
'entries' => $templates,
|
||||
@@ -361,7 +473,7 @@ final class InvoiceController extends AbstractController
|
||||
*/
|
||||
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
|
||||
{
|
||||
if (!$this->invoiceRepository->hasTemplate()) {
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
$this->flashWarning('invoice.first_template');
|
||||
}
|
||||
|
||||
@@ -394,7 +506,7 @@ final class InvoiceController extends AbstractController
|
||||
public function deleteTemplate(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
try {
|
||||
$this->invoiceRepository->removeTemplate($template);
|
||||
$this->templateRepository->removeTemplate($template);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
|
||||
@@ -411,7 +523,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->invoiceRepository->saveTemplate($template);
|
||||
$this->templateRepository->saveTemplate($template);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
@@ -426,11 +538,13 @@ final class InvoiceController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getToolbarForm(InvoiceQuery $query, string $method): FormInterface
|
||||
protected function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface
|
||||
{
|
||||
return $this->createForm(InvoiceToolbarForm::class, $query, [
|
||||
$form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class;
|
||||
|
||||
return $this->createForm($form, $query, [
|
||||
'action' => $this->generateUrl('invoice', []),
|
||||
'method' => $method,
|
||||
'method' => 'GET',
|
||||
'include_user' => $this->isGranted('view_other_timesheet'),
|
||||
'attr' => [
|
||||
'id' => 'invoice-print-form'
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Form\Type\RoundingModeType;
|
||||
use App\Form\Type\SkinType;
|
||||
use App\Form\Type\TrackingModeType;
|
||||
use App\Form\Type\WeekDaysType;
|
||||
use App\Form\Type\YesNoType;
|
||||
use App\Repository\ConfigurationRepository;
|
||||
use App\Validator\Constraints\DateTimeFormat;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
@@ -275,6 +276,22 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setType(WeekDaysType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_FORM_INVOICE)
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('invoice.number_format')
|
||||
->setLabel('invoice.number_format')
|
||||
->setRequired(true)
|
||||
->setType(TextType::class) // TODO that should be a custom type with validation
|
||||
->setTranslationDomain('system-configuration'),
|
||||
(new Configuration())
|
||||
->setName('invoice.simple_form')
|
||||
->setLabel('simple_form')
|
||||
->setRequired(false)
|
||||
->setType(YesNoType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER)
|
||||
->setConfiguration([
|
||||
|
||||
@@ -224,6 +224,12 @@ class Configuration implements ConfigurationInterface
|
||||
->scalarPrototype()->end()
|
||||
->defaultValue([])
|
||||
->end()
|
||||
->booleanNode('simple_form')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->scalarNode('number_format')
|
||||
->defaultValue('{Y}/{cy,3}')
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
|
||||
290
src/Entity/Invoice.php
Normal file
290
src/Entity/Invoice.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Invoice\InvoiceModel;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* @ORM\Table(name="kimai2_invoices",
|
||||
* uniqueConstraints={
|
||||
* @ORM\UniqueConstraint(columns={"invoice_number"}),
|
||||
* @ORM\UniqueConstraint(columns={"invoice_filename"})
|
||||
* }
|
||||
* )
|
||||
* @UniqueEntity("invoiceNumber")
|
||||
* @UniqueEntity("invoiceFilename")
|
||||
*
|
||||
* @ORM\Entity(repositoryClass="App\Repository\InvoiceRepository")
|
||||
*/
|
||||
class Invoice
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_NEW = 'new';
|
||||
|
||||
/**
|
||||
* @var int|null
|
||||
*
|
||||
* @ORM\Column(name="id", type="integer")
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue(strategy="IDENTITY")
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="invoice_number", type="string", length=50, nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $invoiceNumber;
|
||||
|
||||
/**
|
||||
* @var Customer|null
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\Customer")
|
||||
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $customer;
|
||||
|
||||
/**
|
||||
* @var User|null
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\User")
|
||||
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
* @var \DateTime
|
||||
*
|
||||
* @ORM\Column(name="created_at", type="datetime", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $createdAt;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
|
||||
*/
|
||||
private $timezone;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="total", type="float", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $total = 0.00;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="tax", type="float", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $tax = 0.00;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="currency", type="string", length=3, nullable=false)
|
||||
* @Assert\Length(max=3)
|
||||
*/
|
||||
private $currency;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="due_days", type="integer", length=3, nullable=false)
|
||||
* @Assert\Range(min = 0, max = 999)
|
||||
*/
|
||||
private $dueDays = 30;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="vat", type="float", nullable=false)
|
||||
* @Assert\Range(min = 0.0, max = 99.99)
|
||||
*/
|
||||
private $vat = 0.00;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="status", type="string", length=20, nullable=false)
|
||||
*/
|
||||
private $status = self::STATUS_NEW;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="invoice_filename", type="string", length=100, nullable=false)
|
||||
*/
|
||||
private $invoiceFilename;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $localized = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCustomer(): ?Customer
|
||||
{
|
||||
return $this->customer;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getInvoiceNumber(): ?string
|
||||
{
|
||||
return $this->invoiceNumber;
|
||||
}
|
||||
|
||||
public function getTotal(): float
|
||||
{
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTime
|
||||
{
|
||||
if (!$this->localized) {
|
||||
if (null !== $this->createdAt && null !== $this->timezone) {
|
||||
$this->createdAt->setTimeZone(new \DateTimeZone($this->timezone));
|
||||
}
|
||||
|
||||
$this->localized = true;
|
||||
}
|
||||
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getDueDate(): ?\DateTime
|
||||
{
|
||||
if (null === $this->getCreatedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dueDate = clone $this->getCreatedAt();
|
||||
$dueDate->modify('+ ' . $this->dueDays . 'days');
|
||||
|
||||
return $dueDate;
|
||||
}
|
||||
|
||||
public function isOverdue(): bool
|
||||
{
|
||||
if (null === $this->getDueDate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getDueDate()->getTimestamp() < (new \DateTime('now', new \DateTimeZone($this->timezone)))->getTimestamp();
|
||||
}
|
||||
|
||||
public function setFilename(string $filename): Invoice
|
||||
{
|
||||
$this->invoiceFilename = $filename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setModel(InvoiceModel $model): Invoice
|
||||
{
|
||||
$this->customer = $model->getCustomer();
|
||||
$this->user = $model->getUser();
|
||||
$this->total = $model->getCalculator()->getTotal();
|
||||
$this->tax = $model->getCalculator()->getTax();
|
||||
$this->invoiceNumber = $model->getNumberGenerator()->getInvoiceNumber();
|
||||
$this->currency = $model->getCurrency();
|
||||
|
||||
$createdAt = $model->getInvoiceDate();
|
||||
$this->createdAt = $createdAt;
|
||||
$this->timezone = $createdAt->getTimezone()->getName();
|
||||
|
||||
$template = $model->getTemplate();
|
||||
$this->dueDays = $template->getDueDays();
|
||||
$this->vat = $template->getVat();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isNew(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_NEW;
|
||||
}
|
||||
|
||||
public function setIsNew(): Invoice
|
||||
{
|
||||
$this->status = self::STATUS_NEW;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function setIsPending(): Invoice
|
||||
{
|
||||
$this->status = self::STATUS_PENDING;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PAID;
|
||||
}
|
||||
|
||||
public function setIsPaid(): Invoice
|
||||
{
|
||||
$this->status = self::STATUS_PAID;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDueDays(): int
|
||||
{
|
||||
return $this->dueDays;
|
||||
}
|
||||
|
||||
public function getVat(): float
|
||||
{
|
||||
return $this->vat;
|
||||
}
|
||||
|
||||
public function getTax(): float
|
||||
{
|
||||
return $this->tax;
|
||||
}
|
||||
|
||||
public function getCurrency(): ?string
|
||||
{
|
||||
return $this->currency;
|
||||
}
|
||||
|
||||
public function getInvoiceFilename(): ?string
|
||||
{
|
||||
return $this->invoiceFilename;
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,8 @@ class InvoiceTemplate
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
// ---- trait methods below ---
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
|
||||
@@ -14,20 +14,6 @@ use App\Invoice\InvoiceItemInterface;
|
||||
|
||||
interface ExportItemInterface extends InvoiceItemInterface
|
||||
{
|
||||
/**
|
||||
* A name representation for this type of export.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string;
|
||||
|
||||
/**
|
||||
* A name representation for the category of this type.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getCategory(): string;
|
||||
|
||||
/**
|
||||
* Whether this item was already exported.
|
||||
*
|
||||
|
||||
@@ -13,6 +13,7 @@ class SystemConfiguration
|
||||
{
|
||||
public const SECTION_ROUNDING = 'rounding';
|
||||
public const SECTION_TIMESHEET = 'timesheet';
|
||||
public const SECTION_FORM_INVOICE = 'invoice';
|
||||
public const SECTION_FORM_CUSTOMER = 'form_customer';
|
||||
public const SECTION_FORM_USER = 'form_user';
|
||||
public const SECTION_THEME = 'theme';
|
||||
|
||||
@@ -45,6 +45,9 @@ class SystemConfigurationForm extends AbstractType
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
'csrf_token_id' => 'edit_system_configurations',
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.systemConfigUpdate'
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,53 +9,27 @@
|
||||
|
||||
namespace App\Form\Toolbar;
|
||||
|
||||
use App\Form\Type\InvoiceTemplateType;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Defines the form used for filtering timesheet entries for invoices.
|
||||
*/
|
||||
class InvoiceToolbarForm extends AbstractToolbarForm
|
||||
class InvoiceToolbarForm extends InvoiceToolbarSimpleForm
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
parent::buildForm($builder, $options);
|
||||
$this->addSearchTermInputField($builder);
|
||||
$this->addTemplateChoice($builder);
|
||||
if ($options['include_user']) {
|
||||
$this->addUsersChoice($builder);
|
||||
}
|
||||
$this->addDateRangeChoice($builder);
|
||||
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
|
||||
$this->addProjectChoice($builder, ['ignore_date' => true]);
|
||||
$this->addActivityChoice($builder);
|
||||
$this->addTagInputField($builder);
|
||||
$this->addExportStateChoice($builder);
|
||||
$builder->add('markAsExported', CheckboxType::class, [
|
||||
'label' => 'label.mark_as_exported',
|
||||
'required' => false,
|
||||
]);
|
||||
$builder->add('create', SubmitType::class, [
|
||||
'label' => 'button.print',
|
||||
'attr' => ['formtarget' => '_blank'],
|
||||
]);
|
||||
$builder->add('preview', SubmitType::class, [
|
||||
'label' => 'button.preview',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addTemplateChoice(FormBuilderInterface $builder)
|
||||
{
|
||||
$builder->add('template', InvoiceTemplateType::class, [
|
||||
'required' => true,
|
||||
'placeholder' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,10 +37,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => InvoiceQuery::class,
|
||||
'csrf_protection' => false,
|
||||
'include_user' => true,
|
||||
]);
|
||||
parent::configureOptions($resolver);
|
||||
$resolver->setDefault('include_user', true);
|
||||
}
|
||||
}
|
||||
|
||||
68
src/Form/Toolbar/InvoiceToolbarSimpleForm.php
Normal file
68
src/Form/Toolbar/InvoiceToolbarSimpleForm.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?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\Form\Toolbar;
|
||||
|
||||
use App\Form\Type\InvoiceTemplateType;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Defines the form used for filtering timesheet entries for invoices.
|
||||
*/
|
||||
class InvoiceToolbarSimpleForm extends AbstractToolbarForm
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$this->addTemplateChoice($builder);
|
||||
$this->addDateRangeChoice($builder);
|
||||
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
|
||||
$this->addProjectChoice($builder, ['ignore_date' => true]);
|
||||
$builder->add('markAsExported', CheckboxType::class, [
|
||||
'label' => 'label.mark_as_exported',
|
||||
'required' => false,
|
||||
]);
|
||||
$builder->add('create', SubmitType::class, [
|
||||
'label' => 'action.save',
|
||||
]);
|
||||
$builder->add('print', SubmitType::class, [
|
||||
'label' => 'button.preview_print',
|
||||
'attr' => ['formtarget' => '_blank'],
|
||||
]);
|
||||
$builder->add('preview', SubmitType::class, [
|
||||
'label' => 'button.preview',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addTemplateChoice(FormBuilderInterface $builder)
|
||||
{
|
||||
$builder->add('template', InvoiceTemplateType::class, [
|
||||
'required' => true,
|
||||
'placeholder' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => InvoiceQuery::class,
|
||||
'csrf_protection' => false,
|
||||
'include_user' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class InvoiceRendererType extends AbstractType
|
||||
foreach ($this->service->getRenderer() as $renderer) {
|
||||
if ($renderer->supports($document)) {
|
||||
$documents[$document->getId()] = $document->getName();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,13 +71,13 @@ class InvoiceRendererType extends AbstractType
|
||||
{
|
||||
$renderer = $label;
|
||||
|
||||
return ucfirst(
|
||||
substr(
|
||||
$renderer,
|
||||
1 + strrpos($renderer, '.'),
|
||||
strrpos($renderer, '.')
|
||||
)
|
||||
);
|
||||
$parts = explode('.', $renderer);
|
||||
|
||||
if (count($parts) > 2) {
|
||||
array_pop($parts);
|
||||
}
|
||||
|
||||
return ucfirst(array_pop($parts));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
namespace App\Invoice\Calculator;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Export\ExportItemInterface;
|
||||
use App\Invoice\InvoiceItem;
|
||||
use App\Invoice\InvoiceItemInterface;
|
||||
use App\Invoice\InvoiceItemWithAmountInterface;
|
||||
@@ -42,13 +41,8 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
|
||||
$amount = $entry->getAmount();
|
||||
}
|
||||
|
||||
$type = Timesheet::TYPE_TIMESHEET;
|
||||
$category = Timesheet::CATEGORY_WORK;
|
||||
|
||||
if ($entry instanceof ExportItemInterface) {
|
||||
$type = $entry->getType();
|
||||
$category = $entry->getCategory();
|
||||
}
|
||||
$type = $entry->getType();
|
||||
$category = $entry->getCategory();
|
||||
|
||||
if (null !== $invoiceItem->getType() && $type !== $invoiceItem->getType()) {
|
||||
$type = self::TYPE_MIXED;
|
||||
|
||||
@@ -47,14 +47,23 @@ abstract class AbstractSumInvoiceCalculator extends AbstractMergedCalculator imp
|
||||
}
|
||||
$invoiceItem = $invoiceItems[$id];
|
||||
$this->mergeInvoiceItems($invoiceItem, $entry);
|
||||
$this->mergeSumTimesheet($invoiceItem, $entry);
|
||||
$this->mergeSumInvoiceItem($invoiceItem, $entry);
|
||||
}
|
||||
|
||||
return array_values($invoiceItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 1.9 - use mergeSumInvoiceItem() instead
|
||||
*/
|
||||
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
{
|
||||
// allows to set values per calculator after merging the timesheet
|
||||
}
|
||||
|
||||
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
{
|
||||
@trigger_error('mergeSumTimesheet() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
|
||||
$this->mergeSumTimesheet($invoiceItem, $entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,19 +21,18 @@ class ActivityInvoiceCalculator extends AbstractSumInvoiceCalculator implements
|
||||
protected function calculateSumIdentifier(InvoiceItemInterface $invoiceItem): string
|
||||
{
|
||||
if (null === $invoiceItem->getActivity()) {
|
||||
throw new \Exception('Cannot work with invoice items that do not have an activity');
|
||||
}
|
||||
|
||||
if (null === $invoiceItem->getActivity()->getId()) {
|
||||
throw new \Exception('Cannot handle un-persisted activities');
|
||||
return '__NULL__';
|
||||
}
|
||||
|
||||
return (string) $invoiceItem->getActivity()->getId();
|
||||
}
|
||||
|
||||
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
{
|
||||
$invoiceItem->setActivity($entry->getActivity());
|
||||
if (null === $entry->getActivity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoiceItem->setDescription($entry->getActivity()->getName());
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class ProjectInvoiceCalculator extends AbstractSumInvoiceCalculator implements C
|
||||
return (string) $invoiceItem->getProject()->getId();
|
||||
}
|
||||
|
||||
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
{
|
||||
$invoiceItem->setProject($entry->getProject());
|
||||
$invoiceItem->setDescription($entry->getProject()->getName());
|
||||
|
||||
49
src/Invoice/InvoiceFilename.php
Normal file
49
src/Invoice/InvoiceFilename.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?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 Symfony\Component\String\UnicodeString;
|
||||
|
||||
final class InvoiceFilename
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $filename;
|
||||
|
||||
public function __construct(InvoiceModel $model)
|
||||
{
|
||||
$filename = $model->getNumberGenerator()->getInvoiceNumber();
|
||||
|
||||
$filename = str_replace(['/', '\\'], '-', $filename);
|
||||
|
||||
$company = $model->getCustomer()->getCompany();
|
||||
if (empty($company)) {
|
||||
$company = $model->getCustomer()->getName();
|
||||
}
|
||||
|
||||
if (!empty($company)) {
|
||||
$company = new UnicodeString($company);
|
||||
$filename .= '-' . $company->snake();
|
||||
}
|
||||
|
||||
$this->filename = $filename;
|
||||
}
|
||||
|
||||
public function getFilename()
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
return $this->getFilename();
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,6 @@ use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* @deprecated will be removed with 2.0 - use ExportItemInterface instead
|
||||
*/
|
||||
interface InvoiceItemInterface
|
||||
{
|
||||
public function getActivity(): ?Activity;
|
||||
@@ -43,4 +40,18 @@ interface InvoiceItemInterface
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
public function getVisibleMetaFields(): array;
|
||||
|
||||
/**
|
||||
* A name representation for this type of item.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string;
|
||||
|
||||
/**
|
||||
* A name representation for the category of this item.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getCategory(): string;
|
||||
}
|
||||
|
||||
@@ -190,14 +190,18 @@ final class InvoiceModel
|
||||
return new \DateTime('+' . $this->getTemplate()->getDueDays() . ' days');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getInvoiceDate(): \DateTime
|
||||
{
|
||||
return $this->invoiceDate;
|
||||
}
|
||||
|
||||
public function setInvoiceDate(\DateTime $date): InvoiceModel
|
||||
{
|
||||
$this->invoiceDate = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setNumberGenerator(NumberGeneratorInterface $generator): InvoiceModel
|
||||
{
|
||||
$this->generator = $generator;
|
||||
|
||||
142
src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php
Normal file
142
src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?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\NumberGenerator;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\NumberGeneratorInterface;
|
||||
use App\Repository\InvoiceRepository;
|
||||
|
||||
final class ConfigurableNumberGenerator implements NumberGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var InvoiceModel
|
||||
*/
|
||||
private $model;
|
||||
/**
|
||||
* @var InvoiceRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $format;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $number;
|
||||
|
||||
public function __construct(InvoiceRepository $repository, SystemConfiguration $configuration)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->format = $configuration->find('invoice.number_format');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InvoiceModel $model
|
||||
*/
|
||||
public function setModel(InvoiceModel $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getInvoiceNumber(): string
|
||||
{
|
||||
if (null !== $this->number) {
|
||||
return $this->number;
|
||||
}
|
||||
|
||||
$format = $this->format;
|
||||
$invoiceDate = $this->model->getInvoiceDate();
|
||||
$timestamp = $invoiceDate->getTimestamp();
|
||||
$result = $format;
|
||||
|
||||
preg_match_all('/{[^}]*?}/', $format, $matches);
|
||||
foreach ($matches[0] as $part) {
|
||||
$formatter = null;
|
||||
$tmp = str_replace(['{', '}'], '', $part);
|
||||
|
||||
// number format
|
||||
if (substr_count($tmp, ',') !== 0) {
|
||||
$formatter = explode(',', $tmp);
|
||||
$tmp = $formatter[0];
|
||||
$formatter = $formatter[1];
|
||||
}
|
||||
|
||||
switch ($tmp) {
|
||||
case 'Y':
|
||||
$partialResult = date('Y', $timestamp);
|
||||
break;
|
||||
|
||||
case 'y':
|
||||
$partialResult = date('y', $timestamp);
|
||||
break;
|
||||
|
||||
case 'M':
|
||||
$partialResult = date('m', $timestamp);
|
||||
break;
|
||||
|
||||
case 'm':
|
||||
$partialResult = date('n', $timestamp);
|
||||
break;
|
||||
|
||||
case 'D':
|
||||
$partialResult = date('d', $timestamp);
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
$partialResult = date('j', $timestamp);
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
$partialResult = date('ymd', $timestamp);
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
$partialResult = $this->repository->getCounterForAllTime($invoiceDate) + 1;
|
||||
break;
|
||||
|
||||
case 'cy':
|
||||
$partialResult = $this->repository->getCounterForYear($invoiceDate) + 1;
|
||||
break;
|
||||
|
||||
case 'cm':
|
||||
$partialResult = $this->repository->getCounterForMonth($invoiceDate) + 1;
|
||||
break;
|
||||
|
||||
case 'cd':
|
||||
$partialResult = $this->repository->getCounterForDay($invoiceDate) + 1;
|
||||
break;
|
||||
|
||||
default:
|
||||
$partialResult = $part;
|
||||
}
|
||||
|
||||
if (null !== $formatter) {
|
||||
$partialResult = str_pad($partialResult, $formatter, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$result = str_replace($part, $partialResult, $result);
|
||||
}
|
||||
|
||||
return $this->number = (string) $result;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class DateNumberGenerator implements NumberGeneratorInterface
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return 'default';
|
||||
return 'date';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
namespace App\Invoice\Renderer;
|
||||
|
||||
use App\Entity\InvoiceDocument;
|
||||
use App\Invoice\InvoiceFilename;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -47,19 +47,7 @@ abstract class AbstractRenderer
|
||||
|
||||
protected function buildFilename(InvoiceModel $model): string
|
||||
{
|
||||
$filename = $model->getNumberGenerator()->getInvoiceNumber();
|
||||
|
||||
$company = $model->getCustomer()->getCompany();
|
||||
if (empty($company)) {
|
||||
$company = $model->getCustomer()->getName();
|
||||
}
|
||||
|
||||
if (!empty($company)) {
|
||||
$company = new UnicodeString($company);
|
||||
$filename .= '-' . $company->snake();
|
||||
}
|
||||
|
||||
return $filename;
|
||||
return (string) new InvoiceFilename($model);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
60
src/Invoice/Renderer/PdfRenderer.php
Normal file
60
src/Invoice/Renderer/PdfRenderer.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?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\Renderer;
|
||||
|
||||
use App\Entity\InvoiceDocument;
|
||||
use App\Invoice\InvoiceFilename;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\RendererInterface;
|
||||
use App\Utils\HtmlToPdfConverter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Twig\Environment;
|
||||
|
||||
final class PdfRenderer implements RendererInterface
|
||||
{
|
||||
/**
|
||||
* @var Environment
|
||||
*/
|
||||
private $twig;
|
||||
/**
|
||||
* @var HtmlToPdfConverter
|
||||
*/
|
||||
private $converter;
|
||||
|
||||
public function __construct(Environment $twig, HtmlToPdfConverter $converter)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
$this->converter = $converter;
|
||||
}
|
||||
|
||||
public function supports(InvoiceDocument $document): bool
|
||||
{
|
||||
return stripos($document->getFilename(), '.pdf.twig') !== false;
|
||||
}
|
||||
|
||||
public function render(InvoiceDocument $document, InvoiceModel $model): Response
|
||||
{
|
||||
$content = $this->twig->render('@invoice/' . basename($document->getFilename()), [
|
||||
'model' => $model
|
||||
]);
|
||||
$content = $this->converter->convertToPdf($content);
|
||||
$filename = (string) new InvoiceFilename($model);
|
||||
|
||||
$response = new Response($content);
|
||||
|
||||
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename . '.pdf');
|
||||
|
||||
$response->headers->set('Content-Type', 'application/pdf');
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ final class TwigRenderer implements RendererInterface
|
||||
|
||||
$response = new Response();
|
||||
$response->setContent($content);
|
||||
$response->headers->set('Content-Type', 'text/html; charset=UTF-8');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceDocument;
|
||||
use App\Event\InvoicePostRenderEvent;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Utils\FileHelper;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* Service to manage invoice dependencies.
|
||||
@@ -37,10 +41,15 @@ final class ServiceInvoice
|
||||
* @var InvoiceDocumentRepository
|
||||
*/
|
||||
private $documents;
|
||||
/**
|
||||
* @var FileHelper
|
||||
*/
|
||||
private $fileHelper;
|
||||
|
||||
public function __construct(InvoiceDocumentRepository $repository)
|
||||
public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper)
|
||||
{
|
||||
$this->documents = $repository;
|
||||
$this->fileHelper = $fileHelper;
|
||||
}
|
||||
|
||||
public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice
|
||||
@@ -141,4 +150,62 @@ final class ServiceInvoice
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function getInvoicesDirectory(): string
|
||||
{
|
||||
return $this->fileHelper->getDataSubdirectory('invoices');
|
||||
}
|
||||
|
||||
public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo
|
||||
{
|
||||
$invoiceDirectory = $this->getInvoicesDirectory();
|
||||
$filename = $invoice->getInvoiceFilename();
|
||||
$full = $invoiceDirectory . $filename;
|
||||
|
||||
if (is_file($full) && is_readable($full)) {
|
||||
return new \SplFileInfo($full);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function saveGeneratedInvoice(InvoicePostRenderEvent $event): string
|
||||
{
|
||||
$invoiceDirectory = $this->getInvoicesDirectory();
|
||||
$filename = (string) new InvoiceFilename($event->getModel());
|
||||
|
||||
$response = $event->getResponse();
|
||||
|
||||
if ($event->getResponse()->headers->has('Content-Disposition')) {
|
||||
$disposition = $event->getResponse()->headers->get('Content-Disposition');
|
||||
$parts = explode(';', $disposition);
|
||||
foreach ($parts as $part) {
|
||||
if (stripos($part, 'filename=') === false) {
|
||||
continue;
|
||||
}
|
||||
$filename = explode('filename=', $part);
|
||||
if (count($filename) > 1) {
|
||||
$filename = $filename[1];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$disposition = $event->getResponse()->headers->get('Content-Type');
|
||||
$parts = explode(';', $disposition);
|
||||
$parts = explode('/', $parts[0]);
|
||||
$filename .= '.' . $parts[1];
|
||||
}
|
||||
|
||||
if (is_file($invoiceDirectory . $filename)) {
|
||||
throw new \Exception(sprintf('Invoice "%s" already exists', $filename));
|
||||
}
|
||||
|
||||
if ($response instanceof BinaryFileResponse) {
|
||||
$file = $response->getFile();
|
||||
$file->move($invoiceDirectory, $filename);
|
||||
} else {
|
||||
$this->fileHelper->saveFile($invoiceDirectory . $filename, $event->getResponse()->getContent());
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
}
|
||||
|
||||
55
src/Migrations/Version20200308171950.php
Normal file
55
src/Migrations/Version20200308171950.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* 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 DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 1.9
|
||||
*/
|
||||
final class Version20200308171950 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create the invoice table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$invoices = $schema->createTable('kimai2_invoices');
|
||||
|
||||
$invoices->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
|
||||
$invoices->addColumn('invoice_number', 'string', ['length' => 50, 'notnull' => true]);
|
||||
$invoices->addColumn('customer_id', 'integer', ['length' => 11, 'notnull' => true]);
|
||||
$invoices->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => true]);
|
||||
$invoices->addColumn('created_at', 'datetime', ['notnull' => true]);
|
||||
$invoices->addColumn('timezone', 'string', ['length' => 64, 'notnull' => true]);
|
||||
$invoices->addColumn('total', 'float', ['notnull' => true]);
|
||||
$invoices->addColumn('tax', 'float', ['notnull' => true]);
|
||||
$invoices->addColumn('currency', 'string', ['length' => 3, 'notnull' => true]);
|
||||
$invoices->addColumn('status', 'string', ['length' => 20, 'notnull' => true]);
|
||||
$invoices->addColumn('due_days', 'integer', ['length' => 11, 'notnull' => true]);
|
||||
$invoices->addColumn('vat', 'float', ['notnull' => true]);
|
||||
$invoices->addColumn('invoice_filename', 'string', ['length' => 100, 'notnull' => true]);
|
||||
$invoices->addUniqueIndex(['invoice_number'], 'UNIQ_76C38E372DA68207');
|
||||
$invoices->addUniqueIndex(['invoice_filename'], 'UNIQ_76C38E372323B33D');
|
||||
$invoices->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_76C38E37A76ED395');
|
||||
$invoices->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_76C38E379395C3F3');
|
||||
$invoices->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('kimai2_invoices');
|
||||
}
|
||||
}
|
||||
172
src/Repository/InvoiceRepository.php
Normal file
172
src/Repository/InvoiceRepository.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?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\Repository;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Loader\InvoiceLoader;
|
||||
use App\Repository\Paginator\LoaderPaginator;
|
||||
use App\Repository\Paginator\PaginatorInterface;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
|
||||
class InvoiceRepository extends EntityRepository
|
||||
{
|
||||
public function saveInvoice(Invoice $invoice)
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->persist($invoice);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
private function getCounterFor(\DateTime $start, \DateTime $end): int
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('count(i.createdAt) as counter')
|
||||
->from(Invoice::class, 'i')
|
||||
->andWhere($qb->expr()->gte('i.createdAt', ':start'))
|
||||
->andWhere($qb->expr()->lte('i.createdAt', ':end'))
|
||||
->setParameter('start', $start)
|
||||
->setParameter('end', $end)
|
||||
;
|
||||
|
||||
$result = $qb->getQuery()->getOneOrNullResult();
|
||||
|
||||
if ($result === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $result['counter'];
|
||||
}
|
||||
|
||||
public function getCounterForDay(\DateTime $date): int
|
||||
{
|
||||
$start = (clone $date)->setTime(0, 0, 0);
|
||||
$end = (clone $date)->setTime(23, 59, 59);
|
||||
|
||||
return $this->getCounterFor($start, $end);
|
||||
}
|
||||
|
||||
public function getCounterForMonth(\DateTime $date): int
|
||||
{
|
||||
$start = (clone $date)->setDate($date->format('Y'), $date->format('n'), 1)->setTime(0, 0, 0);
|
||||
$end = (clone $date)->setDate($date->format('Y'), $date->format('n'), $date->format('t'))->setTime(23, 59, 59);
|
||||
|
||||
return $this->getCounterFor($start, $end);
|
||||
}
|
||||
|
||||
public function getCounterForYear(\DateTime $date): int
|
||||
{
|
||||
$start = (clone $date)->setDate($date->format('Y'), 1, 1)->setTime(0, 0, 0);
|
||||
$end = (clone $date)->setDate($date->format('Y'), 12, 31)->setTime(23, 59, 59);
|
||||
|
||||
return $this->getCounterFor($start, $end);
|
||||
}
|
||||
|
||||
public function getCounterForAllTime(\DateTime $date): int
|
||||
{
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
|
||||
{
|
||||
// make sure that all queries without a user see all projects
|
||||
if (null === $user && empty($teams)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure that admins see all projects
|
||||
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$teams = array_merge($teams, $user->getTeams()->toArray());
|
||||
}
|
||||
|
||||
$qb
|
||||
->leftJoin('i.customer', 'c')
|
||||
->leftJoin('c.teams', 'c_teams');
|
||||
|
||||
if (empty($teams)) {
|
||||
$qb->andWhere($qb->expr()->isNull('c_teams'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$orCustomer = $qb->expr()->orX(
|
||||
$qb->expr()->isNull('c_teams'),
|
||||
$qb->expr()->isMemberOf(':teams', 'c.teams')
|
||||
);
|
||||
$qb->andWhere($orCustomer);
|
||||
|
||||
$qb->setParameter('teams', $teams);
|
||||
}
|
||||
|
||||
private function getQueryBuilderForQuery(InvoiceQuery $query): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb
|
||||
->select('i')
|
||||
->from(Invoice::class, 'i')
|
||||
;
|
||||
|
||||
$orderBy = $query->getOrderBy();
|
||||
switch ($orderBy) {
|
||||
case 'date':
|
||||
$orderBy = 'i.createdAt';
|
||||
break;
|
||||
}
|
||||
|
||||
$qb->addOrderBy($orderBy, $query->getOrder());
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getCurrentUser());
|
||||
|
||||
// this will make sure, that we do not accidentally create results with multiple rows
|
||||
// => which would result in a wrong LIMIT / pagination results
|
||||
// the second group by is needed due to SQL standard (even though logically not really required for this query)
|
||||
$qb->addGroupBy('i.id')->addGroupBy($orderBy);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function countInvoicesForQuery(InvoiceQuery $query): int
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
$qb
|
||||
->resetDQLPart('select')
|
||||
->resetDQLPart('orderBy')
|
||||
->resetDQLPart('groupBy')
|
||||
->select($qb->expr()->countDistinct('i.id'))
|
||||
;
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
protected function getPaginatorForQuery(InvoiceQuery $query): PaginatorInterface
|
||||
{
|
||||
$counter = $this->countInvoicesForQuery($query);
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
|
||||
return new LoaderPaginator(new InvoiceLoader($qb->getEntityManager()), $qb, $counter);
|
||||
}
|
||||
|
||||
public function getPagerfantaForQuery(InvoiceQuery $query): Pagerfanta
|
||||
{
|
||||
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
|
||||
$paginator->setMaxPerPage($query->getPageSize());
|
||||
$paginator->setCurrentPage($query->getPage());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
}
|
||||
55
src/Repository/Loader/InvoiceIdLoader.php
Normal file
55
src/Repository/Loader/InvoiceIdLoader.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\Repository\Loader;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InvoiceIdLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $entityManager;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ids
|
||||
*/
|
||||
public function loadResults(array $ids): void
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$em = $this->entityManager;
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$customer = $qb->select('PARTIAL i.{id}', 'customer')
|
||||
->from(Invoice::class, 'i')
|
||||
->leftJoin('i.customer', 'customer')
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$user = $qb->select('PARTIAL i.{id}', 'user')
|
||||
->from(Invoice::class, 'i')
|
||||
->leftJoin('i.user', 'user')
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
38
src/Repository/Loader/InvoiceLoader.php
Normal file
38
src/Repository/Loader/InvoiceLoader.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?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\Repository\Loader;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final class InvoiceLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var InvoiceIdLoader
|
||||
*/
|
||||
private $loader;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->loader = new InvoiceIdLoader($entityManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Invoice[] $invoices
|
||||
*/
|
||||
public function loadResults(array $invoices): void
|
||||
{
|
||||
$ids = array_map(function (Invoice $invoice) {
|
||||
return $invoice->getId();
|
||||
}, $invoices);
|
||||
|
||||
$this->loader->loadResults($ids);
|
||||
}
|
||||
}
|
||||
59
src/Utils/FileHelper.php
Normal file
59
src/Utils/FileHelper.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?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\Utils;
|
||||
|
||||
final class FileHelper
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $dataDir;
|
||||
|
||||
public function __construct(string $dataDir)
|
||||
{
|
||||
$this->dataDir = $dataDir;
|
||||
}
|
||||
|
||||
public function getDataSubdirectory(string $directory): string
|
||||
{
|
||||
$subDirectory = $this->dataDir . '/' . rtrim(ltrim($directory, '/'), '/') . '/';
|
||||
|
||||
$this->makeDir($subDirectory);
|
||||
|
||||
if (!is_dir($subDirectory)) {
|
||||
throw new \Exception(sprintf('Directory "%s" does not exist', $subDirectory));
|
||||
}
|
||||
|
||||
if (!is_writable($subDirectory)) {
|
||||
throw new \Exception(sprintf('Directory "%s" is not writable', $subDirectory));
|
||||
}
|
||||
|
||||
return $subDirectory;
|
||||
}
|
||||
|
||||
public function makeDir(string $directory)
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === @mkdir($directory)) {
|
||||
throw new \Exception(sprintf('Failed to create directory "%s", check file permissions', $directory));
|
||||
}
|
||||
}
|
||||
|
||||
public function saveFile(string $filename, $data)
|
||||
{
|
||||
$result = @file_put_contents($filename, $data);
|
||||
if ($result === false) {
|
||||
throw new \Exception('File "%s" could not be written');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user