added command to create invoices via bash (#1574)
This commit is contained in:
@@ -16,10 +16,10 @@ Perform EACH version specific task between your version and the new one, otherwi
|
|||||||
- The default invoice number format changed. If you want to use the old one: configure `{date}` as format - see [invoice documentation](https://www.kimai.org/documentation/invoices.html)
|
- The default invoice number format changed. If you want to use the old one: configure `{date}` as format - see [invoice documentation](https://www.kimai.org/documentation/invoices.html)
|
||||||
- HTML invoice templates are now treated like other files and offered as download. If you are using relative URLs for including
|
- HTML invoice templates are now treated like other files and offered as download. If you are using relative URLs for including
|
||||||
assets (CSS, images) you need to either inline them (see the default templates) or use absolute URLs.
|
assets (CSS, images) you need to either inline them (see the default templates) or use absolute URLs.
|
||||||
- Invoice templates that use query values `activity.X` or `project.X` should be checked, as multi-select is now possible for filtering
|
- Invoice templates that use the templates variables `${activity.X}` or `${project.X}` should be checked and possibly adapted, as multi-select is now possible for filtering
|
||||||
|
|
||||||
Permission changes:
|
Permission changes:
|
||||||
- `history_invoice` - NEW: grants all features of the new invoice archive (by default for all admins)
|
- `history_invoice` - NEW: grants all features for the new invoice archive (by default for all admins)
|
||||||
|
|
||||||
### Developer
|
### Developer
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,6 @@
|
|||||||
# See https://symfony.com/doc/current/cookbook/testing/http_authentication.html
|
# See https://symfony.com/doc/current/cookbook/testing/http_authentication.html
|
||||||
security:
|
security:
|
||||||
encoders:
|
encoders:
|
||||||
# to make tests much faster, BCrypt cost is changed to its minimum allowed value (4)
|
|
||||||
# See https://symfony.com/doc/current/reference/configuration/security.html#using-the-bcrypt-password-encoder
|
|
||||||
App\Entity\User: { algorithm: auto }
|
App\Entity\User: { algorithm: auto }
|
||||||
|
|
||||||
firewalls:
|
firewalls:
|
||||||
|
|||||||
468
src/Command/InvoiceCreateCommand.php
Normal file
468
src/Command/InvoiceCreateCommand.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,15 +12,10 @@ namespace App\Controller;
|
|||||||
use App\Configuration\SystemConfiguration;
|
use App\Configuration\SystemConfiguration;
|
||||||
use App\Entity\Invoice;
|
use App\Entity\Invoice;
|
||||||
use App\Entity\InvoiceTemplate;
|
use App\Entity\InvoiceTemplate;
|
||||||
use App\Event\InvoicePostRenderEvent;
|
|
||||||
use App\Event\InvoicePreRenderEvent;
|
|
||||||
use App\Export\ExportItemInterface;
|
|
||||||
use App\Form\InvoiceDocumentUploadForm;
|
use App\Form\InvoiceDocumentUploadForm;
|
||||||
use App\Form\InvoiceTemplateForm;
|
use App\Form\InvoiceTemplateForm;
|
||||||
use App\Form\Toolbar\InvoiceToolbarForm;
|
use App\Form\Toolbar\InvoiceToolbarForm;
|
||||||
use App\Form\Toolbar\InvoiceToolbarSimpleForm;
|
use App\Form\Toolbar\InvoiceToolbarSimpleForm;
|
||||||
use App\Invoice\InvoiceFormatter;
|
|
||||||
use App\Invoice\InvoiceModel;
|
|
||||||
use App\Invoice\ServiceInvoice;
|
use App\Invoice\ServiceInvoice;
|
||||||
use App\Repository\InvoiceDocumentRepository;
|
use App\Repository\InvoiceDocumentRepository;
|
||||||
use App\Repository\InvoiceRepository;
|
use App\Repository\InvoiceRepository;
|
||||||
@@ -58,25 +53,20 @@ final class InvoiceController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
private $dateTimeFactory;
|
private $dateTimeFactory;
|
||||||
/**
|
/**
|
||||||
* @var InvoiceFormatter
|
* @var InvoiceRepository
|
||||||
*/
|
*/
|
||||||
private $formatter;
|
private $invoiceRepository;
|
||||||
/**
|
/**
|
||||||
* @var EventDispatcherInterface
|
* @var EventDispatcherInterface
|
||||||
*/
|
*/
|
||||||
private $dispatcher;
|
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->service = $service;
|
||||||
$this->templateRepository = $templateRepository;
|
$this->templateRepository = $templateRepository;
|
||||||
$this->invoiceRepository = $invoiceRepository;
|
$this->invoiceRepository = $invoiceRepository;
|
||||||
$this->dateTimeFactory = $dateTimeFactory;
|
$this->dateTimeFactory = $dateTimeFactory;
|
||||||
$this->formatter = $formatter;
|
|
||||||
$this->dispatcher = $dispatcher;
|
$this->dispatcher = $dispatcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,44 +84,50 @@ final class InvoiceController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$showPreview = false;
|
$showPreview = false;
|
||||||
$entries = [];
|
$model = null;
|
||||||
|
|
||||||
$query = $this->getDefaultQuery();
|
$query = $this->getDefaultQuery();
|
||||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||||
$form->setData($query);
|
$form->setData($query);
|
||||||
$form->submit($request->query->all(), false);
|
$form->submit($request->query->all(), false);
|
||||||
|
|
||||||
if ($this->isGranted('create_invoice')) {
|
if ($this->isGranted('create_invoice') && $form->isValid()) {
|
||||||
if ($form->isValid()) {
|
try {
|
||||||
try {
|
/** @var SubmitButton $createButton */
|
||||||
/** @var SubmitButton $createButton */
|
$createButton = $form->get('create');
|
||||||
$createButton = $form->get('create');
|
if ($createButton->isClicked()) {
|
||||||
if ($createButton->isClicked()) {
|
return $this->renderInvoice($query);
|
||||||
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 */
|
/** @var SubmitButton $printButton */
|
||||||
$previewButton = $form->get('preview');
|
$printButton = $form->get('print');
|
||||||
if ($previewButton->isClicked()) {
|
if ($printButton->isClicked()) {
|
||||||
$showPreview = true;
|
return $this->service->renderInvoice($query, $this->dispatcher);
|
||||||
$entries = $this->getPreviewEntries($query);
|
|
||||||
}
|
}
|
||||||
|
} 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);
|
try {
|
||||||
if (!empty($entries)) {
|
$model = $this->service->createModel($query);
|
||||||
$model->addEntries($entries);
|
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', [
|
return $this->render('invoice/index.html.twig', [
|
||||||
@@ -164,54 +160,23 @@ final class InvoiceController extends AbstractController
|
|||||||
return $query;
|
return $query;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function renderInvoice(InvoiceQuery $query, bool $saveInvoice = false)
|
protected function renderInvoice(InvoiceQuery $query)
|
||||||
{
|
{
|
||||||
$entries = $this->getEntries($query);
|
try {
|
||||||
$model = $this->prepareModel($query);
|
$invoice = $this->service->createInvoice($query, $this->dispatcher);
|
||||||
foreach ($entries as $repo => $items) {
|
|
||||||
$model->addEntries($items);
|
|
||||||
}
|
|
||||||
|
|
||||||
$document = $this->service->getDocumentByName($model->getTemplate()->getRenderer());
|
$this->flashSuccess('action.update.success');
|
||||||
if (null === $document) {
|
|
||||||
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ($this->service->getRenderer() as $renderer) {
|
if ($this->isGranted('history_invoice')) {
|
||||||
if ($renderer->supports($document)) {
|
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
|
||||||
$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;
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
$this->flashError(
|
$file = $this->service->getInvoiceFile($invoice);
|
||||||
sprintf('Cannot render invoice: %s (%s)', $model->getTemplate()->getRenderer(), $document->getName())
|
|
||||||
);
|
return $this->file($file->getRealPath(), $file->getBasename());
|
||||||
|
} catch (\Exception $ex) {
|
||||||
|
$this->flashError($ex->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('invoice');
|
return $this->redirectToRoute('invoice');
|
||||||
}
|
}
|
||||||
@@ -222,26 +187,12 @@ final class InvoiceController extends AbstractController
|
|||||||
*/
|
*/
|
||||||
public function changeStatusAction(Invoice $invoice, string $status): Response
|
public function changeStatusAction(Invoice $invoice, string $status): Response
|
||||||
{
|
{
|
||||||
if (!in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) {
|
try {
|
||||||
throw $this->createNotFoundException('Unknwon invoice status');
|
$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');
|
$this->flashSuccess('action.update.success');
|
||||||
|
|
||||||
return $this->redirectToRoute('admin_invoice_list');
|
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"})
|
* @Route(path="/template", name="admin_invoice_template", methods={"GET", "POST"})
|
||||||
* @Security("is_granted('manage_invoice_template')")
|
* @Security("is_granted('manage_invoice_template')")
|
||||||
@@ -478,22 +337,10 @@ final class InvoiceController extends AbstractController
|
|||||||
}
|
}
|
||||||
|
|
||||||
$template = new InvoiceTemplate();
|
$template = new InvoiceTemplate();
|
||||||
|
|
||||||
if (null !== $copyFrom) {
|
if (null !== $copyFrom) {
|
||||||
$template
|
$template = clone $copyFrom;
|
||||||
->setName('Copy of ' . $copyFrom->getName())
|
$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())
|
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->renderTemplateForm($template, $request);
|
return $this->renderTemplateForm($template, $request);
|
||||||
|
|||||||
@@ -213,7 +213,7 @@ class Invoice
|
|||||||
$this->user = $model->getUser();
|
$this->user = $model->getUser();
|
||||||
$this->total = $model->getCalculator()->getTotal();
|
$this->total = $model->getCalculator()->getTotal();
|
||||||
$this->tax = $model->getCalculator()->getTax();
|
$this->tax = $model->getCalculator()->getTax();
|
||||||
$this->invoiceNumber = $model->getNumberGenerator()->getInvoiceNumber();
|
$this->invoiceNumber = $model->getInvoiceNumber();
|
||||||
$this->currency = $model->getCurrency();
|
$this->currency = $model->getCurrency();
|
||||||
|
|
||||||
$createdAt = $model->getInvoiceDate();
|
$createdAt = $model->getInvoiceDate();
|
||||||
|
|||||||
@@ -347,4 +347,11 @@ class InvoiceTemplate
|
|||||||
{
|
{
|
||||||
return $this->getName();
|
return $this->getName();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function __clone()
|
||||||
|
{
|
||||||
|
if ($this->id) {
|
||||||
|
$this->id = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
src/Event/InvoiceCreatedEvent.php
Normal file
31
src/Event/InvoiceCreatedEvent.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,7 @@ class InvoiceTemplateType extends AbstractType
|
|||||||
$resolver->setDefaults([
|
$resolver->setDefaults([
|
||||||
'label' => 'label.template',
|
'label' => 'label.template',
|
||||||
'class' => InvoiceTemplate::class,
|
'class' => InvoiceTemplate::class,
|
||||||
|
'choice_label' => 'name',
|
||||||
'query_builder' => function (InvoiceTemplateRepository $repository) {
|
'query_builder' => function (InvoiceTemplateRepository $repository) {
|
||||||
return $repository->getQueryBuilderForFormType();
|
return $repository->getQueryBuilderForFormType();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,14 @@ class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
|
|||||||
'customer.country' => $customer->getCountry(),
|
'customer.country' => $customer->getCountry(),
|
||||||
'customer.homepage' => $customer->getHomepage(),
|
'customer.homepage' => $customer->getHomepage(),
|
||||||
'customer.comment' => $customer->getComment(),
|
'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) {
|
foreach ($customer->getVisibleMetaFields() as $metaField) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
|
|||||||
$values = [
|
$values = [
|
||||||
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
|
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
|
||||||
'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
|
'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
|
||||||
'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
|
'invoice.number' => $model->getInvoiceNumber(),
|
||||||
'invoice.currency' => $currency,
|
'invoice.currency' => $currency,
|
||||||
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
|
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
|
||||||
'invoice.vat' => $model->getCalculator()->getVat(),
|
'invoice.vat' => $model->getCalculator()->getVat(),
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ final class InvoiceFilename
|
|||||||
|
|
||||||
public function __construct(InvoiceModel $model)
|
public function __construct(InvoiceModel $model)
|
||||||
{
|
{
|
||||||
$filename = $model->getNumberGenerator()->getInvoiceNumber();
|
$filename = $model->getInvoiceNumber();
|
||||||
|
|
||||||
$filename = str_replace(['/', '\\'], '-', $filename);
|
$filename = str_replace(['/', '\\'], '-', $filename);
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,10 @@ final class InvoiceModel
|
|||||||
* @var InvoiceItemHydrator[]
|
* @var InvoiceItemHydrator[]
|
||||||
*/
|
*/
|
||||||
private $itemHydrator = [];
|
private $itemHydrator = [];
|
||||||
|
/**
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private $invoiceNumber;
|
||||||
|
|
||||||
public function __construct(InvoiceFormatter $formatter)
|
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[]
|
* @return InvoiceItemInterface[]
|
||||||
*/
|
*/
|
||||||
@@ -202,6 +208,19 @@ final class InvoiceModel
|
|||||||
return $this;
|
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
|
public function setNumberGenerator(NumberGeneratorInterface $generator): InvoiceModel
|
||||||
{
|
{
|
||||||
$this->generator = $generator;
|
$this->generator = $generator;
|
||||||
@@ -210,6 +229,9 @@ final class InvoiceModel
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated since 1.9 - will be removed with 2.0 - use getInvoiceNumber() instead
|
||||||
|
*/
|
||||||
public function getNumberGenerator(): ?NumberGeneratorInterface
|
public function getNumberGenerator(): ?NumberGeneratorInterface
|
||||||
{
|
{
|
||||||
return $this->generator;
|
return $this->generator;
|
||||||
|
|||||||
@@ -28,10 +28,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
|
|||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
private $format;
|
private $format;
|
||||||
/**
|
|
||||||
* @var string
|
|
||||||
*/
|
|
||||||
private $number;
|
|
||||||
|
|
||||||
public function __construct(InvoiceRepository $repository, SystemConfiguration $configuration)
|
public function __construct(InvoiceRepository $repository, SystemConfiguration $configuration)
|
||||||
{
|
{
|
||||||
@@ -60,10 +56,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
|
|||||||
*/
|
*/
|
||||||
public function getInvoiceNumber(): string
|
public function getInvoiceNumber(): string
|
||||||
{
|
{
|
||||||
if (null !== $this->number) {
|
|
||||||
return $this->number;
|
|
||||||
}
|
|
||||||
|
|
||||||
$format = $this->format;
|
$format = $this->format;
|
||||||
$invoiceDate = $this->model->getInvoiceDate();
|
$invoiceDate = $this->model->getInvoiceDate();
|
||||||
$timestamp = $invoiceDate->getTimestamp();
|
$timestamp = $invoiceDate->getTimestamp();
|
||||||
@@ -137,6 +129,6 @@ final class ConfigurableNumberGenerator implements NumberGeneratorInterface
|
|||||||
$result = str_replace($part, $partialResult, $result);
|
$result = str_replace($part, $partialResult, $result);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->number = (string) $result;
|
return (string) $result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,17 @@ namespace App\Invoice;
|
|||||||
|
|
||||||
use App\Entity\Invoice;
|
use App\Entity\Invoice;
|
||||||
use App\Entity\InvoiceDocument;
|
use App\Entity\InvoiceDocument;
|
||||||
|
use App\Event\InvoiceCreatedEvent;
|
||||||
use App\Event\InvoicePostRenderEvent;
|
use App\Event\InvoicePostRenderEvent;
|
||||||
|
use App\Event\InvoicePreRenderEvent;
|
||||||
use App\Repository\InvoiceDocumentRepository;
|
use App\Repository\InvoiceDocumentRepository;
|
||||||
|
use App\Repository\InvoiceRepository;
|
||||||
|
use App\Repository\Query\InvoiceQuery;
|
||||||
|
use App\Timesheet\UserDateTimeFactory;
|
||||||
use App\Utils\FileHelper;
|
use App\Utils\FileHelper;
|
||||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Service to manage invoice dependencies.
|
* Service to manage invoice dependencies.
|
||||||
@@ -45,11 +52,26 @@ final class ServiceInvoice
|
|||||||
* @var FileHelper
|
* @var FileHelper
|
||||||
*/
|
*/
|
||||||
private $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->documents = $repository;
|
||||||
$this->fileHelper = $fileHelper;
|
$this->fileHelper = $fileHelper;
|
||||||
|
$this->invoiceRepository = $invoiceRepository;
|
||||||
|
$this->dateTimeFactory = $dateTimeFactory;
|
||||||
|
$this->formatter = $formatter;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice
|
public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice
|
||||||
@@ -153,7 +175,7 @@ final class ServiceInvoice
|
|||||||
|
|
||||||
private function getInvoicesDirectory(): string
|
private function getInvoicesDirectory(): string
|
||||||
{
|
{
|
||||||
return $this->fileHelper->getDataSubdirectory('invoices');
|
return $this->fileHelper->getDataDirectory('invoices');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo
|
public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo
|
||||||
@@ -208,4 +230,209 @@ final class ServiceInvoice
|
|||||||
|
|
||||||
return $filename;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,51 +9,53 @@
|
|||||||
|
|
||||||
namespace App\Utils;
|
namespace App\Utils;
|
||||||
|
|
||||||
|
use Symfony\Component\Filesystem\Filesystem;
|
||||||
|
|
||||||
final class FileHelper
|
final class FileHelper
|
||||||
{
|
{
|
||||||
/**
|
/**
|
||||||
* @var string
|
* @var string
|
||||||
*/
|
*/
|
||||||
private $dataDir;
|
private $dataDir;
|
||||||
|
/**
|
||||||
|
* @var Filesystem
|
||||||
|
*/
|
||||||
|
private $filesystem;
|
||||||
|
|
||||||
public function __construct(string $dataDir)
|
public function __construct(string $dataDir)
|
||||||
{
|
{
|
||||||
$this->dataDir = $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 (!empty($subDirectory)) {
|
||||||
|
$directory .= rtrim(ltrim($subDirectory, '/'), '/') . '/';
|
||||||
if (!is_dir($subDirectory)) {
|
|
||||||
throw new \Exception(sprintf('Directory "%s" does not exist', $subDirectory));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!is_writable($subDirectory)) {
|
$this->makeDir($directory);
|
||||||
throw new \Exception(sprintf('Directory "%s" is not writable', $subDirectory));
|
|
||||||
|
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)
|
public function makeDir(string $directory)
|
||||||
{
|
{
|
||||||
if (is_dir($directory)) {
|
$this->filesystem->mkdir($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)
|
public function saveFile(string $filename, $data)
|
||||||
{
|
{
|
||||||
$result = @file_put_contents($filename, $data);
|
$this->filesystem->dumpFile($filename, $data);
|
||||||
if ($result === false) {
|
|
||||||
throw new \Exception('File "%s" could not be written');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="{{ app.request.locale }}">
|
{% set fallback = app.request is not null ? app.request.locale : 'en' %}
|
||||||
|
{% set language = model.template.language|default(fallback) %}
|
||||||
|
<html lang="{{ language }}">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||||
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
|
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
|
||||||
<title>{% block title %}{{ model.numberGenerator.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
|
<title>{% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
|
||||||
<style type="text/css">
|
<style type="text/css">
|
||||||
{{ encore_entry_css_source('invoice')|raw }}
|
{{ encore_entry_css_source('invoice')|raw }}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{% extends 'invoice/layout.html.twig' %}
|
{% extends 'invoice/layout.html.twig' %}
|
||||||
{% set language = model.template.language|default(app.request.locale) %}
|
{% set fallback = app.request is not null ? app.request.locale : 'en' %}
|
||||||
|
{% set language = model.template.language|default(fallback) %}
|
||||||
{% set isDecimal = model.template.decimalDuration|default(false) %}
|
{% set isDecimal = model.template.decimalDuration|default(false) %}
|
||||||
|
|
||||||
{% block invoice %}
|
{% block invoice %}
|
||||||
@@ -51,7 +52,7 @@
|
|||||||
<div class="col-sm-5">
|
<div class="col-sm-5">
|
||||||
<p contenteditable="true">
|
<p contenteditable="true">
|
||||||
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
|
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
|
||||||
{{ model.numberGenerator.invoiceNumber }}
|
{{ model.invoiceNumber }}
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
|
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% import "macros/widgets.html.twig" as widgets %}
|
{% import "macros/widgets.html.twig" as widgets %}
|
||||||
{% extends 'invoice/layout.html.twig' %}
|
{% extends 'invoice/layout.html.twig' %}
|
||||||
{% set language = model.template.language|default(app.request.locale) %}
|
{% set fallback = app.request is not null ? app.request.locale : 'en' %}
|
||||||
|
{% set language = model.template.language|default(fallback) %}
|
||||||
{% set isDecimal = model.template.decimalDuration|default(false) %}
|
{% set isDecimal = model.template.decimalDuration|default(false) %}
|
||||||
|
|
||||||
{% block invoice %}
|
{% block invoice %}
|
||||||
@@ -30,11 +31,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ 'invoice.service_date'|trans({}, 'messages', language) }}:</th>
|
<th>{{ 'invoice.service_date'|trans({}, 'messages', language) }}:</th>
|
||||||
<td><span contenteditable="true">{{ model.query.end|month_name|trans({}, 'messages', language) }} {{ model.query.end|date('Y') }}</td>
|
<td><span contenteditable="true">{{ model.query.end|month_name|trans({}, 'messages', language) }} {{ model.query.end|date('Y') }}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{{ 'invoice.number'|trans({}, 'messages', language) }}:</th>
|
<th>{{ 'invoice.number'|trans({}, 'messages', language) }}:</th>
|
||||||
<td><span contenteditable="true">{{ model.numberGenerator.invoiceNumber }}</td>
|
<td>{{ model.invoiceNumber }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
|
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{% import "macros/widgets.html.twig" as widgets %}
|
{% import "macros/widgets.html.twig" as widgets %}
|
||||||
{% extends 'invoice/layout.html.twig' %}
|
{% extends 'invoice/layout.html.twig' %}
|
||||||
{% set language = model.template.language|default(app.request.locale) %}
|
{% set fallback = app.request is not null ? app.request.locale : 'en' %}
|
||||||
|
{% set language = model.template.language|default(fallback) %}
|
||||||
{% set isDecimal = model.template.decimalDuration|default(false) %}
|
{% set isDecimal = model.template.decimalDuration|default(false) %}
|
||||||
|
|
||||||
{% block invoice %}
|
{% block invoice %}
|
||||||
|
|||||||
248
tests/Command/InvoiceCreateCommandTest.php
Normal file
248
tests/Command/InvoiceCreateCommandTest.php
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
<?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\Tests\Command;
|
||||||
|
|
||||||
|
use App\Command\InvoiceCreateCommand;
|
||||||
|
use App\DataFixtures\UserFixtures;
|
||||||
|
use App\Entity\Customer;
|
||||||
|
use App\Entity\CustomerMeta;
|
||||||
|
use App\Entity\Project;
|
||||||
|
use App\Invoice\ServiceInvoice;
|
||||||
|
use App\Repository\CustomerRepository;
|
||||||
|
use App\Repository\InvoiceTemplateRepository;
|
||||||
|
use App\Repository\TimesheetRepository;
|
||||||
|
use App\Repository\UserRepository;
|
||||||
|
use App\Tests\DataFixtures\CustomerFixtures;
|
||||||
|
use App\Tests\DataFixtures\InvoiceFixtures;
|
||||||
|
use App\Tests\DataFixtures\ProjectFixtures;
|
||||||
|
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||||
|
use App\Tests\KernelTestTrait;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
|
use Symfony\Component\Console\Tester\CommandTester;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @covers \App\Command\InvoiceCreateCommand
|
||||||
|
* @group integration
|
||||||
|
*/
|
||||||
|
class InvoiceCreateCommandTest extends KernelTestCase
|
||||||
|
{
|
||||||
|
use KernelTestTrait;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Application
|
||||||
|
*/
|
||||||
|
protected $application;
|
||||||
|
|
||||||
|
private function clearInvoiceFiles()
|
||||||
|
{
|
||||||
|
$path = __DIR__ . '/../_data/invoices/';
|
||||||
|
|
||||||
|
if (is_dir($path)) {
|
||||||
|
$files = glob($path . '*');
|
||||||
|
foreach ($files as $file) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
parent::tearDown();
|
||||||
|
$this->clearInvoiceFiles();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->clearInvoiceFiles();
|
||||||
|
$kernel = self::bootKernel();
|
||||||
|
$this->application = new Application($kernel);
|
||||||
|
$container = self::$container;
|
||||||
|
|
||||||
|
$this->application->add(new InvoiceCreateCommand(
|
||||||
|
$container->get(ServiceInvoice::class),
|
||||||
|
$container->get(TimesheetRepository::class),
|
||||||
|
$container->get(CustomerRepository::class),
|
||||||
|
$container->get(InvoiceTemplateRepository::class),
|
||||||
|
$container->get(UserRepository::class),
|
||||||
|
$container->get('event_dispatcher')
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
'user'
|
||||||
|
'start'
|
||||||
|
'end'
|
||||||
|
'timezone'
|
||||||
|
'customer'
|
||||||
|
'template'
|
||||||
|
'search'
|
||||||
|
'exported'
|
||||||
|
'by-customer'
|
||||||
|
'by-project'
|
||||||
|
'set-exported'
|
||||||
|
'template-meta'
|
||||||
|
* @param $user
|
||||||
|
* @param array $params
|
||||||
|
* @return CommandTester
|
||||||
|
*/
|
||||||
|
protected function createInvoice(array $options = [])
|
||||||
|
{
|
||||||
|
$command = $this->application->find('kimai:invoice:create');
|
||||||
|
$commandTester = new CommandTester($command);
|
||||||
|
$commandTester->execute(array_merge($options, [
|
||||||
|
'command' => $command->getName(),
|
||||||
|
]));
|
||||||
|
|
||||||
|
return $commandTester;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function assertCommandErrors(array $options = [], string $errorMessage = '')
|
||||||
|
{
|
||||||
|
$commandTester = $this->createInvoice($options);
|
||||||
|
|
||||||
|
$output = $commandTester->getDisplay();
|
||||||
|
$this->assertStringContainsString('[ERROR] ' . $errorMessage, $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithUnknownExportFilter()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--exported' => 'foo'], 'Unknown "exported" filter given');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithMissingUser()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors([], 'You must set a "user" to create invoices');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithInvalidUser()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => 'assdfd'], 'The given username "assdfd" could not be resolved');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithMissingEnd()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--start' => '2020-01-01'], 'You need to supply a end date if a start date was given');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateByCustomerAndByProject()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--by-customer' => null, '--by-project' => null], 'You cannot mix "by-customer" and "by-project"');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithMissingGenerationMode()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN], 'Could not determine generation mode');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithMissingTemplate()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1], 'You must either pass the "template" or "template-meta" option');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithInvalidStart()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1, '--exported' => 'exported', '--template' => 'x', '--start' => 'öäüß', '--end' => '2020-01-01'], 'Invalid start date given');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithInvalidEnd()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 1, '--template' => 'x', '--start' => '2020-01-01', '--end' => 'öäüß'], 'Invalid end date given');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateWithInvalidCustomer()
|
||||||
|
{
|
||||||
|
$this->assertCommandErrors(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => 3, '--template' => 'x'], 'Unknown customer ID: 3');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateInvoice()
|
||||||
|
{
|
||||||
|
$fixture = new InvoiceFixtures();
|
||||||
|
$this->importFixture($this, $fixture);
|
||||||
|
|
||||||
|
$commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--set-exported' => null, '--customer' => 1, '--template' => 'Invoice', '--start' => '2020-01-01', '--end' => '2020-03-01']);
|
||||||
|
|
||||||
|
$output = $commandTester->getDisplay();
|
||||||
|
$this->assertStringContainsString('+----+----------+-------+------------- Created 1 invoice(s) --------------------------------------+', $output);
|
||||||
|
$this->assertStringContainsString('| ID | Customer | Total | Filename |', $output);
|
||||||
|
$this->assertStringContainsString('+----+----------+-------+-------------------------------------------------------------------------+', $output);
|
||||||
|
$this->assertStringContainsString('| 1 | Test | 0 EUR | /', $output);
|
||||||
|
$this->assertStringContainsString('/tests/_data/invoices/2020-001-test.html |', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function prepareFixtures(\DateTime $start)
|
||||||
|
{
|
||||||
|
$em = self::$container->get('doctrine.orm.entity_manager');
|
||||||
|
|
||||||
|
$fixture = new CustomerFixtures();
|
||||||
|
$fixture->setAmount(1);
|
||||||
|
$fixture->setCallback(function (Customer $customer) {
|
||||||
|
$meta = new CustomerMeta();
|
||||||
|
$meta->setName('template');
|
||||||
|
$meta->setValue('Invoice');
|
||||||
|
$customer->setMetaField($meta);
|
||||||
|
});
|
||||||
|
$this->importFixture($em, $fixture);
|
||||||
|
|
||||||
|
$fixture = new ProjectFixtures();
|
||||||
|
$fixture->setCustomers([$em->getRepository(Customer::class)->find(2)]);
|
||||||
|
$fixture->setAmount(1);
|
||||||
|
$this->importFixture($em, $fixture);
|
||||||
|
|
||||||
|
$fixture = new TimesheetFixtures();
|
||||||
|
$fixture->setUser($this->getUserByName($em, UserFixtures::USERNAME_SUPER_ADMIN));
|
||||||
|
$fixture->setAmount(20);
|
||||||
|
$fixture->setStartDate($start);
|
||||||
|
$fixture->setProjects([$em->getRepository(Project::class)->find(2)]);
|
||||||
|
$this->importFixture($em, $fixture);
|
||||||
|
|
||||||
|
$fixture = new InvoiceFixtures();
|
||||||
|
$this->importFixture($em, $fixture);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateInvoiceByCustomer()
|
||||||
|
{
|
||||||
|
$start = new \DateTime('-2 months');
|
||||||
|
$end = new \DateTime();
|
||||||
|
|
||||||
|
$this->prepareFixtures($start);
|
||||||
|
|
||||||
|
$commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--by-customer' => null, '--template-meta' => 'template', '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]);
|
||||||
|
|
||||||
|
$output = $commandTester->getDisplay();
|
||||||
|
$this->assertStringContainsString('Created 1 invoice(s) ', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateInvoiceByCustomerId()
|
||||||
|
{
|
||||||
|
$start = new \DateTime('-2 months');
|
||||||
|
$end = new \DateTime();
|
||||||
|
|
||||||
|
$this->prepareFixtures($start);
|
||||||
|
|
||||||
|
$commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--customer' => '2,1', '--template-meta' => 'template', '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]);
|
||||||
|
|
||||||
|
$output = $commandTester->getDisplay();
|
||||||
|
$this->assertStringContainsString('Created 1 invoice(s) ', $output);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateInvoiceByProject()
|
||||||
|
{
|
||||||
|
$start = new \DateTime('-2 months');
|
||||||
|
$end = new \DateTime();
|
||||||
|
|
||||||
|
$this->prepareFixtures($start);
|
||||||
|
|
||||||
|
$commandTester = $this->createInvoice(['--user' => UserFixtures::USERNAME_SUPER_ADMIN, '--exported' => 'all', '--by-project' => null, '--template-meta' => 'template', '--start' => $start->format('Y-m-d'), '--end' => $end->format('Y-m-d')]);
|
||||||
|
|
||||||
|
$output = $commandTester->getDisplay();
|
||||||
|
$this->assertStringContainsString('Created 1 invoice(s) ', $output);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,14 +26,7 @@ class InvoiceControllerTest extends ControllerBaseTest
|
|||||||
protected function setUp(): void
|
protected function setUp(): void
|
||||||
{
|
{
|
||||||
parent::setUp();
|
parent::setUp();
|
||||||
$path = __DIR__ . '/../_data/invoices/';
|
$this->clearInvoiceFiles();
|
||||||
|
|
||||||
if (is_dir($path)) {
|
|
||||||
$files = glob($path . '*');
|
|
||||||
foreach ($files as $file) {
|
|
||||||
unlink($file);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
@@ -144,7 +137,7 @@ class InvoiceControllerTest extends ControllerBaseTest
|
|||||||
$this->assertEquals($template->getPaymentTerms(), $values['paymentTerms']);
|
$this->assertEquals($template->getPaymentTerms(), $values['paymentTerms']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPrintAction()
|
public function testCreateAction()
|
||||||
{
|
{
|
||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||||
/** @var EntityManager $em */
|
/** @var EntityManager $em */
|
||||||
@@ -210,7 +203,66 @@ class InvoiceControllerTest extends ControllerBaseTest
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testPrintActionAsAdminWithDownloadAndStatusChange()
|
public function testPrintAction()
|
||||||
|
{
|
||||||
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
|
||||||
|
/** @var EntityManager $em */
|
||||||
|
$em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
|
||||||
|
|
||||||
|
$fixture = new InvoiceFixtures();
|
||||||
|
$this->importFixture($client, $fixture);
|
||||||
|
|
||||||
|
$begin = new \DateTime('first day of this month');
|
||||||
|
$end = new \DateTime('last day of this month');
|
||||||
|
$fixture = new TimesheetFixtures();
|
||||||
|
$fixture
|
||||||
|
->setUser($this->getUserByRole($em, User::ROLE_TEAMLEAD))
|
||||||
|
->setAmount(20)
|
||||||
|
->setStartDate($begin)
|
||||||
|
;
|
||||||
|
$this->importFixture($client, $fixture);
|
||||||
|
|
||||||
|
$this->request($client, '/invoice/');
|
||||||
|
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||||
|
|
||||||
|
$dateRange = $begin->format('Y-m-d') . DateRangeType::DATE_SPACER . $end->format('Y-m-d');
|
||||||
|
|
||||||
|
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
|
||||||
|
$node = $form->getFormNode();
|
||||||
|
$node->setAttribute('action', $this->createUrl('/invoice/?preview='));
|
||||||
|
$node->setAttribute('method', 'GET');
|
||||||
|
$client->submit($form, [
|
||||||
|
'template' => 1,
|
||||||
|
'daterange' => $dateRange,
|
||||||
|
'customer' => 1,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||||
|
|
||||||
|
// no warning should be displayed
|
||||||
|
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
|
||||||
|
$this->assertEquals(0, $node->count());
|
||||||
|
// but the datatable with all timesheets + 1 row for the total
|
||||||
|
$this->assertDataTableRowCount($client, 'datatable_invoice', 21);
|
||||||
|
|
||||||
|
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
|
||||||
|
$node = $form->getFormNode();
|
||||||
|
$node->setAttribute('action', $this->createUrl('/invoice/?print='));
|
||||||
|
$node->setAttribute('method', 'GET');
|
||||||
|
$client->submit($form, [
|
||||||
|
'template' => 1,
|
||||||
|
'daterange' => $dateRange,
|
||||||
|
'customer' => 1,
|
||||||
|
'projects' => [1],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||||
|
$node = $client->getCrawler()->filter('body');
|
||||||
|
$this->assertEquals(1, $node->count());
|
||||||
|
$this->assertEquals('invoice_print', $node->getIterator()[0]->getAttribute('class'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCreateActionAsAdminWithDownloadAndStatusChange()
|
||||||
{
|
{
|
||||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||||
/** @var EntityManager $em */
|
/** @var EntityManager $em */
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ class InvoiceTemplateTest extends TestCase
|
|||||||
|
|
||||||
self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage('de'));
|
self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage('de'));
|
||||||
self::assertEquals('de', $sut->getLanguage());
|
self::assertEquals('de', $sut->getLanguage());
|
||||||
|
|
||||||
|
self::assertEquals($sut, clone $sut);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testToString()
|
public function testToString()
|
||||||
|
|||||||
29
tests/Event/InvoiceCreatedEventTest.php
Normal file
29
tests/Event/InvoiceCreatedEventTest.php
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<?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\Tests\Event;
|
||||||
|
|
||||||
|
use App\Entity\Invoice;
|
||||||
|
use App\Event\InvoiceCreatedEvent;
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @covers \App\Event\InvoiceCreatedEvent
|
||||||
|
*/
|
||||||
|
class InvoiceCreatedEventTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testDefaultValues()
|
||||||
|
{
|
||||||
|
$invoice = new Invoice();
|
||||||
|
|
||||||
|
$sut = new InvoiceCreatedEvent($invoice);
|
||||||
|
|
||||||
|
self::assertSame($invoice, $sut->getInvoice());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -47,6 +47,10 @@ class InvoiceModelCustomerHydratorTest extends TestCase
|
|||||||
'customer.number',
|
'customer.number',
|
||||||
'customer.homepage',
|
'customer.homepage',
|
||||||
'customer.comment',
|
'customer.comment',
|
||||||
|
'customer.email',
|
||||||
|
'customer.fax',
|
||||||
|
'customer.phone',
|
||||||
|
'customer.mobile',
|
||||||
'customer.meta.foo-customer',
|
'customer.meta.foo-customer',
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ use App\Entity\InvoiceTemplate;
|
|||||||
use App\Entity\Timesheet;
|
use App\Entity\Timesheet;
|
||||||
use App\Invoice\Calculator\DefaultCalculator;
|
use App\Invoice\Calculator\DefaultCalculator;
|
||||||
use App\Invoice\InvoiceModel;
|
use App\Invoice\InvoiceModel;
|
||||||
use App\Invoice\NumberGenerator\DateNumberGenerator;
|
|
||||||
use App\Repository\Query\InvoiceQuery;
|
use App\Repository\Query\InvoiceQuery;
|
||||||
|
use App\Tests\Invoice\NumberGenerator\IncrementingNumberGenerator;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +43,16 @@ class InvoiceModelTest extends TestCase
|
|||||||
self::assertSame($formatter, $sut->getFormatter());
|
self::assertSame($formatter, $sut->getFormatter());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testEmptyObjectThrowsExceptionOnNumberGenerator()
|
||||||
|
{
|
||||||
|
$formatter = new DebugFormatter();
|
||||||
|
$sut = new InvoiceModel($formatter);
|
||||||
|
|
||||||
|
$this->expectException(\Exception::class);
|
||||||
|
$this->expectExceptionMessage('InvoiceModel::getInvoiceNumber() cannot be called before calling setNumberGenerator()');
|
||||||
|
$sut->getInvoiceNumber();
|
||||||
|
}
|
||||||
|
|
||||||
public function testSetter()
|
public function testSetter()
|
||||||
{
|
{
|
||||||
$sut = new InvoiceModel(new DebugFormatter());
|
$sut = new InvoiceModel(new DebugFormatter());
|
||||||
@@ -59,9 +69,14 @@ class InvoiceModelTest extends TestCase
|
|||||||
self::assertInstanceOf(InvoiceModel::class, $sut->setCalculator($calculator));
|
self::assertInstanceOf(InvoiceModel::class, $sut->setCalculator($calculator));
|
||||||
self::assertSame($calculator, $sut->getCalculator());
|
self::assertSame($calculator, $sut->getCalculator());
|
||||||
|
|
||||||
$generator = new DateNumberGenerator();
|
$generator = new IncrementingNumberGenerator();
|
||||||
self::assertInstanceOf(InvoiceModel::class, $sut->setNumberGenerator($generator));
|
self::assertInstanceOf(InvoiceModel::class, $sut->setNumberGenerator($generator));
|
||||||
self::assertSame($generator, $sut->getNumberGenerator());
|
self::assertSame($generator, $sut->getNumberGenerator());
|
||||||
|
$number = $sut->getInvoiceNumber();
|
||||||
|
$first = $sut->getNumberGenerator()->getInvoiceNumber();
|
||||||
|
$second = $sut->getNumberGenerator()->getInvoiceNumber();
|
||||||
|
self::assertEquals(((int) $first + 1), $second);
|
||||||
|
self::assertEquals($number, $sut->getInvoiceNumber());
|
||||||
|
|
||||||
$template = new InvoiceTemplate();
|
$template = new InvoiceTemplate();
|
||||||
self::assertNull($sut->getDueDate());
|
self::assertNull($sut->getDueDate());
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?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\Tests\Invoice\NumberGenerator;
|
||||||
|
|
||||||
|
use App\Invoice\InvoiceModel;
|
||||||
|
use App\Invoice\NumberGeneratorInterface;
|
||||||
|
|
||||||
|
class IncrementingNumberGenerator implements NumberGeneratorInterface
|
||||||
|
{
|
||||||
|
private $counter = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param InvoiceModel $model
|
||||||
|
*/
|
||||||
|
public function setModel(InvoiceModel $model)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getInvoiceNumber(): string
|
||||||
|
{
|
||||||
|
return $this->counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique ID of this number generator.
|
||||||
|
*
|
||||||
|
* Prefix it with your company name followed by a hyphen (e.g. "acme-"),
|
||||||
|
* if this is a third-party generator.
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getId(): string
|
||||||
|
{
|
||||||
|
return 'testing';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -58,7 +58,7 @@ class CsvRendererTest extends TestCase
|
|||||||
|
|
||||||
$file = $response->getFile();
|
$file = $response->getFile();
|
||||||
$this->assertEquals('text/csv', $response->headers->get('Content-Type'));
|
$this->assertEquals('text/csv', $response->headers->get('Content-Type'));
|
||||||
$filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name.csv';
|
$filename = $model->getInvoiceNumber() . '-customer_with_special_name.csv';
|
||||||
$this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition'));
|
$this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition'));
|
||||||
|
|
||||||
$this->assertTrue(file_exists($file->getRealPath()));
|
$this->assertTrue(file_exists($file->getRealPath()));
|
||||||
|
|||||||
@@ -119,6 +119,10 @@ class DebugRendererTest extends TestCase
|
|||||||
'customer.number',
|
'customer.number',
|
||||||
'customer.homepage',
|
'customer.homepage',
|
||||||
'customer.comment',
|
'customer.comment',
|
||||||
|
'customer.email',
|
||||||
|
'customer.fax',
|
||||||
|
'customer.phone',
|
||||||
|
'customer.mobile',
|
||||||
'customer.meta.foo-customer',
|
'customer.meta.foo-customer',
|
||||||
'activity.id',
|
'activity.id',
|
||||||
'activity.name',
|
'activity.name',
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class DocxRendererTest extends TestCase
|
|||||||
/** @var BinaryFileResponse $response */
|
/** @var BinaryFileResponse $response */
|
||||||
$response = $sut->render($document, $model);
|
$response = $sut->render($document, $model);
|
||||||
|
|
||||||
$filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name.docx';
|
$filename = $model->getInvoiceNumber() . '-customer_with_special_name.docx';
|
||||||
$file = $response->getFile();
|
$file = $response->getFile();
|
||||||
$this->assertEquals('application/vnd.openxmlformats-officedocument.wordprocessingml.document', $response->headers->get('Content-Type'));
|
$this->assertEquals('application/vnd.openxmlformats-officedocument.wordprocessingml.document', $response->headers->get('Content-Type'));
|
||||||
$this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition'));
|
$this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition'));
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class OdsRendererTest extends TestCase
|
|||||||
/** @var BinaryFileResponse $response */
|
/** @var BinaryFileResponse $response */
|
||||||
$response = $sut->render($document, $model);
|
$response = $sut->render($document, $model);
|
||||||
|
|
||||||
$filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name.ods';
|
$filename = $model->getInvoiceNumber() . '-customer_with_special_name.ods';
|
||||||
$file = $response->getFile();
|
$file = $response->getFile();
|
||||||
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
|
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
|
||||||
$this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition'));
|
$this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition'));
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class TwigRendererTest extends KernelTestCase
|
|||||||
|
|
||||||
$content = $response->getContent();
|
$content = $response->getContent();
|
||||||
|
|
||||||
$filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name';
|
$filename = $model->getInvoiceNumber() . '-customer_with_special_name';
|
||||||
$this->assertStringContainsString('<title>' . $filename . '</title>', $content);
|
$this->assertStringContainsString('<title>' . $filename . '</title>', $content);
|
||||||
$this->assertStringContainsString('<h2 class="page-header">
|
$this->assertStringContainsString('<h2 class="page-header">
|
||||||
<span contenteditable="true">a very *long* test invoice / template title with [special] character</span>
|
<span contenteditable="true">a very *long* test invoice / template title with [special] character</span>
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class XlsxRendererTest extends TestCase
|
|||||||
/** @var BinaryFileResponse $response */
|
/** @var BinaryFileResponse $response */
|
||||||
$response = $sut->render($document, $model);
|
$response = $sut->render($document, $model);
|
||||||
|
|
||||||
$filename = $model->getNumberGenerator()->getInvoiceNumber() . '-customer_with_special_name.xlsx';
|
$filename = $model->getInvoiceNumber() . '-customer_with_special_name.xlsx';
|
||||||
$file = $response->getFile();
|
$file = $response->getFile();
|
||||||
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
|
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
|
||||||
$this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition'));
|
$this->assertEquals('attachment; filename=' . $filename, $response->headers->get('Content-Disposition'));
|
||||||
|
|||||||
@@ -9,12 +9,15 @@
|
|||||||
|
|
||||||
namespace App\Tests\Invoice;
|
namespace App\Tests\Invoice;
|
||||||
|
|
||||||
|
use App\Entity\Invoice;
|
||||||
use App\Entity\InvoiceDocument;
|
use App\Entity\InvoiceDocument;
|
||||||
use App\Invoice\Calculator\DefaultCalculator;
|
use App\Invoice\Calculator\DefaultCalculator;
|
||||||
use App\Invoice\NumberGenerator\DateNumberGenerator;
|
use App\Invoice\NumberGenerator\DateNumberGenerator;
|
||||||
use App\Invoice\Renderer\TwigRenderer;
|
use App\Invoice\Renderer\TwigRenderer;
|
||||||
use App\Invoice\ServiceInvoice;
|
use App\Invoice\ServiceInvoice;
|
||||||
use App\Repository\InvoiceDocumentRepository;
|
use App\Repository\InvoiceDocumentRepository;
|
||||||
|
use App\Repository\InvoiceRepository;
|
||||||
|
use App\Tests\Mocks\Security\UserDateTimeFactoryFactory;
|
||||||
use App\Utils\FileHelper;
|
use App\Utils\FileHelper;
|
||||||
use PHPUnit\Framework\TestCase;
|
use PHPUnit\Framework\TestCase;
|
||||||
use Twig\Environment;
|
use Twig\Environment;
|
||||||
@@ -27,8 +30,18 @@ class ServiceInvoiceTest extends TestCase
|
|||||||
private function getSut(array $paths): ServiceInvoice
|
private function getSut(array $paths): ServiceInvoice
|
||||||
{
|
{
|
||||||
$repo = new InvoiceDocumentRepository($paths);
|
$repo = new InvoiceDocumentRepository($paths);
|
||||||
|
$invoiceRepo = $this->createMock(InvoiceRepository::class);
|
||||||
|
$userDateTime = (new UserDateTimeFactoryFactory($this))->create();
|
||||||
|
|
||||||
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')));
|
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')), $invoiceRepo, $userDateTime, new DebugFormatter());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testInvalidExceptionOnChangeState()
|
||||||
|
{
|
||||||
|
$this->expectException(\InvalidArgumentException::class);
|
||||||
|
$this->expectExceptionMessage('Unknown invoice status');
|
||||||
|
$sut = $this->getSut([]);
|
||||||
|
$sut->changeInvoiceStatus(new Invoice(), 'foo');
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testEmptyObject()
|
public function testEmptyObject()
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
<div class="col-sm-5">
|
<div class="col-sm-5">
|
||||||
<p contenteditable="true">
|
<p contenteditable="true">
|
||||||
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
|
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
|
||||||
{{ model.numberGenerator.invoiceNumber }}
|
{{ model.invoiceNumber }}
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
|
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Doctrine\Bundle\FixturesBundle\Fixture;
|
|||||||
use Doctrine\Common\DataFixtures\Executor\ORMExecutor;
|
use Doctrine\Common\DataFixtures\Executor\ORMExecutor;
|
||||||
use Doctrine\Common\DataFixtures\Loader;
|
use Doctrine\Common\DataFixtures\Loader;
|
||||||
use Doctrine\ORM\EntityManager;
|
use Doctrine\ORM\EntityManager;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||||
use Symfony\Component\HttpKernel\HttpKernelBrowser;
|
use Symfony\Component\HttpKernel\HttpKernelBrowser;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,12 +23,18 @@ use Symfony\Component\HttpKernel\HttpKernelBrowser;
|
|||||||
*/
|
*/
|
||||||
trait KernelTestTrait
|
trait KernelTestTrait
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @param $client HttpKernelBrowser|EntityManager|KernelTestCase
|
||||||
|
* @param Fixture $fixture
|
||||||
|
*/
|
||||||
protected function importFixture($client, Fixture $fixture)
|
protected function importFixture($client, Fixture $fixture)
|
||||||
{
|
{
|
||||||
if ($client instanceof HttpKernelBrowser) {
|
if ($client instanceof HttpKernelBrowser) {
|
||||||
$em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
|
$em = static::$kernel->getContainer()->get('doctrine.orm.entity_manager');
|
||||||
} elseif ($client instanceof EntityManager) {
|
} elseif ($client instanceof EntityManager) {
|
||||||
$em = $client;
|
$em = $client;
|
||||||
|
} elseif ($client instanceof KernelTestCase) {
|
||||||
|
$em = $client::$container->get('doctrine.orm.entity_manager');
|
||||||
} else {
|
} else {
|
||||||
throw new \InvalidArgumentException('Fixtures need an EntityManager to be imported');
|
throw new \InvalidArgumentException('Fixtures need an EntityManager to be imported');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user