create multiple invoices at once (#2465)

This commit is contained in:
Kevin Papst
2021-03-27 20:47:53 +01:00
committed by GitHub
parent 87d07ffaaf
commit f8a5ff7315
34 changed files with 754 additions and 378 deletions

View File

@@ -19,8 +19,8 @@ use App\Repository\InvoiceTemplateRepository;
use App\Repository\ProjectRepository;
use App\Repository\Query\InvoiceQuery;
use App\Repository\Query\TimesheetQuery;
use App\Repository\TimesheetRepository;
use App\Repository\UserRepository;
use App\Timesheet\DateTimeFactory;
use App\Utils\SearchTerm;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Helper\Table;
@@ -39,10 +39,6 @@ class InvoiceCreateCommand extends Command
* @var ServiceInvoice
*/
private $serviceInvoice;
/**
* @var TimesheetRepository
*/
private $timesheetRepository;
/**
* @var CustomerRepository
*/
@@ -70,7 +66,6 @@ class InvoiceCreateCommand extends Command
public function __construct(
ServiceInvoice $serviceInvoice,
TimesheetRepository $timesheetRepository,
CustomerRepository $customerRepository,
ProjectRepository $projectRepository,
InvoiceTemplateRepository $invoiceTemplateRepository,
@@ -78,7 +73,6 @@ class InvoiceCreateCommand extends Command
EventDispatcherInterface $eventDispatcher
) {
$this->serviceInvoice = $serviceInvoice;
$this->timesheetRepository = $timesheetRepository;
$this->customerRepository = $customerRepository;
$this->projectRepository = $projectRepository;
$this->invoiceTemplateRepository = $invoiceTemplateRepository;
@@ -99,7 +93,7 @@ class InvoiceCreateCommand extends Command
->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('timezone', null, InputOption::VALUE_OPTIONAL, 'Timezone for start and end date query (fallback: users timezone)', null)
->addOption('customer', null, InputOption::VALUE_OPTIONAL, 'Comma separated list of customer IDs', null)
->addOption('project', null, InputOption::VALUE_OPTIONAL, 'Comma separated list of project IDs', null)
->addOption('by-customer', null, InputOption::VALUE_NONE, 'If set, one invoice for each active customer in the given timerange is created')
@@ -157,7 +151,13 @@ class InvoiceCreateCommand extends Command
return 1;
}
$timezone = new \DateTimeZone($input->getOption('timezone'));
$timezone = $input->getOption('timezone');
if ($timezone === null) {
$timezone = $user->getTimezone();
}
$timezone = new \DateTimeZone($timezone);
$dateFactory = new DateTimeFactory($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');
@@ -191,7 +191,7 @@ class InvoiceCreateCommand extends Command
$start = $input->getOption('start');
if (!empty($start)) {
try {
$start = new \DateTime($start, $timezone);
$start = $dateFactory->createDateTime($start);
} catch (\Exception $ex) {
$io->error('Invalid start date given');
@@ -199,14 +199,14 @@ class InvoiceCreateCommand extends Command
}
}
if (!$start instanceof \DateTime) {
$start = new \DateTime('first day of this month', $timezone);
$start = $dateFactory->getStartOfMonth();
}
$start->setTime(0, 0, 0);
$end = $input->getOption('end');
if (!empty($end)) {
try {
$end = new \DateTime($end, $timezone);
$end = $dateFactory->createDateTime($end);
} catch (\Exception $ex) {
$io->error('Invalid end date given');
@@ -214,7 +214,7 @@ class InvoiceCreateCommand extends Command
}
}
if (!$end instanceof \DateTime) {
$end = new \DateTime('last day of this month', $timezone);
$end = $dateFactory->getEndOfMonth();
}
$end->setTime(23, 59, 59);
@@ -519,7 +519,7 @@ class InvoiceCreateCommand extends Command
*/
private function getActiveCustomers(InvoiceQuery $invoiceQuery): array
{
$results = $this->timesheetRepository->getTimesheetsForQuery($invoiceQuery);
$results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
$customers = [];
@@ -537,7 +537,7 @@ class InvoiceCreateCommand extends Command
*/
private function getActiveProjects(InvoiceQuery $invoiceQuery): array
{
$results = $this->timesheetRepository->getTimesheetsForQuery($invoiceQuery);
$results = $this->serviceInvoice->getInvoiceItems($invoiceQuery);
$projects = [];

View File

@@ -209,10 +209,8 @@ abstract class AbstractController extends BaseAbstractController implements Serv
$form->submit($submitData, false);
if (!$form->isValid()) {
$data->resetByFormError($form->getErrors());
}
if ($request->query->has('setDefaultQuery')) {
$data->resetByFormError($form->getErrors(true));
} elseif ($request->query->has('setDefaultQuery')) {
$params = [];
foreach ($form->all() as $name => $child) {
$params[$name] = $child->getViewData();

View File

@@ -10,6 +10,7 @@
namespace App\Controller;
use App\Configuration\SystemConfiguration;
use App\Entity\Customer;
use App\Entity\Invoice;
use App\Entity\InvoiceTemplate;
use App\Export\Spreadsheet\AnnotatedObjectExporter;
@@ -30,7 +31,6 @@ use App\Repository\Query\InvoiceQuery;
use Exception;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
use Symfony\Component\Form\FormInterface;
use Symfony\Component\Form\SubmitButton;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
@@ -83,45 +83,30 @@ final class InvoiceController extends AbstractController
$this->flashWarning('invoice.first_template');
}
$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->handleSearch($form, $request)) {
return $this->redirectToRoute('invoice');
}
if ($this->isGranted('create_invoice') && $form->isValid()) {
// use the current request locale as fallback, if no translation was configured
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
$query->getTemplate()->setLanguage($request->getLocale());
}
$models = [];
$total = 0;
$searched = false;
try {
/** @var SubmitButton $createButton */
$createButton = $form->get('create');
if ($createButton->isClicked()) {
return $this->renderInvoice($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()) {
if ($form->isValid() && $this->isGranted('create_invoice')) {
if ($request->query->has('createInvoice')) {
try {
$model = $this->service->createModel($query);
$entries = $this->service->findInvoiceItems($query);
if (!empty($entries)) {
$model->addEntries($entries);
}
return $this->renderInvoice($query, $request);
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
}
}
if ($form->get('template')->getData() !== null) {
try {
$models = $this->service->createModels($query);
$searched = true;
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError($ex->getMessage());
@@ -129,14 +114,73 @@ final class InvoiceController extends AbstractController
}
}
foreach ($models as $model) {
$total += \count($model->getCalculator()->getEntries());
}
return $this->render('invoice/index.html.twig', [
'query' => $query,
'model' => $model,
'models' => $models,
'form' => $form->createView(),
'limit_preview' => ($total > 500),
'searched' => $searched,
]);
}
protected function getDefaultQuery(): InvoiceQuery
/**
* @Route(path="/preview/{customer}/{template}", name="invoice_preview", methods={"GET"})
* @Security("is_granted('view_invoice')")
*/
public function previewAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->submit($request->query->all(), false);
if ($form->isValid() && $this->isGranted('create_invoice')) {
try {
$query->setTemplate($template);
$query->setCustomers([$customer]);
$model = $this->service->createModel($query);
return $this->service->renderInvoiceWithModel($model, $this->dispatcher);
} catch (Exception $ex) {
$this->logException($ex);
$this->flashError('action.update.error', ['%reason%' => 'Failed generating invoice preview: ' . $ex->getMessage()]);
}
}
return $this->redirectToRoute('invoice');
}
/**
* @Route(path="/save-invoice/{customer}/{template}", name="invoice_create", methods={"GET"})
* @Security("is_granted('view_invoice')")
*/
public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response
{
if (!$this->templateRepository->hasTemplate()) {
return $this->redirectToRoute('invoice');
}
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
$form->submit($request->query->all(), false);
if ($form->isValid() && $this->isGranted('create_invoice')) {
$query->setTemplate($template);
$query->setCustomers([$customer]);
return $this->renderInvoice($query, $request);
}
return $this->redirectToRoute('invoice');
}
private function getDefaultQuery(): InvoiceQuery
{
$factory = $this->getDateTimeFactory();
$begin = $factory->getStartOfMonth();
@@ -159,20 +203,23 @@ final class InvoiceController extends AbstractController
return $query;
}
protected function renderInvoice(InvoiceQuery $query)
private function renderInvoice(InvoiceQuery $query, Request $request)
{
// use the current request locale as fallback, if no translation was configured
if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) {
$query->getTemplate()->setLanguage($request->getLocale());
}
try {
$invoice = $this->service->createInvoice($query, $this->dispatcher);
$invoices = $this->service->createInvoices($query, $this->dispatcher);
$this->flashSuccess('action.update.success');
if ($this->isGranted('history_invoice')) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
if (\count($invoices) === 1) {
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]);
}
$file = $this->service->getInvoiceFile($invoice);
return $this->file($file->getRealPath(), $file->getBasename());
return $this->redirectToRoute('admin_invoice_list');
} catch (Exception $ex) {
$this->flashUpdateException($ex);
}
@@ -182,7 +229,6 @@ final class InvoiceController extends AbstractController
/**
* @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
{
@@ -198,7 +244,6 @@ final class InvoiceController extends AbstractController
/**
* @Route(path="/delete/{id}", name="admin_invoice_delete", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/
public function deleteInvoiceAction(Invoice $invoice): Response
{
@@ -214,7 +259,6 @@ final class InvoiceController extends AbstractController
/**
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
* @Security("is_granted('history_invoice')")
*/
public function downloadAction(Invoice $invoice): Response
{
@@ -231,7 +275,6 @@ final class InvoiceController extends AbstractController
/**
* @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
{
@@ -380,10 +423,6 @@ final class InvoiceController extends AbstractController
*/
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
{
if (!$this->templateRepository->hasTemplate()) {
$this->flashWarning('invoice.first_template');
}
$template = new InvoiceTemplate();
if (null !== $copyFrom) {
@@ -410,7 +449,7 @@ final class InvoiceController extends AbstractController
return $this->redirectToRoute('admin_invoice_template');
}
protected function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response
{
$editForm = $this->createEditForm($template);
@@ -433,7 +472,7 @@ final class InvoiceController extends AbstractController
]);
}
protected function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface
private function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface
{
$form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class;

View File

@@ -30,15 +30,13 @@ class InvoiceSubscriber extends AbstractActionsSubscriber
return;
}
if ($this->isGranted('history_invoice')) {
if ($invoice->isNew()) {
$event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending'])]);
} elseif ($invoice->isPending()) {
$event->addAction('invoice.paid', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'paid'])]);
}
$event->addAction('download', ['url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']);
$event->addDelete($this->path('admin_invoice_delete', ['id' => $invoice->getId()]), false);
if ($invoice->isNew()) {
$event->addAction('invoice.pending', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'pending'])]);
} elseif ($invoice->isPending()) {
$event->addAction('invoice.paid', ['url' => $this->path('admin_invoice_status', ['id' => $invoice->getId(), 'status' => 'paid'])]);
}
$event->addAction('download', ['url' => $this->path('admin_invoice_download', ['id' => $invoice->getId()]), 'target' => '_blank']);
$event->addDelete($this->path('admin_invoice_delete', ['id' => $invoice->getId()]), false);
}
}

View File

@@ -22,9 +22,7 @@ class InvoicesSubscriber extends AbstractActionsSubscriber
{
$event->addColumnToggle('#modal_invoice');
if ($this->isGranted('history_invoice')) {
$event->addAction('list', ['url' => $this->path('admin_invoice_list')]);
}
$event->addAction('list', ['url' => $this->path('admin_invoice_list')]);
if ($this->isGranted('manage_invoice_template')) {
$event->addAction('invoice-template', ['url' => $this->path('admin_invoice_template')]);

View File

@@ -50,6 +50,10 @@ final class EnhancedChoiceTypeExtension extends AbstractTypeExtension
$extendedOptions = ['class' => 'selectpicker'];
if ($options['multiple']) {
$extendedOptions['size'] = 1;
}
if (false !== $options['width']) {
$extendedOptions['data-width'] = $options['width'];
}

View File

@@ -12,7 +12,6 @@ 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;
@@ -28,22 +27,12 @@ class InvoiceToolbarSimpleForm extends AbstractToolbarForm
{
$this->addTemplateChoice($builder);
$this->addDateRange($builder, ['timezone' => $options['timezone']]);
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => ''], true);
$this->addCustomerMultiChoice($builder, ['required' => false, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => ''], true);
$this->addProjectMultiChoice($builder, ['ignore_date' => true], false, 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)

View File

@@ -21,21 +21,9 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$formatter = $model->getFormatter();
$entries = $model->getCalculator()->getEntries();
$begin = null;
if ($model->getQuery()->getBegin() !== null) {
$begin = $model->getQuery()->getBegin();
} elseif (!empty($entries)) {
$begin = $entries[0];
}
$end = null;
if ($model->getQuery()->getEnd() !== null) {
$end = $model->getQuery()->getEnd();
} elseif (!empty($entries)) {
$end = array_keys($entries)[\count($entries) - 1];
}
$begin = $model->getQuery()->getBegin();
$end = $model->getQuery()->getEnd();
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),

View File

@@ -90,7 +90,9 @@ final class ServiceInvoice
{
foreach ($this->getNumberGenerator() as $generator) {
if ($generator->getId() === $name) {
return $generator;
// several models can co-exist at the same time and NumberGeneratorInterface works
// with setModel() instead of __construct() - returning the same instance would lead to bugs!
return clone $generator;
}
}
@@ -116,7 +118,9 @@ final class ServiceInvoice
{
foreach ($this->getCalculator() as $calculator) {
if ($calculator->getId() === $name) {
return $calculator;
// several models can co-exist at the same time and CalculatorInterface works
// with setModel() instead of __construct() - returning the same instance would lead to bugs!
return clone $calculator;
}
}
@@ -258,50 +262,33 @@ final class ServiceInvoice
/**
* @param InvoiceQuery $query
* @return array<string, InvoiceItemInterface[]>
* @return InvoiceItemInterface[]
*/
private function findInvoiceItemsWithRepository(InvoiceQuery $query): array
public function findInvoiceItems(InvoiceQuery $query): array
{
@trigger_error('Using findInvoiceItems() is deprecated since 1.14 and will be removed with 2.0', E_USER_DEPRECATED);
// customer needs to be defined, as we need the currency for the invoice
if (!$query->hasCustomers()) {
return [];
}
$factory = $this->getDateTimeFactory($query);
if (null === $query->getBegin()) {
$query->setBegin($factory->getStartOfMonth());
}
if (null === $query->getEnd()) {
$query->setEnd($factory->getEndOfMonth());
}
$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;
return $this->getInvoiceItems($query);
}
/**
* @param InvoiceQuery $query
* @return InvoiceItemInterface[]
*/
public function findInvoiceItems(InvoiceQuery $query): array
public function getInvoiceItems(InvoiceQuery $query): array
{
$entries = [];
$temp = $this->findInvoiceItemsWithRepository($query);
$items = [];
foreach ($temp as $repo => $items) {
$entries = array_merge($entries, $items);
foreach ($this->getInvoiceItemRepositories() as $repository) {
$items = array_merge($items, $repository->getInvoiceItemsForQuery($query));
}
return $entries;
return $items;
}
private function getDateTimeFactory(InvoiceQuery $query): DateTimeFactory
@@ -316,29 +303,17 @@ final class ServiceInvoice
}
/**
* @param array<string, InvoiceItemInterface[]> $entries
* @param InvoiceItemInterface[] $entries
*/
private function markEntriesAsExported(iterable $entries)
private function markEntriesAsExported(array $entries)
{
$repositories = $this->getInvoiceItemRepositories();
foreach ($entries as $repo => $items) {
foreach ($repositories as $repository) {
if (\get_class($repository) === $repo) {
$repository->setExported($items);
}
}
foreach ($this->getInvoiceItemRepositories() as $repository) {
$repository->setExported($entries);
}
}
public function renderInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Response
public function renderInvoiceWithModel(InvoiceModel $model, 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());
@@ -361,20 +336,21 @@ final class ServiceInvoice
);
}
public function renderInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Response
{
$model = $this->createModel($query);
return $this->renderInvoiceWithModel($model, $dispatcher);
}
/**
* @param InvoiceQuery $query
* @param InvoiceModel $model
* @param EventDispatcherInterface $dispatcher
* @return Invoice
* @throws \Exception
*/
public function createInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Invoice
public function createInvoiceFromModel(InvoiceModel $model, 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());
@@ -386,8 +362,8 @@ final class ServiceInvoice
$response = $renderer->render($document, $model);
if ($query->isMarkAsExported()) {
$this->markEntriesAsExported($entries);
if ($model->getQuery()->isMarkAsExported()) {
$this->markEntriesAsExported($model->getEntries());
}
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
@@ -411,6 +387,37 @@ final class ServiceInvoice
);
}
/**
* @param InvoiceQuery $query
* @param EventDispatcherInterface $dispatcher
* @return Invoice[]
* @throws \Exception
*/
public function createInvoices(InvoiceQuery $query, EventDispatcherInterface $dispatcher): array
{
$invoices = [];
$models = $this->createModels($query);
foreach ($models as $model) {
$invoices[] = $this->createInvoiceFromModel($model, $dispatcher);
}
return $invoices;
}
/**
* @param InvoiceQuery $query
* @param EventDispatcherInterface $dispatcher
* @return Invoice
* @throws \Exception
*/
public function createInvoice(InvoiceQuery $query, EventDispatcherInterface $dispatcher): Invoice
{
$model = $this->createModel($query);
return $this->createInvoiceFromModel($model, $dispatcher);
}
public function deleteInvoice(Invoice $invoice)
{
$invoiceDirectory = $this->getInvoicesDirectory();
@@ -426,9 +433,23 @@ final class ServiceInvoice
* @throws \Exception
*/
public function createModel(InvoiceQuery $query): InvoiceModel
{
$model = $this->createModelWithoutEntries($query);
$model->addEntries($this->getInvoiceItems($query));
$this->prepareModelQueryDates($model);
return $model;
}
private function createModelWithoutEntries(InvoiceQuery $query): InvoiceModel
{
$template = $query->getTemplate();
if (!$query->hasCustomers()) {
throw new \Exception('Cannot create invoice model without customer');
}
if (null === $template) {
throw new \Exception('Cannot create invoice model without template');
}
@@ -449,9 +470,7 @@ final class ServiceInvoice
$model->setUser($query->getCurrentUser());
}
if ($query->hasCustomers()) {
$model->setCustomer($query->getCustomers()[0]);
}
$model->setCustomer($query->getCustomers()[0]);
$generator = $this->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if (null === $generator) {
@@ -468,4 +487,95 @@ final class ServiceInvoice
return $model;
}
private function prepareModelQueryDates(InvoiceModel $model)
{
$begin = $model->getQuery()->getBegin();
$end = $model->getQuery()->getEnd();
if ($begin !== null && $end !== null) {
return;
}
if (\count($model->getEntries()) === 0) {
return;
}
$tmpBegin = null;
$tmpEnd = null;
foreach ($model->getEntries() as $entry) {
if ($begin === null) {
if ($tmpBegin === null) {
$tmpBegin = $entry->getBegin();
} else {
$tmpBegin = min($entry->getBegin(), $tmpBegin);
}
}
if ($end === null) {
if ($tmpEnd === null) {
$tmpEnd = $entry->getEnd();
} else {
$tmpEnd = max($entry->getEnd(), $tmpEnd);
}
}
}
if ($begin === null && $tmpBegin !== null) {
$model->getQuery()->setBegin($tmpBegin);
}
if ($end === null && $tmpEnd !== null) {
$model->getQuery()->setEnd($tmpEnd);
}
}
/**
* @param InvoiceQuery $query
* @return InvoiceModel[]
* @throws \Exception
*/
public function createModels(InvoiceQuery $query): array
{
$models = [];
$customerEntries = [];
$items = $this->getInvoiceItems($query);
foreach ($items as $entry) {
$customer = $entry->getProject()->getCustomer();
$id = $customer->getId();
if (!\array_key_exists($id, $customerEntries)) {
$customerEntries[$id] = [
'customer' => $customer,
'entries' => [],
];
}
$customerEntries[$id]['entries'][] = $entry;
}
if (empty($customerEntries)) {
return [];
}
uasort($customerEntries, function ($a, $b) {
return strcmp($a['customer']->getName(), $b['customer']->getName());
});
foreach ($customerEntries as $id => $settings) {
if (empty($settings['entries'])) {
continue;
}
$customerQuery = clone $query;
$customerQuery->setCustomers([$settings['customer']]);
$model = $this->createModelWithoutEntries($customerQuery);
$model->addEntries($settings['entries']);
$this->prepareModelQueryDates($model);
$models[] = $model;
}
return $models;
}
}

View File

@@ -32,7 +32,7 @@ final class TimesheetInvoiceItemRepository implements InvoiceItemRepositoryInter
*/
public function getInvoiceItemsForQuery(InvoiceQuery $query): iterable
{
return $this->repository->getTimesheetsForQuery($query);
return $this->repository->getTimesheetsForQuery($query, true);
}
/**

View File

@@ -27,6 +27,7 @@ use App\Repository\Paginator\PaginatorInterface;
use App\Repository\Query\TimesheetQuery;
use DateInterval;
use DateTime;
use Doctrine\DBAL\Types\Types;
use Doctrine\ORM\AbstractQuery;
use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\Expr\Join;
@@ -34,7 +35,6 @@ use Doctrine\ORM\QueryBuilder;
use Exception;
use InvalidArgumentException;
use Pagerfanta\Pagerfanta;
use PDO;
/**
* @extends \Doctrine\ORM\EntityRepository<Timesheet>
@@ -837,15 +837,15 @@ class TimesheetRepository extends EntityRepository
}
if ($query->isExported()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', true, PDO::PARAM_BOOL);
$qb->andWhere('t.exported = :exported')->setParameter('exported', true, Types::BOOLEAN);
} elseif ($query->isNotExported()) {
$qb->andWhere('t.exported = :exported')->setParameter('exported', false, PDO::PARAM_BOOL);
$qb->andWhere('t.exported = :exported')->setParameter('exported', false, Types::BOOLEAN);
}
if ($query->isBillable()) {
$qb->andWhere('t.billable = :billable')->setParameter('billable', true, PDO::PARAM_BOOL);
$qb->andWhere('t.billable = :billable')->setParameter('billable', true, Types::BOOLEAN);
} elseif ($query->isNotBillable()) {
$qb->andWhere('t.billable = :billable')->setParameter('billable', false, PDO::PARAM_BOOL);
$qb->andWhere('t.billable = :billable')->setParameter('billable', false, Types::BOOLEAN);
}
if (null !== $query->getModifiedAfter()) {
@@ -942,7 +942,7 @@ class TimesheetRepository extends EntityRepository
->groupBy('a.id', 'p.id')
->orderBy('maxid', 'DESC')
->setMaxResults($limit)
->setParameter('visible', true, PDO::PARAM_BOOL)
->setParameter('visible', true, Types::BOOLEAN)
;
if (null !== $user) {
@@ -974,7 +974,7 @@ class TimesheetRepository extends EntityRepository
}
/**
* @param Timesheet[] $timesheets
* @param Timesheet[]|int[] $timesheets
*/
public function setExported(array $timesheets)
{
@@ -987,7 +987,7 @@ class TimesheetRepository extends EntityRepository
->update(Timesheet::class, 't')
->set('t.exported', ':exported')
->where($qb->expr()->in('t.id', ':ids'))
->setParameter('exported', true, PDO::PARAM_BOOL)
->setParameter('exported', true, Types::BOOLEAN)
->setParameter('ids', $timesheets)
->getQuery()
->execute();