added command to create invoices via bash (#1574)

This commit is contained in:
Kevin Papst
2020-03-20 13:14:05 +01:00
committed by GitHub
parent 7b13ea860a
commit b8c5323ece
35 changed files with 1301 additions and 273 deletions

View File

@@ -0,0 +1,468 @@
<?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\Command;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Invoice\ServiceInvoice;
use App\Repository\CustomerRepository;
use App\Repository\InvoiceTemplateRepository;
use App\Repository\Query\InvoiceQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Utils\SearchTerm;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
class InvoiceCreateCommand extends Command
{
/**
* @var ServiceInvoice
*/
private $serviceInvoice;
/**
* @var TimesheetRepository
*/
private $timesheetRepository;
/**
* @var CustomerRepository
*/
private $customerRepository;
/**
* @var InvoiceTemplateRepository
*/
private $invoiceTemplateRepository;
/**
* @var UserRepository
*/
private $userRepository;
/**
* @var EventDispatcherInterface
*/
private $eventDispatcher;
public function __construct(
ServiceInvoice $serviceInvoice,
TimesheetRepository $timesheetRepository,
CustomerRepository $customerRepository,
InvoiceTemplateRepository $invoiceTemplateRepository,
UserRepository $userRepository,
EventDispatcherInterface $eventDispatcher
) {
$this->serviceInvoice = $serviceInvoice;
$this->timesheetRepository = $timesheetRepository;
$this->customerRepository = $customerRepository;
$this->invoiceTemplateRepository = $invoiceTemplateRepository;
$this->userRepository = $userRepository;
$this->eventDispatcher = $eventDispatcher;
parent::__construct();
}
/**
* {@inheritdoc}
*/
protected function configure()
{
$this
->setName('kimai:invoice:create')
->setDescription('Create invoices')
->setHelp('This command allows to create invoices by several different filters.')
->addOption('user', null, InputOption::VALUE_REQUIRED, 'The user to be used for generating the invoices')
->addOption('start', null, InputOption::VALUE_OPTIONAL, 'Start date (format: 2020-01-01, default: start of the month)', null)
->addOption('end', null, InputOption::VALUE_OPTIONAL, 'End date (format: 2020-01-31, default: end of the month)', null)
->addOption('timezone', null, InputOption::VALUE_OPTIONAL, 'Timezone for start and end date query', date_default_timezone_get())
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'Comma separated list of customer IDs', null)
->addOption('by-customer', null, InputOption::VALUE_NONE, 'If set, one invoice for each active customer in the given timerange is created')
->addOption('by-project', null, InputOption::VALUE_NONE, 'If set, one invoice for each active project in the given timerange is created')
->addOption('set-exported', null, InputOption::VALUE_NONE, 'Whether the invoice items should be marked as exported')
->addOption('template', null, InputOption::VALUE_OPTIONAL, 'Invoice template', null)
->addOption('template-meta', null, InputOption::VALUE_OPTIONAL, 'Fetch invoice template from a meta-field', null)
->addOption('search', null, InputOption::VALUE_OPTIONAL, 'Search term to filter invoice entries', null)
->addOption('exported', null, InputOption::VALUE_OPTIONAL, 'Exported filter for invoice entries (possible values: exported, all), by default only "not exported" items are fetched', null)
;
}
/**
* {@inheritdoc}
*/
protected function execute(InputInterface $input, OutputInterface $output)
{
$io = new SymfonyStyle($input, $output);
// =============== VALIDATION START ===============
$username = $input->getOption('user');
if (empty($username)) {
$io->error('You must set a "user" to create invoices');
return 1;
}
$exportedFilter = TimesheetQuery::STATE_NOT_EXPORTED;
switch ($input->getOption('exported')) {
case null:
break;
case 'all':
$exportedFilter = TimesheetQuery::STATE_ALL;
break;
case 'exported':
$exportedFilter = TimesheetQuery::STATE_EXPORTED;
break;
default:
$io->error('Unknown "exported" filter given');
return 1;
}
$user = $this->userRepository->loadUserByUsername($username);
if (null === $user) {
$io->error(
sprintf('The given username "%s" could not be resolved', $username)
);
return 1;
}
$timezone = $input->getOption('timezone');
$timezone = new \DateTimeZone($timezone);
if (!empty($input->getOption('start')) && empty($input->getOption('end'))) {
$io->error('You need to supply a end date if a start date was given');
return 1;
}
$byActiveCustomer = $input->getOption('by-customer');
$byActiveProject = $input->getOption('by-project');
if ($byActiveCustomer && $byActiveProject) {
$io->error('You cannot mix "by-customer" and "by-project"');
return 1;
}
$customersIDs = $input->getOption('customer');
if (!$byActiveCustomer && !$byActiveProject && empty($customersIDs)) {
$io->error('Could not determine generation mode, you need to set one of: customer, by-customer, by-project');
return 1;
}
if (null === $input->getOption('template') && null === $input->getOption('template-meta')) {
$io->error('You must either pass the "template" or "template-meta" option');
return 1;
}
$start = $input->getOption('start');
if (!empty($start)) {
try {
$start = new \DateTime($start, $timezone);
} catch (\Exception $ex) {
$io->error('Invalid start date given');
return 1;
}
}
if (!$start instanceof \DateTime) {
$start = new \DateTime('first day of this month', $timezone);
}
$start->setTime(0, 0, 0);
$end = $input->getOption('end');
if (!empty($end)) {
try {
$end = new \DateTime($end, $timezone);
} catch (\Exception $ex) {
$io->error('Invalid end date given');
return 1;
}
}
if (!$end instanceof \DateTime) {
$end = new \DateTime('last day of this month', $timezone);
}
$end->setTime(23, 59, 59);
$searchTerm = null;
if (null !== $input->getOption('search')) {
$searchTerm = new SearchTerm($input->getOption('search'));
}
$markAsExported = false;
if ($input->getOption('set-exported')) {
$markAsExported = true;
}
// =============== VALIDATION END ===============
$defaultQuery = new InvoiceQuery();
$defaultQuery->setBegin($start);
$defaultQuery->setEnd($end);
$defaultQuery->setCurrentUser($user);
$defaultQuery->setSearchTerm($searchTerm);
$defaultQuery->setMarkAsExported($markAsExported);
$defaultQuery->setState($exportedFilter);
/** @var Invoice[] $invoices */
$invoices = [];
/** @var Customer[] $customers */
$customers = [];
if (!empty($customersIDs)) {
$customersIDs = explode(',', $customersIDs);
foreach ($customersIDs as $id) {
$tmp = $this->customerRepository->find($id);
if (null === $tmp) {
$io->error('Unknown customer ID: ' . $id);
return 1;
}
$customers[] = $tmp;
}
$invoices = $this->createInvoicesForCustomer($customers, $defaultQuery, $input, $output);
} elseif ($byActiveCustomer) {
$customers = $this->getActiveCustomers($start, $end);
$invoices = $this->createInvoicesForCustomer($customers, $defaultQuery, $input, $output);
} elseif ($byActiveProject) {
$projects = $this->getActiveProjects($start, $end);
$invoices = $this->createInvoicesForProjects($projects, $defaultQuery, $input, $output);
} else {
$io->error('Could not determine generation mode'); //-///9==8=//99/96//////-*/-*//96* <= by Ayumi
return 1;
}
return $this->renderInvoiceResult($input, $output, $invoices);
}
/**
* @param Project[] $projects
* @param InvoiceQuery $defaultQuery
* @param InputInterface $input
* @param OutputInterface $output
* @return Invoice[]
* @throws \Exception
*/
protected function createInvoicesForProjects(array $projects, InvoiceQuery $defaultQuery, InputInterface $input, OutputInterface $output): array
{
$io = new SymfonyStyle($input, $output);
/** @var Invoice[] $invoices */
$invoices = [];
foreach ($projects as $project) {
$query = clone $defaultQuery;
$query->addProject($project);
$query->addCustomer($project->getCustomer());
$tpl = $this->getTemplateForProject($input, $project);
if (null === $tpl) {
$io->warning(sprintf('Could not find invoice template for project "%s", skipping!', $project->getName()));
continue;
}
$query->setTemplate($tpl);
try {
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
} catch (\Exception $ex) {
$io->error(sprintf('Failed to create invoice for project "%s" with: %s', $project->getName(), $ex->getMessage()));
}
}
return $invoices;
}
/**
* @param Customer[] $customers
* @param InvoiceQuery $defaultQuery
* @param InputInterface $input
* @return Invoice[]
* @throws \Exception
*/
protected function createInvoicesForCustomer(array $customers, InvoiceQuery $defaultQuery, InputInterface $input, OutputInterface $output): array
{
$io = new SymfonyStyle($input, $output);
/** @var Invoice[] $invoices */
$invoices = [];
foreach ($customers as $customer) {
$query = clone $defaultQuery;
$query->addCustomer($customer);
$tpl = $this->getTemplateForCustomer($input, $customer);
if (null === $tpl) {
$io->warning(sprintf('Could not find invoice template for customer "%s", skipping!', $customer->getName()));
continue;
}
$query->setTemplate($tpl);
try {
$invoices[] = $this->serviceInvoice->createInvoice($query, $this->eventDispatcher);
} catch (\Exception $ex) {
$io->error(sprintf('Failed to create invoice for customer "%s" with: %s', $customer->getName(), $ex->getMessage()));
}
}
return $invoices;
}
/**
* @param InputInterface $input
* @param OutputInterface $output
* @param Invoice[] $invoices
* @return int
*/
protected function renderInvoiceResult(InputInterface $input, OutputInterface $output, array $invoices): int
{
$io = new SymfonyStyle($input, $output);
if (empty($invoices)) {
$io->warning('No invoice was generated');
return 0;
}
$columns = ['ID', 'Customer', 'Total', 'Filename'];
$table = new Table($output);
$table->setHeaderTitle(sprintf('Created %s invoice(s)', count($invoices)));
$table->setHeaders($columns);
foreach ($invoices as $invoice) {
$file = $this->serviceInvoice->getInvoiceFile($invoice);
if (null === $file) {
$io->warning(
sprintf('Created invoice with ID %s, but file was not found %s', $invoice->getId(), $invoice->getInvoiceFilename())
);
continue;
}
$table->addRow([
$invoice->getId(),
$invoice->getCustomer()->getName(),
$invoice->getTotal() . ' ' . $invoice->getCustomer()->getCurrency(),
$file->getRealPath()
]);
}
$table->render();
return 0;
}
private function getTemplateForCustomer(InputInterface $input, Customer $customer): ?InvoiceTemplate
{
$template = $input->getOption('template');
$meta = $input->getOption('template-meta');
if (!empty($meta)) {
$metaField = $customer->getMetaField($meta);
if (null !== $metaField && !empty($metaField->getValue())) {
$template = $metaField->getValue();
}
}
if (null === $template) {
return null;
}
return $this->findTemplate($template);
}
private function findTemplate(string $idOrName): ?InvoiceTemplate
{
$tpl = $this->invoiceTemplateRepository->find($idOrName);
if (null !== $tpl) {
return $tpl;
}
return $this->invoiceTemplateRepository->findOneBy(['name' => $idOrName]);
}
private function getTemplateForProject(InputInterface $input, Project $project): ?InvoiceTemplate
{
$template = $this->getTemplateForCustomer($input, $project->getCustomer());
$meta = $input->getOption('template-meta');
if (!empty($meta)) {
$metaField = $project->getMetaField($meta);
if (null !== $metaField && !empty($metaField->getValue())) {
$template = $metaField->getValue();
}
}
if (null === $template) {
return null;
}
return $this->findTemplate($template);
}
/**
* @param \DateTime $start
* @param \DateTime $end
* @return Customer[]
*/
private function getActiveCustomers(\DateTime $start, \DateTime $end): array
{
$query = new TimesheetQuery();
$query->setBegin($start);
$query->setEnd($end);
$results = $this->timesheetRepository->getTimesheetsForQuery($query);
$customers = [];
foreach ($results as $result) {
$customer = $result->getProject()->getCustomer();
$customers[$customer->getId()] = $customer;
}
return $customers;
}
/**
* @param \DateTime $start
* @param \DateTime $end
* @return Project[]
*/
private function getActiveProjects(\DateTime $start, \DateTime $end): array
{
$query = new TimesheetQuery();
$query->setBegin($start);
$query->setEnd($end);
$results = $this->timesheetRepository->getTimesheetsForQuery($query);
$projects = [];
foreach ($results as $result) {
$project = $result->getProject();
$projects[$project->getId()] = $project;
}
return $projects;
}
}

View File

@@ -12,15 +12,10 @@ 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\InvoiceModel;
use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
@@ -58,25 +53,20 @@ final class InvoiceController extends AbstractController
*/
private $dateTimeFactory;
/**
* @var InvoiceFormatter
* @var InvoiceRepository
*/
private $formatter;
private $invoiceRepository;
/**
* @var EventDispatcherInterface
*/
private $dispatcher;
/**
* @var InvoiceRepository
*/
private $invoiceRepository;
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher)
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, EventDispatcherInterface $dispatcher)
{
$this->service = $service;
$this->templateRepository = $templateRepository;
$this->invoiceRepository = $invoiceRepository;
$this->dateTimeFactory = $dateTimeFactory;
$this->formatter = $formatter;
$this->dispatcher = $dispatcher;
}
@@ -94,44 +84,50 @@ final class InvoiceController extends AbstractController
}
$showPreview = false;
$entries = [];
$model = null;
$query = $this->getDefaultQuery();
$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()) {
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']);
if ($this->isGranted('create_invoice') && $form->isValid()) {
try {
/** @var SubmitButton $createButton */
$createButton = $form->get('create');
if ($createButton->isClicked()) {
return $this->renderInvoice($query);
}
/** @var SubmitButton $previewButton */
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
$showPreview = true;
$entries = $this->getPreviewEntries($query);
/** @var SubmitButton $printButton */
$printButton = $form->get('print');
if ($printButton->isClicked()) {
return $this->service->renderInvoice($query, $this->dispatcher);
}
} catch (\Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
}
/** @var SubmitButton $previewButton */
$previewButton = $form->get('preview');
if ($previewButton->isClicked()) {
$showPreview = true;
}
}
$model = $this->prepareModel($query);
if (!empty($entries)) {
$model->addEntries($entries);
try {
$model = $this->service->createModel($query);
if ($showPreview) {
$entries = $this->service->findInvoiceItems($query);
if (!empty($entries)) {
$model->addEntries($entries);
}
}
} catch (\Exception $ex) {
$this->logException($ex);
$this->flashError($ex->getMessage());
$showPreview = false;
}
return $this->render('invoice/index.html.twig', [
@@ -164,54 +160,23 @@ final class InvoiceController extends AbstractController
return $query;
}
protected function renderInvoice(InvoiceQuery $query, bool $saveInvoice = false)
protected function renderInvoice(InvoiceQuery $query)
{
$entries = $this->getEntries($query);
$model = $this->prepareModel($query);
foreach ($entries as $repo => $items) {
$model->addEntries($items);
}
try {
$invoice = $this->service->createInvoice($query, $this->dispatcher);
$document = $this->service->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
}
$this->flashSuccess('action.update.success');
foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$this->dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
$response = $renderer->render($document, $model);
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;
if ($this->isGranted('history_invoice')) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
}
}
$this->flashError(
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
$file = $this->service->getInvoiceFile($invoice);
return $this->file($file->getRealPath(), $file->getBasename());
} catch (\Exception $ex) {
$this->flashError($ex->getMessage());
}
return $this->redirectToRoute('invoice');
}
@@ -222,26 +187,12 @@ final class InvoiceController extends AbstractController
*/
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');
try {
$this->service->changeInvoiceStatus($invoice, $status);
} catch (\InvalidArgumentException $ex) {
throw $this->createNotFoundException($ex->getMessage());
}
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');
@@ -290,98 +241,6 @@ final class InvoiceController extends AbstractController
]);
}
/**
* @param ExportItemInterface[] $entries
*/
private function markEntriesAsExported(iterable $entries)
{
$repositories = $this->service->getInvoiceItemRepositories();
foreach ($entries as $repo => $items) {
foreach ($repositories as $repository) {
if (get_class($repository) === $repo) {
$repository->setExported($items);
}
}
}
}
/**
* @param InvoiceQuery $query
* @return ExportItemInterface[]
*/
protected function getEntries(InvoiceQuery $query): array
{
// customer needs to be defined, as we need the currency for the invoice
if (!$query->hasCustomers()) {
return [];
}
if (null === $query->getBegin()) {
$query->setBegin($this->dateTimeFactory->createDateTime('first day of this month'));
}
if (null === $query->getEnd()) {
$query->setEnd($this->dateTimeFactory->createDateTime('last day of this month'));
}
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$repositories = $this->service->getInvoiceItemRepositories();
$items = [];
foreach ($repositories as $repository) {
$items[get_class($repository)] = $repository->getInvoiceItemsForQuery($query);
}
return $items;
}
protected function getPreviewEntries(InvoiceQuery $query): array
{
$entries = [];
$temp = $this->getEntries($query);
foreach ($temp as $repo => $items) {
$entries = array_merge($entries, $items);
}
return $entries;
}
/**
* @param InvoiceQuery $query
* @return InvoiceModel
* @throws \Exception
*/
protected function prepareModel(InvoiceQuery $query): InvoiceModel
{
$model = new InvoiceModel($this->formatter);
$model
->setInvoiceDate($this->dateTimeFactory->createDateTime())
->setQuery($query)
->setUser($this->getUser())
->setCustomer($query->getCustomer())
;
if ($query->getTemplate() !== null) {
$generator = $this->service->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if (null === $generator) {
throw new \Exception('Unknown number generator: ' . $query->getTemplate()->getNumberGenerator());
}
$calculator = $this->service->getCalculatorByName($query->getTemplate()->getCalculator());
if (null === $calculator) {
throw new \Exception('Unknown invoice calculator: ' . $query->getTemplate()->getCalculator());
}
$model->setTemplate($query->getTemplate());
$model->setCalculator($calculator);
$model->setNumberGenerator($generator);
}
return $model;
}
/**
* @Route(path="/template", name="admin_invoice_template", methods={"GET", "POST"})
* @Security("is_granted('manage_invoice_template')")
@@ -478,22 +337,10 @@ final class InvoiceController extends AbstractController
}
$template = new InvoiceTemplate();
if (null !== $copyFrom) {
$template
->setName('Copy of ' . $copyFrom->getName())
->setTitle($copyFrom->getTitle())
->setDueDays($copyFrom->getDueDays())
->setCalculator($copyFrom->getCalculator())
->setVat($copyFrom->getVat())
->setRenderer($copyFrom->getRenderer())
->setCompany($copyFrom->getCompany())
->setPaymentTerms($copyFrom->getPaymentTerms())
->setAddress($copyFrom->getAddress())
->setNumberGenerator($copyFrom->getNumberGenerator())
->setContact($copyFrom->getContact())
->setPaymentDetails($copyFrom->getPaymentDetails())
->setVatId($copyFrom->getVatId())
;
$template = clone $copyFrom;
$template->setName('Copy of ' . $copyFrom->getName());
}
return $this->renderTemplateForm($template, $request);

View File

@@ -213,7 +213,7 @@ class Invoice
$this->user = $model->getUser();
$this->total = $model->getCalculator()->getTotal();
$this->tax = $model->getCalculator()->getTax();
$this->invoiceNumber = $model->getNumberGenerator()->getInvoiceNumber();
$this->invoiceNumber = $model->getInvoiceNumber();
$this->currency = $model->getCurrency();
$createdAt = $model->getInvoiceDate();

View File

@@ -347,4 +347,11 @@ class InvoiceTemplate
{
return $this->getName();
}
public function __clone()
{
if ($this->id) {
$this->id = null;
}
}
}

View File

@@ -0,0 +1,31 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Event;
use App\Entity\Invoice;
use Symfony\Contracts\EventDispatcher\Event;
final class InvoiceCreatedEvent extends Event
{
/**
* @var Invoice
*/
private $invoice;
public function __construct(Invoice $invoice)
{
$this->invoice = $invoice;
}
public function getInvoice(): Invoice
{
return $this->invoice;
}
}

View File

@@ -28,6 +28,7 @@ class InvoiceTemplateType extends AbstractType
$resolver->setDefaults([
'label' => 'label.template',
'class' => InvoiceTemplate::class,
'choice_label' => 'name',
'query_builder' => function (InvoiceTemplateRepository $repository) {
return $repository->getQueryBuilderForFormType();
}

View File

@@ -36,6 +36,14 @@ class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(),
'customer.comment' => $customer->getComment(),
'customer.email' => $customer->getEmail(),
'customer.fax' => $customer->getFax(),
'customer.phone' => $customer->getPhone(),
'customer.mobile' => $customer->getMobile(),
// budget
// remaining budget?
// time-budget
// remaining time-budget?
];
foreach ($customer->getVisibleMetaFields() as $metaField) {

View File

@@ -25,7 +25,7 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
'invoice.number' => $model->getInvoiceNumber(),
'invoice.currency' => $currency,
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(),

View File

@@ -20,7 +20,7 @@ final class InvoiceFilename
public function __construct(InvoiceModel $model)
{
$filename = $model->getNumberGenerator()->getInvoiceNumber();
$filename = $model->getInvoiceNumber();
$filename = str_replace(['/', '\\'], '-', $filename);

View File

@@ -70,6 +70,10 @@ final class InvoiceModel
* @var InvoiceItemHydrator[]
*/
private $itemHydrator = [];
/**
* @var string
*/
private $invoiceNumber;
public function __construct(InvoiceFormatter $formatter)
{
@@ -103,7 +107,9 @@ final class InvoiceModel
}
/**
* Do not use this method for rendering the invoice, use InvoiceModel::getCalculator()->getEntries() instead.
* Returns the raw data from the model.
*
* Do not use this method for rendering the invoice, use getItems() instead.
*
* @return InvoiceItemInterface[]
*/
@@ -202,6 +208,19 @@ final class InvoiceModel
return $this;
}
public function getInvoiceNumber(): string
{
if (null === $this->generator) {
throw new \Exception('InvoiceModel::getInvoiceNumber() cannot be called before calling setNumberGenerator()');
}
if (null === $this->invoiceNumber) {
$this->invoiceNumber = $this->generator->getInvoiceNumber();
}
return $this->invoiceNumber;
}
public function setNumberGenerator(NumberGeneratorInterface $generator): InvoiceModel
{
$this->generator = $generator;
@@ -210,6 +229,9 @@ final class InvoiceModel
return $this;
}
/**
* @deprecated since 1.9 - will be removed with 2.0 - use getInvoiceNumber() instead
*/
public function getNumberGenerator(): ?NumberGeneratorInterface
{
return $this->generator;

View File

@@ -28,10 +28,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
* @var string
*/
private $format;
/**
* @var string
*/
private $number;
public function __construct(InvoiceRepository $repository, SystemConfiguration $configuration)
{
@@ -60,10 +56,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
*/
public function getInvoiceNumber(): string
{
if (null !== $this->number) {
return $this->number;
}
$format = $this->format;
$invoiceDate = $this->model->getInvoiceDate();
$timestamp = $invoiceDate->getTimestamp();
@@ -137,6 +129,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
$result = str_replace($part, $partialResult, $result);
}
return $this->number = (string) $result;
return (string) $result;
}
}

View File

@@ -11,10 +11,17 @@ namespace App\Invoice;
use App\Entity\Invoice;
use App\Entity\InvoiceDocument;
use App\Event\InvoiceCreatedEvent;
use App\Event\InvoicePostRenderEvent;
use App\Event\InvoicePreRenderEvent;
use App\Repository\InvoiceDocumentRepository;
use App\Repository\InvoiceRepository;
use App\Repository\Query\InvoiceQuery;
use App\Timesheet\UserDateTimeFactory;
use App\Utils\FileHelper;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
/**
* Service to manage invoice dependencies.
@@ -45,11 +52,26 @@ final class ServiceInvoice
* @var FileHelper
*/
private $fileHelper;
/**
* @var UserDateTimeFactory
*/
private $dateTimeFactory;
/**
* @var InvoiceFormatter
*/
private $formatter;
/**
* @var InvoiceRepository
*/
private $invoiceRepository;
public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper)
public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter)
{
$this->documents = $repository;
$this->fileHelper = $fileHelper;
$this->invoiceRepository = $invoiceRepository;
$this->dateTimeFactory = $dateTimeFactory;
$this->formatter = $formatter;
}
public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice
@@ -153,7 +175,7 @@ final class ServiceInvoice
private function getInvoicesDirectory(): string
{
return $this->fileHelper->getDataSubdirectory('invoices');
return $this->fileHelper->getDataDirectory('invoices');
}
public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo
@@ -208,4 +230,209 @@ final class ServiceInvoice
return $filename;
}
public function changeInvoiceStatus(Invoice $invoice, string $status)
{
if (!in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) {
throw new \InvalidArgumentException('Unknown 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);
}
/**
* @param InvoiceQuery $query
* @return InvoiceItemInterface[]
*/
private function findInvoiceItemsWithRepository(InvoiceQuery $query): array
{
// customer needs to be defined, as we need the currency for the invoice
if (!$query->hasCustomers()) {
return [];
}
if (null === $query->getBegin()) {
$query->setBegin($this->dateTimeFactory->createDateTime('first day of this month'));
}
if (null === $query->getEnd()) {
$query->setEnd($this->dateTimeFactory->createDateTime('last day of this month'));
}
$query->getBegin()->setTime(0, 0, 0);
$query->getEnd()->setTime(23, 59, 59);
$repositories = $this->getInvoiceItemRepositories();
$items = [];
foreach ($repositories as $repository) {
$items[get_class($repository)] = $repository->getInvoiceItemsForQuery($query);
}
return $items;
}
/**
* @param InvoiceQuery $query
* @return InvoiceItemInterface[]
*/
public function findInvoiceItems(InvoiceQuery $query): array
{
$entries = [];
$temp = $this->findInvoiceItemsWithRepository($query);
foreach ($temp as $repo => $items) {
$entries = array_merge($entries, $items);
}
return $entries;
}
/**
* @param InvoiceItemInterface[] $entries
*/
private function markEntriesAsExported(iterable $entries)
{
$repositories = $this->getInvoiceItemRepositories();
foreach ($entries as $repo => $items) {
foreach ($repositories as $repository) {
if (get_class($repository) === $repo) {
$repository->setExported($items);
}
}
}
}
public function renderInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Response
{
$entries = $this->findInvoiceItemsWithRepository($query);
$model = $this->createModel($query);
foreach ($entries as $repo => $items) {
$model->addEntries($items);
}
$document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
}
foreach ($this->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
$response = $renderer->render($document, $model);
$dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response));
return $response;
}
}
throw new \Exception(
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
}
/**
* @param InvoiceQuery $query
* @param EventDispatcherInterface $dispatcher
* @return Invoice
* @throws \Exception
*/
public function createInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Invoice
{
$entries = $this->findInvoiceItemsWithRepository($query);
$model = $this->createModel($query);
foreach ($entries as $repo => $items) {
$model->addEntries($items);
}
$document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
}
foreach ($this->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
$response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) {
$this->markEntriesAsExported($entries);
}
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
$dispatcher->dispatch($event);
$invoiceFilename = $this->saveGeneratedInvoice($event);
$invoice = new Invoice();
$invoice->setModel($model);
$invoice->setFilename($invoiceFilename);
$this->invoiceRepository->saveInvoice($invoice);
$dispatcher->dispatch(new InvoiceCreatedEvent($invoice));
return $invoice;
}
}
throw new \Exception(
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
);
}
/**
* @param InvoiceQuery $query
* @return InvoiceModel
* @throws \Exception
*/
public function createModel(InvoiceQuery $query): InvoiceModel
{
$model = new InvoiceModel($this->formatter);
$model
->setInvoiceDate($this->dateTimeFactory->createDateTime())
->setQuery($query)
;
if (null !== $query->getCurrentUser()) {
$model->setUser($query->getCurrentUser());
}
if ($query->hasCustomers()) {
$model->setCustomer($query->getCustomers()[0]);
}
if ($query->getTemplate() !== null) {
$generator = $this->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if (null === $generator) {
throw new \Exception('Unknown number generator: ' . $query->getTemplate()->getNumberGenerator());
}
$calculator = $this->getCalculatorByName($query->getTemplate()->getCalculator());
if (null === $calculator) {
throw new \Exception('Unknown invoice calculator: ' . $query->getTemplate()->getCalculator());
}
$model->setTemplate($query->getTemplate());
$model->setCalculator($calculator);
$model->setNumberGenerator($generator);
}
return $model;
}
}

View File

@@ -9,51 +9,53 @@
namespace App\Utils;
use Symfony\Component\Filesystem\Filesystem;
final class FileHelper
{
/**
* @var string
*/
private $dataDir;
/**
* @var Filesystem
*/
private $filesystem;
public function __construct(string $dataDir)
{
$this->dataDir = $dataDir;
$this->filesystem = new Filesystem();
}
public function getDataSubdirectory(string $directory): string
public function getDataDirectory(string $subDirectory = null): string
{
$subDirectory = $this->dataDir . '/' . rtrim(ltrim($directory, '/'), '/') . '/';
$directory = $this->dataDir . '/';
$this->makeDir($subDirectory);
if (!is_dir($subDirectory)) {
throw new \Exception(sprintf('Directory "%s" does not exist', $subDirectory));
if (!empty($subDirectory)) {
$directory .= rtrim(ltrim($subDirectory, '/'), '/') . '/';
}
if (!is_writable($subDirectory)) {
throw new \Exception(sprintf('Directory "%s" is not writable', $subDirectory));
$this->makeDir($directory);
if (!is_dir($directory)) {
throw new \Exception(sprintf('Directory "%s" does not exist', $directory));
}
return $subDirectory;
if (!is_writable($directory)) {
throw new \Exception(sprintf('Directory "%s" is not writable', $directory));
}
return $directory;
}
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));
}
$this->filesystem->mkdir($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');
}
$this->filesystem->dumpFile($filename, $data);
}
}