added invoice archive & configurable invoice numbers (#1541)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user