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,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
]);
}
}

View File

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

View File

@@ -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([

View File

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

View File

@@ -170,6 +170,8 @@ class InvoiceTemplate
return $this->name;
}
// ---- trait methods below ---
public function getTitle(): ?string
{
return $this->title;

View File

@@ -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.
*

View File

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

View File

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

View File

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

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

View File

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

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

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

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

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

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