added invoice archive & configurable invoice numbers (#1541)

This commit is contained in:
Kevin Papst
2020-03-14 01:16:58 +01:00
committed by GitHub
parent dd64eb98f9
commit e6e6a1eeea
106 changed files with 2587 additions and 339 deletions

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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());
}

View File

@@ -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());

View 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();
}
}

View File

@@ -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;
}

View File

@@ -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;

View 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;
}
}

View File

@@ -28,7 +28,7 @@ class DateNumberGenerator implements NumberGeneratorInterface
*/
public function getId(): string
{
return 'default';
return 'date';
}
/**

View File

@@ -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);
}
/**

View 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;
}
}

View File

@@ -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;
}

View File

@@ -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;
}
}