added invoice archive & configurable invoice numbers (#1541)
This commit is contained in:
2
.github/workflows/coverage.yaml
vendored
2
.github/workflows/coverage.yaml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
php: ['7.4']
|
||||
php: ['7.3']
|
||||
|
||||
name: Coverage - PHP ${{ matrix.php }}
|
||||
steps:
|
||||
|
||||
20
UPGRADING.md
20
UPGRADING.md
@@ -8,9 +8,27 @@ you can upgrade your Kimai installation to the latest stable release.
|
||||
Check below if there are more version specific steps required, which need to be executed after the normal update process.
|
||||
Perform EACH version specific task between your version and the new one, otherwise you risk data inconsistency or a broken installation.
|
||||
|
||||
## [1.9](https://github.com/kevinpapst/kimai2/releases/tag/1.9)
|
||||
|
||||
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
|
||||
|
||||
- The directory `var/data/invoices/` will be used to store archived invoice files (check file permissions)
|
||||
- The default invoice number format changed, if you want back the old one, use `{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
|
||||
assets (CSS, images) you need to either inline them (see the default templates) or use absolute URLs.
|
||||
|
||||
Permission changes:
|
||||
- `history_invoice` - NEW: grants all features of the new invoice archive (by default for all admins)
|
||||
|
||||
### Developer
|
||||
|
||||
- BC break: `InvoiceItemInterface` has new methods `getType()` and `getCategory()`
|
||||
|
||||
## [1.8](https://github.com/kevinpapst/kimai2/releases/tag/1.8)
|
||||
|
||||
- New PHP requirement: `ext-xsl` should be pre-installed in most environments when `ext-xml` is loaded
|
||||
**New database tables and fields were created, don't forget to [run the updater](https://www.kimai.org/documentation/updates.html).**
|
||||
|
||||
- New PHP requirement: `ext-xsl` - which should be pre-installed in most environments when `ext-xml` is loaded
|
||||
- New mailer library: check if emails are still working (eg. by using the "password forgotten" function) or if you need to adjust your configuration, [see docs at symfony.com](https://symfony.com/doc/current/components/mailer.html#transport)
|
||||
- Support for line breaks in multiline invoice fields for spreadsheets (check your invoice templates after the update)
|
||||
|
||||
|
||||
@@ -165,7 +165,9 @@
|
||||
"vendor/bin/phpstan analyse tests -c tests/phpstan.neon --level=4"
|
||||
],
|
||||
"kimai:codestyle": "vendor/bin/php-cs-fixer fix --dry-run --verbose --show-progress=none",
|
||||
"kimai:codestyle-fix": "vendor/bin/php-cs-fixer fix"
|
||||
"codestyle": "@kimai:codestyle",
|
||||
"kimai:codestyle-fix": "vendor/bin/php-cs-fixer fix",
|
||||
"codestyle-fix": "@kimai:codestyle-fix"
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
|
||||
@@ -96,7 +96,7 @@ kimai:
|
||||
CUSTOMERS_ALL_TEAM: ['view_team_customer','edit_team_customer','budget_team_customer','comments_team_customer','comments_create_team_customer','details_team_customer']
|
||||
CUSTOMERS_TEAMLEAD: ['view_teamlead_customer','budget_teamlead_customer','comments_teamlead_customer','comments_create_teamlead_customer','details_teamlead_customer']
|
||||
INVOICE: ['view_invoice','create_invoice']
|
||||
INVOICE_TEMPLATE: ['manage_invoice_template']
|
||||
INVOICE_ADMIN: ['manage_invoice_template','history_invoice']
|
||||
TIMESHEET: ['view_own_timesheet','start_own_timesheet','stop_own_timesheet','create_own_timesheet','edit_own_timesheet','export_own_timesheet','delete_own_timesheet']
|
||||
TIMESHEET_OTHER: ['view_other_timesheet','start_other_timesheet','stop_other_timesheet','create_other_timesheet','edit_other_timesheet','export_other_timesheet','delete_other_timesheet']
|
||||
PROFILE: ['view_own_profile','edit_own_profile','password_own_profile','preferences_own_profile','api-token_own_profile']
|
||||
@@ -115,8 +115,8 @@ kimai:
|
||||
# link above sets to one complete set for each user role
|
||||
ROLE_USER: ['@TIMESHEET','@PROFILE','@SINGLE_USER']
|
||||
ROLE_TEAMLEAD: ['@ACTIVITIES_TEAMLEAD','@PROJECTS_TEAMLEAD','@CUSTOMERS_TEAMLEAD','@TIMESHEET_OTHER','@INVOICE','@TIMESHEET','@PROFILE','@EXPORT','@TAGS','@SINGLE_TEAMLEAD']
|
||||
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
|
||||
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_TEMPLATE','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
|
||||
ROLE_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_ADMIN']
|
||||
ROLE_SUPER_ADMIN: ['@ACTIVITIES','@PROJECTS','@CUSTOMERS','@INVOICE','@INVOICE_ADMIN','@TIMESHEET','@TIMESHEET_OTHER','@PROFILE','@PROFILE_OTHER','@USER','@TEAMS','@RATE','@RATE_OTHER','@EXPORT','@TAGS','@SINGLE_SUPER_ADMIN']
|
||||
# mapping "sets" or permissions to user roles ("role name" = [array of "set names"])
|
||||
maps:
|
||||
ROLE_USER: ['ROLE_USER']
|
||||
|
||||
2
config/packages/test/kimai.yaml
Normal file
2
config/packages/test/kimai.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
kimai:
|
||||
data_dir: '%kernel.project_dir%/tests/_data'
|
||||
@@ -83,6 +83,10 @@ services:
|
||||
arguments:
|
||||
$localDomains: '%kimai.i18n_domains%'
|
||||
|
||||
App\Utils\FileHelper:
|
||||
arguments:
|
||||
$dataDir: '%kimai.data_dir%'
|
||||
|
||||
# ================================================================================
|
||||
# DATABASE
|
||||
# ================================================================================
|
||||
@@ -226,3 +230,8 @@ services:
|
||||
class: Doctrine\ORM\EntityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\ProjectRate']
|
||||
|
||||
App\Repository\InvoiceRepository:
|
||||
class: Doctrine\ORM\EntityRepository
|
||||
factory: ['@doctrine.orm.entity_manager', getRepository]
|
||||
arguments: ['App\Entity\Invoice']
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\User;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController as BaseAbstractController;
|
||||
use Symfony\Component\Translation\DataCollectorTranslator;
|
||||
use Symfony\Contracts\Service\ServiceSubscriberInterface;
|
||||
@@ -20,13 +21,6 @@ use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
*/
|
||||
abstract class AbstractController extends BaseAbstractController implements ServiceSubscriberInterface
|
||||
{
|
||||
public const FLASH_SUCCESS = 'success';
|
||||
public const FLASH_WARNING = 'warning';
|
||||
public const FLASH_ERROR = 'error';
|
||||
|
||||
public const DOMAIN_FLASH = 'flashmessages';
|
||||
public const DOMAIN_ERROR = 'exceptions';
|
||||
|
||||
/**
|
||||
* @deprecated since 1.6, will be removed with 2.0
|
||||
*/
|
||||
@@ -40,6 +34,14 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
return $this->container->get('translator');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return LoggerInterface $logger
|
||||
*/
|
||||
private function getLogger()
|
||||
{
|
||||
return $this->container->get('logger');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return User|null
|
||||
*/
|
||||
@@ -56,7 +58,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
*/
|
||||
protected function flashSuccess($translationKey, $parameter = [])
|
||||
{
|
||||
$this->addFlashTranslated(self::FLASH_SUCCESS, $translationKey, $parameter);
|
||||
$this->addFlashTranslated('success', $translationKey, $parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,7 +69,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
*/
|
||||
protected function flashWarning($translationKey, $parameter = [])
|
||||
{
|
||||
$this->addFlashTranslated(self::FLASH_WARNING, $translationKey, $parameter);
|
||||
$this->addFlashTranslated('warning', $translationKey, $parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +80,7 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
*/
|
||||
protected function flashError($translationKey, $parameter = [])
|
||||
{
|
||||
$this->addFlashTranslated(self::FLASH_ERROR, $translationKey, $parameter);
|
||||
$this->addFlashTranslated('error', $translationKey, $parameter);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -92,22 +94,28 @@ abstract class AbstractController extends BaseAbstractController implements Serv
|
||||
{
|
||||
if (!empty($parameter)) {
|
||||
foreach ($parameter as $key => $value) {
|
||||
$parameter[$key] = $this->getTranslator()->trans($value, [], self::DOMAIN_FLASH);
|
||||
$parameter[$key] = $this->getTranslator()->trans($value, [], 'flashmessages');
|
||||
}
|
||||
$message = $this->getTranslator()->trans(
|
||||
$message,
|
||||
$parameter,
|
||||
self::DOMAIN_FLASH
|
||||
'flashmessages'
|
||||
);
|
||||
}
|
||||
|
||||
$this->addFlash($type, $message);
|
||||
}
|
||||
|
||||
protected function logException(\Exception $ex)
|
||||
{
|
||||
$this->getLogger()->critical($ex->getMessage());
|
||||
}
|
||||
|
||||
public static function getSubscribedServices()
|
||||
{
|
||||
return array_merge(parent::getSubscribedServices(), [
|
||||
'translator' => TranslatorInterface::class
|
||||
'translator' => TranslatorInterface::class,
|
||||
'logger' => LoggerInterface::class
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,17 +9,21 @@
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Event\InvoicePostRenderEvent;
|
||||
use App\Event\InvoicePreRenderEvent;
|
||||
use App\Export\ExportItemInterface;
|
||||
use App\Form\InvoiceDocumentUploadForm;
|
||||
use App\Form\InvoiceTemplateForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarForm;
|
||||
use App\Form\Toolbar\InvoiceToolbarSimpleForm;
|
||||
use App\Invoice\InvoiceFormatter;
|
||||
use App\Invoice\InvoiceItemInterface;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Repository\InvoiceTemplateRepository;
|
||||
use App\Repository\Query\BaseQuery;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
@@ -48,7 +52,7 @@ final class InvoiceController extends AbstractController
|
||||
/**
|
||||
* @var InvoiceTemplateRepository
|
||||
*/
|
||||
private $invoiceRepository;
|
||||
private $templateRepository;
|
||||
/**
|
||||
* @var UserDateTimeFactory
|
||||
*/
|
||||
@@ -61,11 +65,16 @@ final class InvoiceController extends AbstractController
|
||||
* @var EventDispatcherInterface
|
||||
*/
|
||||
private $dispatcher;
|
||||
/**
|
||||
* @var InvoiceRepository
|
||||
*/
|
||||
private $invoiceRepository;
|
||||
|
||||
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $invoice, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher)
|
||||
public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, UserDateTimeFactory $dateTimeFactory, InvoiceFormatter $formatter, EventDispatcherInterface $dispatcher)
|
||||
{
|
||||
$this->service = $service;
|
||||
$this->invoiceRepository = $invoice;
|
||||
$this->templateRepository = $templateRepository;
|
||||
$this->invoiceRepository = $invoiceRepository;
|
||||
$this->dateTimeFactory = $dateTimeFactory;
|
||||
$this->formatter = $formatter;
|
||||
$this->dispatcher = $dispatcher;
|
||||
@@ -75,9 +84,9 @@ final class InvoiceController extends AbstractController
|
||||
* @Route(path="/", name="invoice", methods={"GET", "POST"})
|
||||
* @Security("is_granted('view_invoice')")
|
||||
*/
|
||||
public function indexAction(Request $request): Response
|
||||
public function indexAction(Request $request, SystemConfiguration $configuration): Response
|
||||
{
|
||||
if (!$this->invoiceRepository->hasTemplate()) {
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
if ($this->isGranted('manage_invoice_template')) {
|
||||
return $this->redirectToRoute('admin_invoice_template_create');
|
||||
}
|
||||
@@ -88,16 +97,27 @@ final class InvoiceController extends AbstractController
|
||||
$entries = [];
|
||||
|
||||
$query = $this->getDefaultQuery();
|
||||
$form = $this->getToolbarForm($query, 'GET');
|
||||
$form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form'));
|
||||
$form->setData($query);
|
||||
$form->submit($request->query->all(), false);
|
||||
|
||||
if ($this->isGranted('create_invoice')) {
|
||||
if ($form->isValid()) {
|
||||
/** @var SubmitButton $createButton */
|
||||
$createButton = $form->get('create');
|
||||
if ($createButton->isClicked()) {
|
||||
return $this->renderInvoice($query);
|
||||
try {
|
||||
/** @var SubmitButton $createButton */
|
||||
$createButton = $form->get('create');
|
||||
if ($createButton->isClicked()) {
|
||||
return $this->renderInvoice($query, true);
|
||||
}
|
||||
|
||||
/** @var SubmitButton $printButton */
|
||||
$printButton = $form->get('print');
|
||||
if ($printButton->isClicked()) {
|
||||
return $this->renderInvoice($query, false);
|
||||
}
|
||||
} catch (\Exception $ex) {
|
||||
$this->logException($ex);
|
||||
$this->flashError('action.update.error', ['%reason%' => 'check doctor/logs']);
|
||||
}
|
||||
|
||||
/** @var SubmitButton $previewButton */
|
||||
@@ -144,7 +164,7 @@ final class InvoiceController extends AbstractController
|
||||
return $query;
|
||||
}
|
||||
|
||||
protected function renderInvoice(InvoiceQuery $query)
|
||||
protected function renderInvoice(InvoiceQuery $query, bool $saveInvoice = false)
|
||||
{
|
||||
$entries = $this->getEntries($query);
|
||||
$model = $this->prepareModel($query);
|
||||
@@ -162,11 +182,28 @@ final class InvoiceController extends AbstractController
|
||||
$this->dispatcher->dispatch(new InvoicePreRenderEvent($model, $document, $renderer));
|
||||
|
||||
$response = $renderer->render($document, $model);
|
||||
if ($query->isMarkAsExported()) {
|
||||
$this->markEntriesAsExported($entries);
|
||||
}
|
||||
|
||||
$this->dispatcher->dispatch(new InvoicePostRenderEvent($model, $document, $renderer, $response));
|
||||
if ($saveInvoice) {
|
||||
if ($query->isMarkAsExported()) {
|
||||
$this->markEntriesAsExported($entries);
|
||||
}
|
||||
|
||||
$event = new InvoicePostRenderEvent($model, $document, $renderer, $response);
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
$invoiceFilename = $this->service->saveGeneratedInvoice($event);
|
||||
|
||||
$invoice = new Invoice();
|
||||
$invoice->setModel($model);
|
||||
$invoice->setFilename($invoiceFilename);
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
if ($this->isGranted('history_invoice')) {
|
||||
return $this->redirectToRoute('admin_invoice_list', ['id' => $invoice->getId()]);
|
||||
}
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
@@ -180,7 +217,81 @@ final class InvoiceController extends AbstractController
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InvoiceItemInterface[] $entries
|
||||
* @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET"})
|
||||
* @Security("is_granted('history_invoice')")
|
||||
*/
|
||||
public function changeStatusAction(Invoice $invoice, string $status): Response
|
||||
{
|
||||
if (!in_array($status, [Invoice::STATUS_NEW, Invoice::STATUS_PENDING, Invoice::STATUS_PAID])) {
|
||||
throw $this->createNotFoundException('Unknwon invoice status');
|
||||
}
|
||||
|
||||
switch ($status) {
|
||||
case Invoice::STATUS_NEW:
|
||||
$invoice->setIsNew();
|
||||
break;
|
||||
|
||||
case Invoice::STATUS_PENDING:
|
||||
$invoice->setIsPending();
|
||||
break;
|
||||
|
||||
case Invoice::STATUS_PAID:
|
||||
$invoice->setIsPaid();
|
||||
break;
|
||||
}
|
||||
|
||||
$this->invoiceRepository->saveInvoice($invoice);
|
||||
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_list');
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"})
|
||||
* @Security("is_granted('history_invoice')")
|
||||
*/
|
||||
public function downloadAction(Invoice $invoice): Response
|
||||
{
|
||||
$file = $this->service->getInvoiceFile($invoice);
|
||||
|
||||
if (null === $file) {
|
||||
throw $this->createNotFoundException(
|
||||
sprintf('Invoice file "%s" could not be found for invoice ID "%s"', $invoice->getInvoiceFilename(), $invoice->getId())
|
||||
);
|
||||
}
|
||||
|
||||
return $this->file($file->getRealPath(), $file->getBasename());
|
||||
}
|
||||
|
||||
/**
|
||||
* @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"})
|
||||
* @Security("is_granted('history_invoice')")
|
||||
*/
|
||||
public function showInvoicesAction(Request $request, int $page): Response
|
||||
{
|
||||
$invoice = null;
|
||||
|
||||
if (null !== ($id = $request->get('id'))) {
|
||||
$invoice = $this->invoiceRepository->find($id);
|
||||
}
|
||||
|
||||
$query = new InvoiceQuery();
|
||||
$query->setOrderBy('date');
|
||||
$query->setPage($page);
|
||||
$query->setCurrentUser($this->getUser());
|
||||
|
||||
$invoices = $this->invoiceRepository->getPagerfantaForQuery($query);
|
||||
|
||||
return $this->render('invoice/listing.html.twig', [
|
||||
'entries' => $invoices,
|
||||
'query' => $query,
|
||||
'download' => $invoice,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportItemInterface[] $entries
|
||||
*/
|
||||
private function markEntriesAsExported(iterable $entries)
|
||||
{
|
||||
@@ -197,7 +308,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
/**
|
||||
* @param InvoiceQuery $query
|
||||
* @return InvoiceItemInterface[]
|
||||
* @return ExportItemInterface[]
|
||||
*/
|
||||
protected function getEntries(InvoiceQuery $query): array
|
||||
{
|
||||
@@ -246,6 +357,7 @@ final class InvoiceController extends AbstractController
|
||||
{
|
||||
$model = new InvoiceModel($this->formatter);
|
||||
$model
|
||||
->setInvoiceDate($this->dateTimeFactory->createDateTime())
|
||||
->setQuery($query)
|
||||
->setUser($this->getUser())
|
||||
->setCustomer($query->getCustomer())
|
||||
@@ -276,7 +388,7 @@ final class InvoiceController extends AbstractController
|
||||
*/
|
||||
public function listTemplateAction(): Response
|
||||
{
|
||||
$templates = $this->invoiceRepository->getPagerfantaForQuery(new BaseQuery());
|
||||
$templates = $this->templateRepository->getPagerfantaForQuery(new BaseQuery());
|
||||
|
||||
return $this->render('invoice/templates.html.twig', [
|
||||
'entries' => $templates,
|
||||
@@ -361,7 +473,7 @@ final class InvoiceController extends AbstractController
|
||||
*/
|
||||
public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFrom): Response
|
||||
{
|
||||
if (!$this->invoiceRepository->hasTemplate()) {
|
||||
if (!$this->templateRepository->hasTemplate()) {
|
||||
$this->flashWarning('invoice.first_template');
|
||||
}
|
||||
|
||||
@@ -394,7 +506,7 @@ final class InvoiceController extends AbstractController
|
||||
public function deleteTemplate(InvoiceTemplate $template, Request $request): Response
|
||||
{
|
||||
try {
|
||||
$this->invoiceRepository->removeTemplate($template);
|
||||
$this->templateRepository->removeTemplate($template);
|
||||
$this->flashSuccess('action.delete.success');
|
||||
} catch (\Exception $ex) {
|
||||
$this->flashError('action.delete.error', ['%reason%' => $ex->getMessage()]);
|
||||
@@ -411,7 +523,7 @@ final class InvoiceController extends AbstractController
|
||||
|
||||
if ($editForm->isSubmitted() && $editForm->isValid()) {
|
||||
try {
|
||||
$this->invoiceRepository->saveTemplate($template);
|
||||
$this->templateRepository->saveTemplate($template);
|
||||
$this->flashSuccess('action.update.success');
|
||||
|
||||
return $this->redirectToRoute('admin_invoice_template');
|
||||
@@ -426,11 +538,13 @@ final class InvoiceController extends AbstractController
|
||||
]);
|
||||
}
|
||||
|
||||
protected function getToolbarForm(InvoiceQuery $query, string $method): FormInterface
|
||||
protected function getToolbarForm(InvoiceQuery $query, bool $simple): FormInterface
|
||||
{
|
||||
return $this->createForm(InvoiceToolbarForm::class, $query, [
|
||||
$form = $simple ? InvoiceToolbarSimpleForm::class : InvoiceToolbarForm::class;
|
||||
|
||||
return $this->createForm($form, $query, [
|
||||
'action' => $this->generateUrl('invoice', []),
|
||||
'method' => $method,
|
||||
'method' => 'GET',
|
||||
'include_user' => $this->isGranted('view_other_timesheet'),
|
||||
'attr' => [
|
||||
'id' => 'invoice-print-form'
|
||||
|
||||
@@ -20,6 +20,7 @@ use App\Form\Type\RoundingModeType;
|
||||
use App\Form\Type\SkinType;
|
||||
use App\Form\Type\TrackingModeType;
|
||||
use App\Form\Type\WeekDaysType;
|
||||
use App\Form\Type\YesNoType;
|
||||
use App\Repository\ConfigurationRepository;
|
||||
use App\Validator\Constraints\DateTimeFormat;
|
||||
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
|
||||
@@ -275,6 +276,22 @@ final class SystemConfigurationController extends AbstractController
|
||||
->setType(WeekDaysType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_FORM_INVOICE)
|
||||
->setConfiguration([
|
||||
(new Configuration())
|
||||
->setName('invoice.number_format')
|
||||
->setLabel('invoice.number_format')
|
||||
->setRequired(true)
|
||||
->setType(TextType::class) // TODO that should be a custom type with validation
|
||||
->setTranslationDomain('system-configuration'),
|
||||
(new Configuration())
|
||||
->setName('invoice.simple_form')
|
||||
->setLabel('simple_form')
|
||||
->setRequired(false)
|
||||
->setType(YesNoType::class)
|
||||
->setTranslationDomain('system-configuration'),
|
||||
]),
|
||||
(new SystemConfigurationModel())
|
||||
->setSection(SystemConfigurationModel::SECTION_FORM_CUSTOMER)
|
||||
->setConfiguration([
|
||||
|
||||
@@ -224,6 +224,12 @@ class Configuration implements ConfigurationInterface
|
||||
->scalarPrototype()->end()
|
||||
->defaultValue([])
|
||||
->end()
|
||||
->booleanNode('simple_form')
|
||||
->defaultTrue()
|
||||
->end()
|
||||
->scalarNode('number_format')
|
||||
->defaultValue('{Y}/{cy,3}')
|
||||
->end()
|
||||
->end()
|
||||
;
|
||||
|
||||
|
||||
290
src/Entity/Invoice.php
Normal file
290
src/Entity/Invoice.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use App\Invoice\InvoiceModel;
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Symfony\Bridge\Doctrine\Validator\Constraints\UniqueEntity;
|
||||
use Symfony\Component\Validator\Constraints as Assert;
|
||||
|
||||
/**
|
||||
* @ORM\Table(name="kimai2_invoices",
|
||||
* uniqueConstraints={
|
||||
* @ORM\UniqueConstraint(columns={"invoice_number"}),
|
||||
* @ORM\UniqueConstraint(columns={"invoice_filename"})
|
||||
* }
|
||||
* )
|
||||
* @UniqueEntity("invoiceNumber")
|
||||
* @UniqueEntity("invoiceFilename")
|
||||
*
|
||||
* @ORM\Entity(repositoryClass="App\Repository\InvoiceRepository")
|
||||
*/
|
||||
class Invoice
|
||||
{
|
||||
public const STATUS_PENDING = 'pending';
|
||||
public const STATUS_PAID = 'paid';
|
||||
public const STATUS_NEW = 'new';
|
||||
|
||||
/**
|
||||
* @var int|null
|
||||
*
|
||||
* @ORM\Column(name="id", type="integer")
|
||||
* @ORM\Id
|
||||
* @ORM\GeneratedValue(strategy="IDENTITY")
|
||||
*/
|
||||
private $id;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="invoice_number", type="string", length=50, nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $invoiceNumber;
|
||||
|
||||
/**
|
||||
* @var Customer|null
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\Customer")
|
||||
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $customer;
|
||||
|
||||
/**
|
||||
* @var User|null
|
||||
*
|
||||
* @ORM\ManyToOne(targetEntity="App\Entity\User")
|
||||
* @ORM\JoinColumn(onDelete="CASCADE", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $user;
|
||||
|
||||
/**
|
||||
* @var \DateTime
|
||||
*
|
||||
* @ORM\Column(name="created_at", type="datetime", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $createdAt;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="timezone", type="string", length=64, nullable=false)
|
||||
*/
|
||||
private $timezone;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="total", type="float", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $total = 0.00;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="tax", type="float", nullable=false)
|
||||
* @Assert\NotNull()
|
||||
*/
|
||||
private $tax = 0.00;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="currency", type="string", length=3, nullable=false)
|
||||
* @Assert\Length(max=3)
|
||||
*/
|
||||
private $currency;
|
||||
|
||||
/**
|
||||
* @var int
|
||||
*
|
||||
* @ORM\Column(name="due_days", type="integer", length=3, nullable=false)
|
||||
* @Assert\Range(min = 0, max = 999)
|
||||
*/
|
||||
private $dueDays = 30;
|
||||
|
||||
/**
|
||||
* @var float
|
||||
*
|
||||
* @ORM\Column(name="vat", type="float", nullable=false)
|
||||
* @Assert\Range(min = 0.0, max = 99.99)
|
||||
*/
|
||||
private $vat = 0.00;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="status", type="string", length=20, nullable=false)
|
||||
*/
|
||||
private $status = self::STATUS_NEW;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*
|
||||
* @ORM\Column(name="invoice_filename", type="string", length=100, nullable=false)
|
||||
*/
|
||||
private $invoiceFilename;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
private $localized = false;
|
||||
|
||||
public function getId(): ?int
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getCustomer(): ?Customer
|
||||
{
|
||||
return $this->customer;
|
||||
}
|
||||
|
||||
public function getUser(): ?User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function getInvoiceNumber(): ?string
|
||||
{
|
||||
return $this->invoiceNumber;
|
||||
}
|
||||
|
||||
public function getTotal(): float
|
||||
{
|
||||
return $this->total;
|
||||
}
|
||||
|
||||
public function getCreatedAt(): ?\DateTime
|
||||
{
|
||||
if (!$this->localized) {
|
||||
if (null !== $this->createdAt && null !== $this->timezone) {
|
||||
$this->createdAt->setTimeZone(new \DateTimeZone($this->timezone));
|
||||
}
|
||||
|
||||
$this->localized = true;
|
||||
}
|
||||
|
||||
return $this->createdAt;
|
||||
}
|
||||
|
||||
public function getDueDate(): ?\DateTime
|
||||
{
|
||||
if (null === $this->getCreatedAt()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$dueDate = clone $this->getCreatedAt();
|
||||
$dueDate->modify('+ ' . $this->dueDays . 'days');
|
||||
|
||||
return $dueDate;
|
||||
}
|
||||
|
||||
public function isOverdue(): bool
|
||||
{
|
||||
if (null === $this->getDueDate()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $this->getDueDate()->getTimestamp() < (new \DateTime('now', new \DateTimeZone($this->timezone)))->getTimestamp();
|
||||
}
|
||||
|
||||
public function setFilename(string $filename): Invoice
|
||||
{
|
||||
$this->invoiceFilename = $filename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setModel(InvoiceModel $model): Invoice
|
||||
{
|
||||
$this->customer = $model->getCustomer();
|
||||
$this->user = $model->getUser();
|
||||
$this->total = $model->getCalculator()->getTotal();
|
||||
$this->tax = $model->getCalculator()->getTax();
|
||||
$this->invoiceNumber = $model->getNumberGenerator()->getInvoiceNumber();
|
||||
$this->currency = $model->getCurrency();
|
||||
|
||||
$createdAt = $model->getInvoiceDate();
|
||||
$this->createdAt = $createdAt;
|
||||
$this->timezone = $createdAt->getTimezone()->getName();
|
||||
|
||||
$template = $model->getTemplate();
|
||||
$this->dueDays = $template->getDueDays();
|
||||
$this->vat = $template->getVat();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isNew(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_NEW;
|
||||
}
|
||||
|
||||
public function setIsNew(): Invoice
|
||||
{
|
||||
$this->status = self::STATUS_NEW;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPending(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PENDING;
|
||||
}
|
||||
|
||||
public function setIsPending(): Invoice
|
||||
{
|
||||
$this->status = self::STATUS_PENDING;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isPaid(): bool
|
||||
{
|
||||
return $this->status === self::STATUS_PAID;
|
||||
}
|
||||
|
||||
public function setIsPaid(): Invoice
|
||||
{
|
||||
$this->status = self::STATUS_PAID;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getDueDays(): int
|
||||
{
|
||||
return $this->dueDays;
|
||||
}
|
||||
|
||||
public function getVat(): float
|
||||
{
|
||||
return $this->vat;
|
||||
}
|
||||
|
||||
public function getTax(): float
|
||||
{
|
||||
return $this->tax;
|
||||
}
|
||||
|
||||
public function getCurrency(): ?string
|
||||
{
|
||||
return $this->currency;
|
||||
}
|
||||
|
||||
public function getInvoiceFilename(): ?string
|
||||
{
|
||||
return $this->invoiceFilename;
|
||||
}
|
||||
}
|
||||
@@ -170,6 +170,8 @@ class InvoiceTemplate
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
// ---- trait methods below ---
|
||||
|
||||
public function getTitle(): ?string
|
||||
{
|
||||
return $this->title;
|
||||
|
||||
@@ -14,20 +14,6 @@ use App\Invoice\InvoiceItemInterface;
|
||||
|
||||
interface ExportItemInterface extends InvoiceItemInterface
|
||||
{
|
||||
/**
|
||||
* A name representation for this type of export.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string;
|
||||
|
||||
/**
|
||||
* A name representation for the category of this type.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getCategory(): string;
|
||||
|
||||
/**
|
||||
* Whether this item was already exported.
|
||||
*
|
||||
|
||||
@@ -13,6 +13,7 @@ class SystemConfiguration
|
||||
{
|
||||
public const SECTION_ROUNDING = 'rounding';
|
||||
public const SECTION_TIMESHEET = 'timesheet';
|
||||
public const SECTION_FORM_INVOICE = 'invoice';
|
||||
public const SECTION_FORM_CUSTOMER = 'form_customer';
|
||||
public const SECTION_FORM_USER = 'form_user';
|
||||
public const SECTION_THEME = 'theme';
|
||||
|
||||
@@ -45,6 +45,9 @@ class SystemConfigurationForm extends AbstractType
|
||||
'csrf_protection' => true,
|
||||
'csrf_field_name' => '_token',
|
||||
'csrf_token_id' => 'edit_system_configurations',
|
||||
'attr' => [
|
||||
'data-form-event' => 'kimai.systemConfigUpdate'
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,53 +9,27 @@
|
||||
|
||||
namespace App\Form\Toolbar;
|
||||
|
||||
use App\Form\Type\InvoiceTemplateType;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Defines the form used for filtering timesheet entries for invoices.
|
||||
*/
|
||||
class InvoiceToolbarForm extends AbstractToolbarForm
|
||||
class InvoiceToolbarForm extends InvoiceToolbarSimpleForm
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
parent::buildForm($builder, $options);
|
||||
$this->addSearchTermInputField($builder);
|
||||
$this->addTemplateChoice($builder);
|
||||
if ($options['include_user']) {
|
||||
$this->addUsersChoice($builder);
|
||||
}
|
||||
$this->addDateRangeChoice($builder);
|
||||
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
|
||||
$this->addProjectChoice($builder, ['ignore_date' => true]);
|
||||
$this->addActivityChoice($builder);
|
||||
$this->addTagInputField($builder);
|
||||
$this->addExportStateChoice($builder);
|
||||
$builder->add('markAsExported', CheckboxType::class, [
|
||||
'label' => 'label.mark_as_exported',
|
||||
'required' => false,
|
||||
]);
|
||||
$builder->add('create', SubmitType::class, [
|
||||
'label' => 'button.print',
|
||||
'attr' => ['formtarget' => '_blank'],
|
||||
]);
|
||||
$builder->add('preview', SubmitType::class, [
|
||||
'label' => 'button.preview',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addTemplateChoice(FormBuilderInterface $builder)
|
||||
{
|
||||
$builder->add('template', InvoiceTemplateType::class, [
|
||||
'required' => true,
|
||||
'placeholder' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -63,10 +37,7 @@ class InvoiceToolbarForm extends AbstractToolbarForm
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => InvoiceQuery::class,
|
||||
'csrf_protection' => false,
|
||||
'include_user' => true,
|
||||
]);
|
||||
parent::configureOptions($resolver);
|
||||
$resolver->setDefault('include_user', true);
|
||||
}
|
||||
}
|
||||
|
||||
68
src/Form/Toolbar/InvoiceToolbarSimpleForm.php
Normal file
68
src/Form/Toolbar/InvoiceToolbarSimpleForm.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Form\Toolbar;
|
||||
|
||||
use App\Form\Type\InvoiceTemplateType;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use Symfony\Component\Form\Extension\Core\Type\CheckboxType;
|
||||
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
|
||||
use Symfony\Component\Form\FormBuilderInterface;
|
||||
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||
|
||||
/**
|
||||
* Defines the form used for filtering timesheet entries for invoices.
|
||||
*/
|
||||
class InvoiceToolbarSimpleForm extends AbstractToolbarForm
|
||||
{
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function buildForm(FormBuilderInterface $builder, array $options)
|
||||
{
|
||||
$this->addTemplateChoice($builder);
|
||||
$this->addDateRangeChoice($builder);
|
||||
$this->addCustomerChoice($builder, ['required' => true, 'start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true, 'placeholder' => '']);
|
||||
$this->addProjectChoice($builder, ['ignore_date' => true]);
|
||||
$builder->add('markAsExported', CheckboxType::class, [
|
||||
'label' => 'label.mark_as_exported',
|
||||
'required' => false,
|
||||
]);
|
||||
$builder->add('create', SubmitType::class, [
|
||||
'label' => 'action.save',
|
||||
]);
|
||||
$builder->add('print', SubmitType::class, [
|
||||
'label' => 'button.preview_print',
|
||||
'attr' => ['formtarget' => '_blank'],
|
||||
]);
|
||||
$builder->add('preview', SubmitType::class, [
|
||||
'label' => 'button.preview',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function addTemplateChoice(FormBuilderInterface $builder)
|
||||
{
|
||||
$builder->add('template', InvoiceTemplateType::class, [
|
||||
'required' => true,
|
||||
'placeholder' => null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritdoc}
|
||||
*/
|
||||
public function configureOptions(OptionsResolver $resolver)
|
||||
{
|
||||
$resolver->setDefaults([
|
||||
'data_class' => InvoiceQuery::class,
|
||||
'csrf_protection' => false,
|
||||
'include_user' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,7 @@ class InvoiceRendererType extends AbstractType
|
||||
foreach ($this->service->getRenderer() as $renderer) {
|
||||
if ($renderer->supports($document)) {
|
||||
$documents[$document->getId()] = $document->getName();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,13 +71,13 @@ class InvoiceRendererType extends AbstractType
|
||||
{
|
||||
$renderer = $label;
|
||||
|
||||
return ucfirst(
|
||||
substr(
|
||||
$renderer,
|
||||
1 + strrpos($renderer, '.'),
|
||||
strrpos($renderer, '.')
|
||||
)
|
||||
);
|
||||
$parts = explode('.', $renderer);
|
||||
|
||||
if (count($parts) > 2) {
|
||||
array_pop($parts);
|
||||
}
|
||||
|
||||
return ucfirst(array_pop($parts));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
namespace App\Invoice\Calculator;
|
||||
|
||||
use App\Entity\Timesheet;
|
||||
use App\Export\ExportItemInterface;
|
||||
use App\Invoice\InvoiceItem;
|
||||
use App\Invoice\InvoiceItemInterface;
|
||||
use App\Invoice\InvoiceItemWithAmountInterface;
|
||||
@@ -42,13 +41,8 @@ abstract class AbstractMergedCalculator extends AbstractCalculator
|
||||
$amount = $entry->getAmount();
|
||||
}
|
||||
|
||||
$type = Timesheet::TYPE_TIMESHEET;
|
||||
$category = Timesheet::CATEGORY_WORK;
|
||||
|
||||
if ($entry instanceof ExportItemInterface) {
|
||||
$type = $entry->getType();
|
||||
$category = $entry->getCategory();
|
||||
}
|
||||
$type = $entry->getType();
|
||||
$category = $entry->getCategory();
|
||||
|
||||
if (null !== $invoiceItem->getType() && $type !== $invoiceItem->getType()) {
|
||||
$type = self::TYPE_MIXED;
|
||||
|
||||
@@ -47,14 +47,23 @@ abstract class AbstractSumInvoiceCalculator extends AbstractMergedCalculator imp
|
||||
}
|
||||
$invoiceItem = $invoiceItems[$id];
|
||||
$this->mergeInvoiceItems($invoiceItem, $entry);
|
||||
$this->mergeSumTimesheet($invoiceItem, $entry);
|
||||
$this->mergeSumInvoiceItem($invoiceItem, $entry);
|
||||
}
|
||||
|
||||
return array_values($invoiceItems);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated since 1.9 - use mergeSumInvoiceItem() instead
|
||||
*/
|
||||
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
{
|
||||
// allows to set values per calculator after merging the timesheet
|
||||
}
|
||||
|
||||
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
{
|
||||
@trigger_error('mergeSumTimesheet() is deprecated and will be removed with 2.0', E_USER_DEPRECATED);
|
||||
|
||||
$this->mergeSumTimesheet($invoiceItem, $entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,19 +21,18 @@ class ActivityInvoiceCalculator extends AbstractSumInvoiceCalculator implements
|
||||
protected function calculateSumIdentifier(InvoiceItemInterface $invoiceItem): string
|
||||
{
|
||||
if (null === $invoiceItem->getActivity()) {
|
||||
throw new \Exception('Cannot work with invoice items that do not have an activity');
|
||||
}
|
||||
|
||||
if (null === $invoiceItem->getActivity()->getId()) {
|
||||
throw new \Exception('Cannot handle un-persisted activities');
|
||||
return '__NULL__';
|
||||
}
|
||||
|
||||
return (string) $invoiceItem->getActivity()->getId();
|
||||
}
|
||||
|
||||
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
{
|
||||
$invoiceItem->setActivity($entry->getActivity());
|
||||
if (null === $entry->getActivity()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$invoiceItem->setDescription($entry->getActivity()->getName());
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ class ProjectInvoiceCalculator extends AbstractSumInvoiceCalculator implements C
|
||||
return (string) $invoiceItem->getProject()->getId();
|
||||
}
|
||||
|
||||
protected function mergeSumTimesheet(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
protected function mergeSumInvoiceItem(InvoiceItem $invoiceItem, InvoiceItemInterface $entry)
|
||||
{
|
||||
$invoiceItem->setProject($entry->getProject());
|
||||
$invoiceItem->setDescription($entry->getProject()->getName());
|
||||
|
||||
49
src/Invoice/InvoiceFilename.php
Normal file
49
src/Invoice/InvoiceFilename.php
Normal file
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
|
||||
final class InvoiceFilename
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $filename;
|
||||
|
||||
public function __construct(InvoiceModel $model)
|
||||
{
|
||||
$filename = $model->getNumberGenerator()->getInvoiceNumber();
|
||||
|
||||
$filename = str_replace(['/', '\\'], '-', $filename);
|
||||
|
||||
$company = $model->getCustomer()->getCompany();
|
||||
if (empty($company)) {
|
||||
$company = $model->getCustomer()->getName();
|
||||
}
|
||||
|
||||
if (!empty($company)) {
|
||||
$company = new UnicodeString($company);
|
||||
$filename .= '-' . $company->snake();
|
||||
}
|
||||
|
||||
$this->filename = $filename;
|
||||
}
|
||||
|
||||
public function getFilename()
|
||||
{
|
||||
return $this->filename;
|
||||
}
|
||||
|
||||
public function __toString()
|
||||
{
|
||||
return $this->getFilename();
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,6 @@ use App\Entity\MetaTableTypeInterface;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\User;
|
||||
|
||||
/**
|
||||
* @deprecated will be removed with 2.0 - use ExportItemInterface instead
|
||||
*/
|
||||
interface InvoiceItemInterface
|
||||
{
|
||||
public function getActivity(): ?Activity;
|
||||
@@ -43,4 +40,18 @@ interface InvoiceItemInterface
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
public function getVisibleMetaFields(): array;
|
||||
|
||||
/**
|
||||
* A name representation for this type of item.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getType(): string;
|
||||
|
||||
/**
|
||||
* A name representation for the category of this item.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getCategory(): string;
|
||||
}
|
||||
|
||||
@@ -190,14 +190,18 @@ final class InvoiceModel
|
||||
return new \DateTime('+' . $this->getTemplate()->getDueDays() . ' days');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \DateTime
|
||||
*/
|
||||
public function getInvoiceDate(): \DateTime
|
||||
{
|
||||
return $this->invoiceDate;
|
||||
}
|
||||
|
||||
public function setInvoiceDate(\DateTime $date): InvoiceModel
|
||||
{
|
||||
$this->invoiceDate = $date;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setNumberGenerator(NumberGeneratorInterface $generator): InvoiceModel
|
||||
{
|
||||
$this->generator = $generator;
|
||||
|
||||
142
src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php
Normal file
142
src/Invoice/NumberGenerator/ConfigurableNumberGenerator.php
Normal file
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Invoice\NumberGenerator;
|
||||
|
||||
use App\Configuration\SystemConfiguration;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\NumberGeneratorInterface;
|
||||
use App\Repository\InvoiceRepository;
|
||||
|
||||
final class ConfigurableNumberGenerator implements NumberGeneratorInterface
|
||||
{
|
||||
/**
|
||||
* @var InvoiceModel
|
||||
*/
|
||||
private $model;
|
||||
/**
|
||||
* @var InvoiceRepository
|
||||
*/
|
||||
private $repository;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $format;
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $number;
|
||||
|
||||
public function __construct(InvoiceRepository $repository, SystemConfiguration $configuration)
|
||||
{
|
||||
$this->repository = $repository;
|
||||
$this->format = $configuration->find('invoice.number_format');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return 'default';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param InvoiceModel $model
|
||||
*/
|
||||
public function setModel(InvoiceModel $model)
|
||||
{
|
||||
$this->model = $model;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getInvoiceNumber(): string
|
||||
{
|
||||
if (null !== $this->number) {
|
||||
return $this->number;
|
||||
}
|
||||
|
||||
$format = $this->format;
|
||||
$invoiceDate = $this->model->getInvoiceDate();
|
||||
$timestamp = $invoiceDate->getTimestamp();
|
||||
$result = $format;
|
||||
|
||||
preg_match_all('/{[^}]*?}/', $format, $matches);
|
||||
foreach ($matches[0] as $part) {
|
||||
$formatter = null;
|
||||
$tmp = str_replace(['{', '}'], '', $part);
|
||||
|
||||
// number format
|
||||
if (substr_count($tmp, ',') !== 0) {
|
||||
$formatter = explode(',', $tmp);
|
||||
$tmp = $formatter[0];
|
||||
$formatter = $formatter[1];
|
||||
}
|
||||
|
||||
switch ($tmp) {
|
||||
case 'Y':
|
||||
$partialResult = date('Y', $timestamp);
|
||||
break;
|
||||
|
||||
case 'y':
|
||||
$partialResult = date('y', $timestamp);
|
||||
break;
|
||||
|
||||
case 'M':
|
||||
$partialResult = date('m', $timestamp);
|
||||
break;
|
||||
|
||||
case 'm':
|
||||
$partialResult = date('n', $timestamp);
|
||||
break;
|
||||
|
||||
case 'D':
|
||||
$partialResult = date('d', $timestamp);
|
||||
break;
|
||||
|
||||
case 'd':
|
||||
$partialResult = date('j', $timestamp);
|
||||
break;
|
||||
|
||||
case 'date':
|
||||
$partialResult = date('ymd', $timestamp);
|
||||
break;
|
||||
|
||||
case 'c':
|
||||
$partialResult = $this->repository->getCounterForAllTime($invoiceDate) + 1;
|
||||
break;
|
||||
|
||||
case 'cy':
|
||||
$partialResult = $this->repository->getCounterForYear($invoiceDate) + 1;
|
||||
break;
|
||||
|
||||
case 'cm':
|
||||
$partialResult = $this->repository->getCounterForMonth($invoiceDate) + 1;
|
||||
break;
|
||||
|
||||
case 'cd':
|
||||
$partialResult = $this->repository->getCounterForDay($invoiceDate) + 1;
|
||||
break;
|
||||
|
||||
default:
|
||||
$partialResult = $part;
|
||||
}
|
||||
|
||||
if (null !== $formatter) {
|
||||
$partialResult = str_pad($partialResult, $formatter, '0', STR_PAD_LEFT);
|
||||
}
|
||||
|
||||
$result = str_replace($part, $partialResult, $result);
|
||||
}
|
||||
|
||||
return $this->number = (string) $result;
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ class DateNumberGenerator implements NumberGeneratorInterface
|
||||
*/
|
||||
public function getId(): string
|
||||
{
|
||||
return 'default';
|
||||
return 'date';
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,10 +10,10 @@
|
||||
namespace App\Invoice\Renderer;
|
||||
|
||||
use App\Entity\InvoiceDocument;
|
||||
use App\Invoice\InvoiceFilename;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Component\String\UnicodeString;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
@@ -47,19 +47,7 @@ abstract class AbstractRenderer
|
||||
|
||||
protected function buildFilename(InvoiceModel $model): string
|
||||
{
|
||||
$filename = $model->getNumberGenerator()->getInvoiceNumber();
|
||||
|
||||
$company = $model->getCustomer()->getCompany();
|
||||
if (empty($company)) {
|
||||
$company = $model->getCustomer()->getName();
|
||||
}
|
||||
|
||||
if (!empty($company)) {
|
||||
$company = new UnicodeString($company);
|
||||
$filename .= '-' . $company->snake();
|
||||
}
|
||||
|
||||
return $filename;
|
||||
return (string) new InvoiceFilename($model);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
60
src/Invoice/Renderer/PdfRenderer.php
Normal file
60
src/Invoice/Renderer/PdfRenderer.php
Normal file
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Invoice\Renderer;
|
||||
|
||||
use App\Entity\InvoiceDocument;
|
||||
use App\Invoice\InvoiceFilename;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\RendererInterface;
|
||||
use App\Utils\HtmlToPdfConverter;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Twig\Environment;
|
||||
|
||||
final class PdfRenderer implements RendererInterface
|
||||
{
|
||||
/**
|
||||
* @var Environment
|
||||
*/
|
||||
private $twig;
|
||||
/**
|
||||
* @var HtmlToPdfConverter
|
||||
*/
|
||||
private $converter;
|
||||
|
||||
public function __construct(Environment $twig, HtmlToPdfConverter $converter)
|
||||
{
|
||||
$this->twig = $twig;
|
||||
$this->converter = $converter;
|
||||
}
|
||||
|
||||
public function supports(InvoiceDocument $document): bool
|
||||
{
|
||||
return stripos($document->getFilename(), '.pdf.twig') !== false;
|
||||
}
|
||||
|
||||
public function render(InvoiceDocument $document, InvoiceModel $model): Response
|
||||
{
|
||||
$content = $this->twig->render('@invoice/' . basename($document->getFilename()), [
|
||||
'model' => $model
|
||||
]);
|
||||
$content = $this->converter->convertToPdf($content);
|
||||
$filename = (string) new InvoiceFilename($model);
|
||||
|
||||
$response = new Response($content);
|
||||
|
||||
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename . '.pdf');
|
||||
|
||||
$response->headers->set('Content-Type', 'application/pdf');
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ final class TwigRenderer implements RendererInterface
|
||||
|
||||
$response = new Response();
|
||||
$response->setContent($content);
|
||||
$response->headers->set('Content-Type', 'text/html; charset=UTF-8');
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
|
||||
namespace App\Invoice;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceDocument;
|
||||
use App\Event\InvoicePostRenderEvent;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Utils\FileHelper;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* Service to manage invoice dependencies.
|
||||
@@ -37,10 +41,15 @@ final class ServiceInvoice
|
||||
* @var InvoiceDocumentRepository
|
||||
*/
|
||||
private $documents;
|
||||
/**
|
||||
* @var FileHelper
|
||||
*/
|
||||
private $fileHelper;
|
||||
|
||||
public function __construct(InvoiceDocumentRepository $repository)
|
||||
public function __construct(InvoiceDocumentRepository $repository, FileHelper $fileHelper)
|
||||
{
|
||||
$this->documents = $repository;
|
||||
$this->fileHelper = $fileHelper;
|
||||
}
|
||||
|
||||
public function addNumberGenerator(NumberGeneratorInterface $generator): ServiceInvoice
|
||||
@@ -141,4 +150,62 @@ final class ServiceInvoice
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function getInvoicesDirectory(): string
|
||||
{
|
||||
return $this->fileHelper->getDataSubdirectory('invoices');
|
||||
}
|
||||
|
||||
public function getInvoiceFile(Invoice $invoice): ?\SplFileInfo
|
||||
{
|
||||
$invoiceDirectory = $this->getInvoicesDirectory();
|
||||
$filename = $invoice->getInvoiceFilename();
|
||||
$full = $invoiceDirectory . $filename;
|
||||
|
||||
if (is_file($full) && is_readable($full)) {
|
||||
return new \SplFileInfo($full);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function saveGeneratedInvoice(InvoicePostRenderEvent $event): string
|
||||
{
|
||||
$invoiceDirectory = $this->getInvoicesDirectory();
|
||||
$filename = (string) new InvoiceFilename($event->getModel());
|
||||
|
||||
$response = $event->getResponse();
|
||||
|
||||
if ($event->getResponse()->headers->has('Content-Disposition')) {
|
||||
$disposition = $event->getResponse()->headers->get('Content-Disposition');
|
||||
$parts = explode(';', $disposition);
|
||||
foreach ($parts as $part) {
|
||||
if (stripos($part, 'filename=') === false) {
|
||||
continue;
|
||||
}
|
||||
$filename = explode('filename=', $part);
|
||||
if (count($filename) > 1) {
|
||||
$filename = $filename[1];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
$disposition = $event->getResponse()->headers->get('Content-Type');
|
||||
$parts = explode(';', $disposition);
|
||||
$parts = explode('/', $parts[0]);
|
||||
$filename .= '.' . $parts[1];
|
||||
}
|
||||
|
||||
if (is_file($invoiceDirectory . $filename)) {
|
||||
throw new \Exception(sprintf('Invoice "%s" already exists', $filename));
|
||||
}
|
||||
|
||||
if ($response instanceof BinaryFileResponse) {
|
||||
$file = $response->getFile();
|
||||
$file->move($invoiceDirectory, $filename);
|
||||
} else {
|
||||
$this->fileHelper->saveFile($invoiceDirectory . $filename, $event->getResponse()->getContent());
|
||||
}
|
||||
|
||||
return $filename;
|
||||
}
|
||||
}
|
||||
|
||||
55
src/Migrations/Version20200308171950.php
Normal file
55
src/Migrations/Version20200308171950.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use App\Doctrine\AbstractMigration;
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
|
||||
/**
|
||||
* @version 1.9
|
||||
*/
|
||||
final class Version20200308171950 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Create the invoice table';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$invoices = $schema->createTable('kimai2_invoices');
|
||||
|
||||
$invoices->addColumn('id', 'integer', ['autoincrement' => true, 'notnull' => true]);
|
||||
$invoices->addColumn('invoice_number', 'string', ['length' => 50, 'notnull' => true]);
|
||||
$invoices->addColumn('customer_id', 'integer', ['length' => 11, 'notnull' => true]);
|
||||
$invoices->addColumn('user_id', 'integer', ['length' => 11, 'notnull' => true]);
|
||||
$invoices->addColumn('created_at', 'datetime', ['notnull' => true]);
|
||||
$invoices->addColumn('timezone', 'string', ['length' => 64, 'notnull' => true]);
|
||||
$invoices->addColumn('total', 'float', ['notnull' => true]);
|
||||
$invoices->addColumn('tax', 'float', ['notnull' => true]);
|
||||
$invoices->addColumn('currency', 'string', ['length' => 3, 'notnull' => true]);
|
||||
$invoices->addColumn('status', 'string', ['length' => 20, 'notnull' => true]);
|
||||
$invoices->addColumn('due_days', 'integer', ['length' => 11, 'notnull' => true]);
|
||||
$invoices->addColumn('vat', 'float', ['notnull' => true]);
|
||||
$invoices->addColumn('invoice_filename', 'string', ['length' => 100, 'notnull' => true]);
|
||||
$invoices->addUniqueIndex(['invoice_number'], 'UNIQ_76C38E372DA68207');
|
||||
$invoices->addUniqueIndex(['invoice_filename'], 'UNIQ_76C38E372323B33D');
|
||||
$invoices->addForeignKeyConstraint('kimai2_users', ['user_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_76C38E37A76ED395');
|
||||
$invoices->addForeignKeyConstraint('kimai2_customers', ['customer_id'], ['id'], ['onDelete' => 'CASCADE'], 'FK_76C38E379395C3F3');
|
||||
$invoices->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('kimai2_invoices');
|
||||
}
|
||||
}
|
||||
172
src/Repository/InvoiceRepository.php
Normal file
172
src/Repository/InvoiceRepository.php
Normal file
@@ -0,0 +1,172 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Repository;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\User;
|
||||
use App\Repository\Loader\InvoiceLoader;
|
||||
use App\Repository\Paginator\LoaderPaginator;
|
||||
use App\Repository\Paginator\PaginatorInterface;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use Doctrine\ORM\EntityRepository;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use Pagerfanta\Pagerfanta;
|
||||
|
||||
class InvoiceRepository extends EntityRepository
|
||||
{
|
||||
public function saveInvoice(Invoice $invoice)
|
||||
{
|
||||
$entityManager = $this->getEntityManager();
|
||||
$entityManager->persist($invoice);
|
||||
$entityManager->flush();
|
||||
}
|
||||
|
||||
private function getCounterFor(\DateTime $start, \DateTime $end): int
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
$qb->select('count(i.createdAt) as counter')
|
||||
->from(Invoice::class, 'i')
|
||||
->andWhere($qb->expr()->gte('i.createdAt', ':start'))
|
||||
->andWhere($qb->expr()->lte('i.createdAt', ':end'))
|
||||
->setParameter('start', $start)
|
||||
->setParameter('end', $end)
|
||||
;
|
||||
|
||||
$result = $qb->getQuery()->getOneOrNullResult();
|
||||
|
||||
if ($result === null) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return $result['counter'];
|
||||
}
|
||||
|
||||
public function getCounterForDay(\DateTime $date): int
|
||||
{
|
||||
$start = (clone $date)->setTime(0, 0, 0);
|
||||
$end = (clone $date)->setTime(23, 59, 59);
|
||||
|
||||
return $this->getCounterFor($start, $end);
|
||||
}
|
||||
|
||||
public function getCounterForMonth(\DateTime $date): int
|
||||
{
|
||||
$start = (clone $date)->setDate($date->format('Y'), $date->format('n'), 1)->setTime(0, 0, 0);
|
||||
$end = (clone $date)->setDate($date->format('Y'), $date->format('n'), $date->format('t'))->setTime(23, 59, 59);
|
||||
|
||||
return $this->getCounterFor($start, $end);
|
||||
}
|
||||
|
||||
public function getCounterForYear(\DateTime $date): int
|
||||
{
|
||||
$start = (clone $date)->setDate($date->format('Y'), 1, 1)->setTime(0, 0, 0);
|
||||
$end = (clone $date)->setDate($date->format('Y'), 12, 31)->setTime(23, 59, 59);
|
||||
|
||||
return $this->getCounterFor($start, $end);
|
||||
}
|
||||
|
||||
public function getCounterForAllTime(\DateTime $date): int
|
||||
{
|
||||
return $this->count([]);
|
||||
}
|
||||
|
||||
private function addPermissionCriteria(QueryBuilder $qb, ?User $user = null, array $teams = [])
|
||||
{
|
||||
// make sure that all queries without a user see all projects
|
||||
if (null === $user && empty($teams)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure that admins see all projects
|
||||
if (null !== $user && ($user->isSuperAdmin() || $user->isAdmin())) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (null !== $user) {
|
||||
$teams = array_merge($teams, $user->getTeams()->toArray());
|
||||
}
|
||||
|
||||
$qb
|
||||
->leftJoin('i.customer', 'c')
|
||||
->leftJoin('c.teams', 'c_teams');
|
||||
|
||||
if (empty($teams)) {
|
||||
$qb->andWhere($qb->expr()->isNull('c_teams'));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$orCustomer = $qb->expr()->orX(
|
||||
$qb->expr()->isNull('c_teams'),
|
||||
$qb->expr()->isMemberOf(':teams', 'c.teams')
|
||||
);
|
||||
$qb->andWhere($orCustomer);
|
||||
|
||||
$qb->setParameter('teams', $teams);
|
||||
}
|
||||
|
||||
private function getQueryBuilderForQuery(InvoiceQuery $query): QueryBuilder
|
||||
{
|
||||
$qb = $this->getEntityManager()->createQueryBuilder();
|
||||
|
||||
$qb
|
||||
->select('i')
|
||||
->from(Invoice::class, 'i')
|
||||
;
|
||||
|
||||
$orderBy = $query->getOrderBy();
|
||||
switch ($orderBy) {
|
||||
case 'date':
|
||||
$orderBy = 'i.createdAt';
|
||||
break;
|
||||
}
|
||||
|
||||
$qb->addOrderBy($orderBy, $query->getOrder());
|
||||
|
||||
$this->addPermissionCriteria($qb, $query->getCurrentUser());
|
||||
|
||||
// this will make sure, that we do not accidentally create results with multiple rows
|
||||
// => which would result in a wrong LIMIT / pagination results
|
||||
// the second group by is needed due to SQL standard (even though logically not really required for this query)
|
||||
$qb->addGroupBy('i.id')->addGroupBy($orderBy);
|
||||
|
||||
return $qb;
|
||||
}
|
||||
|
||||
public function countInvoicesForQuery(InvoiceQuery $query): int
|
||||
{
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
$qb
|
||||
->resetDQLPart('select')
|
||||
->resetDQLPart('orderBy')
|
||||
->resetDQLPart('groupBy')
|
||||
->select($qb->expr()->countDistinct('i.id'))
|
||||
;
|
||||
|
||||
return (int) $qb->getQuery()->getSingleScalarResult();
|
||||
}
|
||||
|
||||
protected function getPaginatorForQuery(InvoiceQuery $query): PaginatorInterface
|
||||
{
|
||||
$counter = $this->countInvoicesForQuery($query);
|
||||
$qb = $this->getQueryBuilderForQuery($query);
|
||||
|
||||
return new LoaderPaginator(new InvoiceLoader($qb->getEntityManager()), $qb, $counter);
|
||||
}
|
||||
|
||||
public function getPagerfantaForQuery(InvoiceQuery $query): Pagerfanta
|
||||
{
|
||||
$paginator = new Pagerfanta($this->getPaginatorForQuery($query));
|
||||
$paginator->setMaxPerPage($query->getPageSize());
|
||||
$paginator->setCurrentPage($query->getPage());
|
||||
|
||||
return $paginator;
|
||||
}
|
||||
}
|
||||
55
src/Repository/Loader/InvoiceIdLoader.php
Normal file
55
src/Repository/Loader/InvoiceIdLoader.php
Normal file
@@ -0,0 +1,55 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Repository\Loader;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
final class InvoiceIdLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var EntityManagerInterface
|
||||
*/
|
||||
private $entityManager;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->entityManager = $entityManager;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int[] $ids
|
||||
*/
|
||||
public function loadResults(array $ids): void
|
||||
{
|
||||
if (empty($ids)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$em = $this->entityManager;
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$customer = $qb->select('PARTIAL i.{id}', 'customer')
|
||||
->from(Invoice::class, 'i')
|
||||
->leftJoin('i.customer', 'customer')
|
||||
->getQuery()
|
||||
->execute();
|
||||
|
||||
$qb = $em->createQueryBuilder();
|
||||
$user = $qb->select('PARTIAL i.{id}', 'user')
|
||||
->from(Invoice::class, 'i')
|
||||
->leftJoin('i.user', 'user')
|
||||
->getQuery()
|
||||
->execute();
|
||||
}
|
||||
}
|
||||
38
src/Repository/Loader/InvoiceLoader.php
Normal file
38
src/Repository/Loader/InvoiceLoader.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Repository\Loader;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
|
||||
final class InvoiceLoader implements LoaderInterface
|
||||
{
|
||||
/**
|
||||
* @var InvoiceIdLoader
|
||||
*/
|
||||
private $loader;
|
||||
|
||||
public function __construct(EntityManagerInterface $entityManager)
|
||||
{
|
||||
$this->loader = new InvoiceIdLoader($entityManager);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Invoice[] $invoices
|
||||
*/
|
||||
public function loadResults(array $invoices): void
|
||||
{
|
||||
$ids = array_map(function (Invoice $invoice) {
|
||||
return $invoice->getId();
|
||||
}, $invoices);
|
||||
|
||||
$this->loader->loadResults($ids);
|
||||
}
|
||||
}
|
||||
59
src/Utils/FileHelper.php
Normal file
59
src/Utils/FileHelper.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Utils;
|
||||
|
||||
final class FileHelper
|
||||
{
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $dataDir;
|
||||
|
||||
public function __construct(string $dataDir)
|
||||
{
|
||||
$this->dataDir = $dataDir;
|
||||
}
|
||||
|
||||
public function getDataSubdirectory(string $directory): string
|
||||
{
|
||||
$subDirectory = $this->dataDir . '/' . rtrim(ltrim($directory, '/'), '/') . '/';
|
||||
|
||||
$this->makeDir($subDirectory);
|
||||
|
||||
if (!is_dir($subDirectory)) {
|
||||
throw new \Exception(sprintf('Directory "%s" does not exist', $subDirectory));
|
||||
}
|
||||
|
||||
if (!is_writable($subDirectory)) {
|
||||
throw new \Exception(sprintf('Directory "%s" is not writable', $subDirectory));
|
||||
}
|
||||
|
||||
return $subDirectory;
|
||||
}
|
||||
|
||||
public function makeDir(string $directory)
|
||||
{
|
||||
if (is_dir($directory)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (false === @mkdir($directory)) {
|
||||
throw new \Exception(sprintf('Failed to create directory "%s", check file permissions', $directory));
|
||||
}
|
||||
}
|
||||
|
||||
public function saveFile(string $filename, $data)
|
||||
{
|
||||
$result = @file_put_contents($filename, $data);
|
||||
if ($result === false) {
|
||||
throw new \Exception('File "%s" could not be written');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,18 @@
|
||||
|
||||
{% set actions = {'visibility': '#modal_invoice'} %}
|
||||
|
||||
{% if is_granted('history_invoice') %}
|
||||
{% set actions = actions|merge({'list': path('admin_invoice_list')}) %}
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('manage_invoice_template') %}
|
||||
{% set actions = actions|merge({'invoice-template': path('admin_invoice_template')}) %}
|
||||
{% endif %}
|
||||
|
||||
{% if is_granted('system_configuration') %}
|
||||
{% set actions = actions|merge({'settings': {'url': path('system_configuration_section', {'section': 'invoice'}), 'class': 'modal-ajax-form'}}) %}
|
||||
{% endif %}
|
||||
|
||||
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
|
||||
|
||||
{% set event = trigger('actions.invoices', {'actions': actions, 'view': view}) %}
|
||||
@@ -37,6 +45,46 @@
|
||||
{{ widgets.page_actions(actions) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro invoice(invoice) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
|
||||
{% set actions = {} %}
|
||||
|
||||
{% if is_granted('history_invoice') %}
|
||||
{% if invoice.new %}
|
||||
{% set actions = actions|merge({'invoice.pending': path('admin_invoice_status', {'id': invoice.id, 'status': 'pending'})}) %}
|
||||
{% elseif invoice.pending %}
|
||||
{% set actions = actions|merge({'invoice.paid': path('admin_invoice_status', {'id': invoice.id, 'status': 'paid'})}) %}
|
||||
{% endif %}
|
||||
|
||||
{% if actions|length > 0 %}
|
||||
{% set actions = actions|merge({'divider': null}) %}
|
||||
{% endif %}
|
||||
|
||||
{% set actions = actions|merge({'download': {'url': path('admin_invoice_download', {'id': invoice.id}), 'target': '_blank'}}) %}
|
||||
{% endif %}
|
||||
|
||||
{% set event = trigger('actions.invoice', {'actions': actions, 'invoice': invoice}) %}
|
||||
{{ widgets.table_actions(actions) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro invoice_listing(view) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
|
||||
{% set actions = {} %}
|
||||
|
||||
{% if is_granted('view_invoice') %}
|
||||
{% set actions = actions|merge({'back': path('invoice')}) %}
|
||||
{% endif %}
|
||||
|
||||
{% set actions = actions|merge({'visibility': '#modal_invoices'}) %}
|
||||
|
||||
{% set actions = actions|merge({'help': {'url': 'invoices.html'|docu_link, 'target': '_blank'}}) %}
|
||||
|
||||
{% set event = trigger('actions.invoice_details', {'actions': actions, 'view': view}) %}
|
||||
{{ widgets.page_actions(actions) }}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro invoice_upload(view) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
|
||||
|
||||
@@ -5,20 +5,19 @@
|
||||
{% import "invoice/actions.html.twig" as actions %}
|
||||
|
||||
{% set columns = {
|
||||
'date': 'alwaysVisible',
|
||||
'user': 'hidden-xs hidden-sm',
|
||||
'project': 'hidden-xs hidden-sm',
|
||||
'description': 'hidden-xs hidden-sm',
|
||||
'unit_price': 'hidden-xs text-center',
|
||||
'amount': 'text-center',
|
||||
'duration': 'hidden-xs text-center',
|
||||
'total_rate': 'text-right alwaysVisible',
|
||||
'date': {'class': 'alwaysVisible', 'orderBy': false},
|
||||
'user': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
|
||||
'project': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
|
||||
'description': {'class': 'hidden-xs hidden-sm', 'orderBy': false},
|
||||
'unit_price': {'class': 'hidden-xs text-center', 'orderBy': false},
|
||||
'amount': {'class': 'text-center', 'orderBy': false},
|
||||
'duration': {'class': 'hidden-xs text-center', 'orderBy': false},
|
||||
'total_rate': {'class': 'text-right alwaysVisible', 'orderBy': false},
|
||||
} %}
|
||||
|
||||
{% set tableName = 'invoice' %}
|
||||
|
||||
{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %}
|
||||
{% block page_subtitle %}{{ 'invoice.subtitle'|trans }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.invoices('index') }}{% endblock %}
|
||||
|
||||
{% block main_before %}
|
||||
@@ -34,21 +33,34 @@
|
||||
{% block box_before %}{{ form_start(form) }}{% endblock %}
|
||||
{% block box_body %}
|
||||
{{ form_errors(form) }}
|
||||
{{ form_row(form.searchTerm) }}
|
||||
{% if form.searchTerm is defined %}
|
||||
{{ form_row(form.searchTerm) }}
|
||||
{% endif %}
|
||||
{{ form_row(form.daterange) }}
|
||||
{{ form_row(form.customer) }}
|
||||
{{ form_row(form.project) }}
|
||||
{{ form_row(form.activity) }}
|
||||
{% if form.activity is defined %}
|
||||
{{ form_row(form.activity) }}
|
||||
{% endif %}
|
||||
{% if form.users is defined %}
|
||||
{{ form_row(form.users) }}
|
||||
{% endif %}
|
||||
{{ form_row(form.tags) }}
|
||||
{{ form_row(form.exported) }}
|
||||
{% if form.tags is defined %}
|
||||
{{ form_row(form.tags) }}
|
||||
{% endif %}
|
||||
{% if form.exported is defined %}
|
||||
{{ form_row(form.exported) }}
|
||||
{% endif %}
|
||||
{{ form_row(form.template) }}
|
||||
{{ form_row(form.markAsExported) }}
|
||||
{% endblock %}
|
||||
{% block box_footer%}
|
||||
{{ form_widget(form.create, {'attr': {'class': 'btn btn-success'}}) }}
|
||||
{% set createAttr = {'class': 'btn btn-success'} %}
|
||||
{% if not is_granted('history_invoice') %}
|
||||
{% set createAttr = createAttr|merge({'formtarget': '_blank'}) %}
|
||||
{% endif %}
|
||||
{{ form_widget(form.create, {'attr': createAttr}) }}
|
||||
{{ form_widget(form.print) }}
|
||||
{{ form_widget(form.preview) }}
|
||||
{% endblock %}
|
||||
{% block box_after %}{{ form_end(form) }}{% endblock %}
|
||||
@@ -96,8 +108,26 @@
|
||||
<td class="text-right text-nowrap">{{ entry.rate|money(model.calculator.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
<tr>
|
||||
<th></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'user') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'project') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'description') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'amount') }}"></th>
|
||||
<th class="{{ tables.data_table_column_class(tableName, columns, 'duration') }} text-center text-nowrap">{{ model.calculator.timeWorked|duration(isDecimal) }}</th>
|
||||
<th class="text-right text-nowrap">{{ model.calculator.total|money(model.calculator.currency) }}</th>
|
||||
</tr>
|
||||
{{ tables.data_table_footer(entries) }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
KimaiReloadPageWidget.create('kimai.systemConfigUpdate', true);
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
62
templates/invoice/listing.html.twig
Normal file
62
templates/invoice/listing.html.twig
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends 'base.html.twig' %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% import "macros/toolbar.html.twig" as toolbar %}
|
||||
{% import "macros/datatables.html.twig" as tables %}
|
||||
{% import "invoice/actions.html.twig" as actions %}
|
||||
{% import "invoice/macros.html.twig" as macros %}
|
||||
|
||||
{% set columns = {
|
||||
'date': {'class': 'alwaysVisible text-nowrap', 'orderBy': false},
|
||||
'user': {'class': 'hidden-xs hidden-sm text-nowrap hidden', 'orderBy': false},
|
||||
'customer': {'class': 'hidden-xs hidden-sm text-nowrap', 'orderBy': false},
|
||||
'invoice_number': {'class': 'hidden-xs hidden-sm text-nowrap', 'title': 'invoice.number'|trans, 'orderBy': false},
|
||||
'due_date': {'class': 'hidden-xs text-nowrap', 'title': 'invoice.due_days'|trans, 'orderBy': false},
|
||||
'status': {'class': 'text-center alwaysVisible text-nowrap', 'orderBy': false},
|
||||
'tax': {'class': 'hidden-xs text-center text-nowrap hidden', 'title': 'invoice.tax'|trans, 'orderBy': false},
|
||||
'total_rate': {'class': 'hidden-xs text-center text-nowrap', 'orderBy': false},
|
||||
'actions': {'class': 'actions alwaysVisible', 'orderBy': false},
|
||||
} %}
|
||||
|
||||
{% set tableName = 'invoices' %}
|
||||
|
||||
{% block page_title %}{{ 'invoice.title'|trans }}{% endblock %}
|
||||
{% block page_actions %}{{ actions.invoice_listing('index') }}{% endblock %}
|
||||
|
||||
{% block main_before %}
|
||||
{{ tables.data_table_column_modal(tableName, columns) }}
|
||||
{% endblock %}
|
||||
|
||||
{% block main %}
|
||||
|
||||
{% if entries is empty %}
|
||||
{{ widgets.callout('warning', 'error.no_entries_found') }}
|
||||
{% else %}
|
||||
{{ tables.datatable_header(tableName, columns, query, {}) }}
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'date') }}">{{ entry.createdAt|date_short }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'user') }}">{{ widgets.user_avatar(entry.user) }} {{ widgets.username(entry.user) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'customer') }}">{{ widgets.label_customer(entry.customer) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'invoice_number') }}">{{ widgets.label(entry.invoiceNumber, 'default') }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'due_date') }}">{{ macros.invoice_due_date(entry) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'status') }}">{{ macros.invoice_status(entry) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'tax') }}">{{ entry.tax|money(entry.currency) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'total_rate') }}">{{ entry.total|money(entry.currency) }}</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'actions') }}">{{ actions.invoice(entry) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{{ tables.data_table_footer(entries, 'admin_invoice_list') }}
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{{ parent() }}
|
||||
{% if download is not null %}
|
||||
<script type="text/javascript">
|
||||
document.addEventListener('kimai.initialized', function() {
|
||||
location.href = '{{ path('admin_invoice_download', {'id': download.id}) }}';
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
25
templates/invoice/macros.html.twig
Normal file
25
templates/invoice/macros.html.twig
Normal file
@@ -0,0 +1,25 @@
|
||||
|
||||
{% macro invoice_status(invoice) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% set overdue = invoice.overdue %}
|
||||
{% if invoice.new and overdue %}
|
||||
{{ widgets.label('status.new'|trans, 'danger') }}
|
||||
{% elseif invoice.new %}
|
||||
{{ widgets.label('status.new'|trans, 'primary') }}
|
||||
{% elseif invoice.pending and overdue %}
|
||||
{{ widgets.label('status.pending'|trans, 'danger') }}
|
||||
{% elseif invoice.pending %}
|
||||
{{ widgets.label('status.pending'|trans, 'warning') }}
|
||||
{% elseif invoice.paid %}
|
||||
{{ widgets.label('status.paid'|trans, 'success') }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
|
||||
{% macro invoice_due_date(invoice) %}
|
||||
{% import "macros/widgets.html.twig" as widgets %}
|
||||
{% if invoice.overdue and not invoice.paid %}
|
||||
{{ widgets.label(invoice.dueDate|date_short, 'danger') }}
|
||||
{% else %}
|
||||
{{ widgets.label(invoice.dueDate|date_short, 'primary') }}
|
||||
{% endif %}
|
||||
{% endmacro %}
|
||||
@@ -62,6 +62,11 @@
|
||||
{{ form_row(form.numberGenerator) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{{ form_row(form.decimalDuration) }}
|
||||
</div>
|
||||
</div>
|
||||
{{ form_widget(form) }}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
@@ -321,12 +321,12 @@ abstract class ControllerBaseTest extends WebTestCase
|
||||
*/
|
||||
protected function assertIsRedirect(HttpKernelBrowser $client, $url = null)
|
||||
{
|
||||
self::assertTrue($client->getResponse()->isRedirect());
|
||||
self::assertTrue($client->getResponse()->isRedirect(), 'Response is not a redirect');
|
||||
if (null === $url) {
|
||||
return;
|
||||
}
|
||||
|
||||
self::assertTrue($client->getResponse()->headers->has('Location'));
|
||||
self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location'));
|
||||
self::assertTrue($client->getResponse()->headers->has('Location'), 'Could not find "Location" header');
|
||||
self::assertStringEndsWith($url, $client->getResponse()->headers->get('Location'), 'Redirect URL does not match');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,12 +16,44 @@ use App\Form\Type\DateRangeType;
|
||||
use App\Tests\DataFixtures\InvoiceFixtures;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
*/
|
||||
class InvoiceControllerTest extends ControllerBaseTest
|
||||
{
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$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();
|
||||
}
|
||||
|
||||
private function clearInvoiceFiles()
|
||||
{
|
||||
$path = __DIR__ . '/../_data/invoices/';
|
||||
|
||||
if (is_dir($path)) {
|
||||
$files = glob($path . '*');
|
||||
foreach ($files as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function testIsSecure()
|
||||
{
|
||||
$this->assertUrlIsSecured('/invoice/');
|
||||
@@ -76,7 +108,6 @@ class InvoiceControllerTest extends ControllerBaseTest
|
||||
'company' => 'Company name',
|
||||
'renderer' => 'default',
|
||||
'calculator' => 'default',
|
||||
'numberGenerator' => 'default',
|
||||
]
|
||||
]);
|
||||
|
||||
@@ -111,7 +142,6 @@ class InvoiceControllerTest extends ControllerBaseTest
|
||||
$this->assertEquals($template->getCompany(), $values['company']);
|
||||
$this->assertEquals($template->getAddress(), $values['address']);
|
||||
$this->assertEquals($template->getPaymentTerms(), $values['paymentTerms']);
|
||||
$this->assertEquals($template->getNumberGenerator(), $values['numberGenerator']);
|
||||
}
|
||||
|
||||
public function testPrintAction()
|
||||
@@ -153,8 +183,8 @@ class InvoiceControllerTest extends ControllerBaseTest
|
||||
// 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
|
||||
$this->assertDataTableRowCount($client, 'datatable_invoice', 20);
|
||||
// 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();
|
||||
@@ -180,6 +210,92 @@ class InvoiceControllerTest extends ControllerBaseTest
|
||||
}
|
||||
}
|
||||
|
||||
public function testPrintActionAsAdminWithDownloadAndStatusChange()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
/** @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_ADMIN))
|
||||
->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/?create='));
|
||||
$node->setAttribute('method', 'GET');
|
||||
$client->submit($form, [
|
||||
'template' => 1,
|
||||
'daterange' => $dateRange,
|
||||
'customer' => 1,
|
||||
'project' => 1,
|
||||
'markAsExported' => 1,
|
||||
]);
|
||||
|
||||
$this->assertIsRedirect($client, '/invoice/show?id=1');
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->assertHasFlashSuccess($client);
|
||||
|
||||
$this->assertHasDataTable($client);
|
||||
$this->assertDataTableRowCount($client, 'datatable_invoices', 1);
|
||||
|
||||
// make sure the invoice is saved
|
||||
$this->request($client, '/invoice/download/1');
|
||||
$response = $client->getResponse();
|
||||
$this->assertTrue($response->isSuccessful());
|
||||
self::assertInstanceOf(BinaryFileResponse::class, $response);
|
||||
self::assertFileExists($response->getFile());
|
||||
|
||||
$this->request($client, '/invoice/change-status/1/pending');
|
||||
$this->assertIsRedirect($client, '/invoice/show');
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->request($client, '/invoice/change-status/1/paid');
|
||||
$this->assertIsRedirect($client, '/invoice/show');
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
|
||||
$this->request($client, '/invoice/change-status/1/new');
|
||||
$this->assertIsRedirect($client, '/invoice/show');
|
||||
$client->followRedirect();
|
||||
$this->assertTrue($client->getResponse()->isSuccessful());
|
||||
}
|
||||
|
||||
public function testEditTemplateAction()
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
|
||||
@@ -196,7 +312,6 @@ class InvoiceControllerTest extends ControllerBaseTest
|
||||
'company' => 'Company name',
|
||||
'renderer' => 'default',
|
||||
'calculator' => 'default',
|
||||
'numberGenerator' => 'default',
|
||||
]
|
||||
]);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class PermissionControllerTest extends ControllerBaseTest
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$this->assertAccessIsGranted($client, '/admin/permissions');
|
||||
$this->assertHasDataTable($client);
|
||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 108);
|
||||
$this->assertDataTableRowCount($client, 'datatable_user_admin_permissions', 109);
|
||||
$this->assertPageActions($client, [
|
||||
'back' => $this->createUrl('/admin/user/'),
|
||||
'roles modal-ajax-form' => $this->createUrl('/admin/permissions/roles/create'),
|
||||
|
||||
@@ -68,6 +68,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
|
||||
{
|
||||
return [
|
||||
['form[name=system_configuration_form_timesheet]', $this->createUrl('/admin/system-config/update/timesheet')],
|
||||
['form[name=system_configuration_form_invoice]', $this->createUrl('/admin/system-config/update/invoice')],
|
||||
['form[name=system_configuration_form_rounding]', $this->createUrl('/admin/system-config/update/rounding')],
|
||||
['form[name=system_configuration_form_form_customer]', $this->createUrl('/admin/system-config/update/form_customer')],
|
||||
['form[name=system_configuration_form_form_user]', $this->createUrl('/admin/system-config/update/form_user')],
|
||||
|
||||
@@ -289,6 +289,8 @@ class ConfigurationTest extends TestCase
|
||||
0 => 'var/invoices/',
|
||||
1 => 'templates/invoice/renderer/',
|
||||
],
|
||||
'simple_form' => true,
|
||||
'number_format' => '{Y}/{cy,3}',
|
||||
],
|
||||
'languages' => [],
|
||||
'calendar' => [
|
||||
|
||||
169
tests/Entity/InvoiceTest.php
Normal file
169
tests/Entity/InvoiceTest.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?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\Entity;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\CustomerMeta;
|
||||
use App\Entity\Invoice;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Entity\Project;
|
||||
use App\Entity\ProjectMeta;
|
||||
use App\Entity\Timesheet;
|
||||
use App\Entity\User;
|
||||
use App\Entity\UserPreference;
|
||||
use App\Invoice\Calculator\DefaultCalculator;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\NumberGenerator\DateNumberGenerator;
|
||||
use App\Repository\Query\InvoiceQuery;
|
||||
use App\Tests\Invoice\DebugFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Entity\Invoice
|
||||
*/
|
||||
class InvoiceTest extends TestCase
|
||||
{
|
||||
public function testDefaultValues()
|
||||
{
|
||||
$sut = new Invoice();
|
||||
self::assertNull($sut->getCreatedAt());
|
||||
self::assertNull($sut->getCurrency());
|
||||
self::assertNull($sut->getCustomer());
|
||||
self::assertNull($sut->getDueDate());
|
||||
self::assertEquals(30, $sut->getDueDays());
|
||||
self::assertNull($sut->getId());
|
||||
self::assertNull($sut->getInvoiceFilename());
|
||||
self::assertNull($sut->getInvoiceNumber());
|
||||
self::assertEquals(0.0, $sut->getTax());
|
||||
self::assertEquals(0.0, $sut->getTotal());
|
||||
self::assertNull($sut->getUser());
|
||||
self::assertEquals(0.0, $sut->getVat());
|
||||
self::assertTrue($sut->isNew());
|
||||
self::assertFalse($sut->isPending());
|
||||
self::assertFalse($sut->isPaid());
|
||||
self::assertFalse($sut->isOverdue());
|
||||
}
|
||||
|
||||
public function testSetterAndGetter()
|
||||
{
|
||||
$date = new \DateTime('-2 months');
|
||||
$sut = new Invoice();
|
||||
|
||||
$sut->setIsPending();
|
||||
self::assertFalse($sut->isNew());
|
||||
self::assertTrue($sut->isPending());
|
||||
self::assertFalse($sut->isPaid());
|
||||
|
||||
$sut->setIsPaid();
|
||||
self::assertFalse($sut->isNew());
|
||||
self::assertFalse($sut->isPending());
|
||||
self::assertTrue($sut->isPaid());
|
||||
|
||||
$sut->setIsNew();
|
||||
self::assertTrue($sut->isNew());
|
||||
self::assertFalse($sut->isPending());
|
||||
self::assertFalse($sut->isPaid());
|
||||
self::assertFalse($sut->isOverdue());
|
||||
|
||||
$sut->setModel($this->getInvoiceModel($date));
|
||||
self::assertTrue($sut->isOverdue());
|
||||
|
||||
self::assertEquals($date, $sut->getCreatedAt());
|
||||
self::assertEquals('USD', $sut->getCurrency());
|
||||
self::assertNotNull($sut->getCustomer());
|
||||
self::assertNotNull($sut->getDueDate());
|
||||
self::assertEquals(9, $sut->getDueDays());
|
||||
self::assertNull($sut->getId());
|
||||
self::assertNull($sut->getInvoiceFilename());
|
||||
self::assertEquals(date('ymd', $date->getTimestamp()), $sut->getInvoiceNumber());
|
||||
self::assertEquals(55.72, $sut->getTax());
|
||||
self::assertEquals(348.99, $sut->getTotal());
|
||||
self::assertNotNull($sut->getUser());
|
||||
self::assertEquals(19, $sut->getVat());
|
||||
}
|
||||
|
||||
protected function getInvoiceModel(\DateTime $created): InvoiceModel
|
||||
{
|
||||
$user = new User();
|
||||
$user->setUsername('one-user');
|
||||
$user->setTitle('user title');
|
||||
$user->setAlias('genious alias');
|
||||
$user->setEmail('fantastic@four');
|
||||
$user->addPreference((new UserPreference())->setName('kitty')->setValue('kat'));
|
||||
$user->addPreference((new UserPreference())->setName('hello')->setValue('world'));
|
||||
|
||||
$customer = new Customer();
|
||||
$customer->setName('customer,with/special#name');
|
||||
$customer->setCurrency('USD');
|
||||
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
|
||||
$customer->setVatId('kjuo8967');
|
||||
|
||||
$template = new InvoiceTemplate();
|
||||
$template->setTitle('a test invoice template title');
|
||||
$template->setVat(19);
|
||||
$template->setDueDays(9);
|
||||
|
||||
$project = new Project();
|
||||
$project->setName('project name');
|
||||
$project->setCustomer($customer);
|
||||
$project->setMetaField((new ProjectMeta())->setName('foo-project')->setValue('bar-project')->setIsVisible(true));
|
||||
|
||||
$activity = new Activity();
|
||||
$activity->setName('activity description');
|
||||
$activity->setProject($project);
|
||||
$activity->setMetaField((new ActivityMeta())->setName('foo-activity')->setValue('bar-activity')->setIsVisible(true));
|
||||
|
||||
$userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
|
||||
$user1 = $this->getMockBuilder(User::class)->onlyMethods($userMethods)->disableOriginalConstructor()->getMock();
|
||||
$user1->method('getId')->willReturn(1);
|
||||
$user1->method('getPreferenceValue')->willReturn('50');
|
||||
$user1->method('getUsername')->willReturn('foo-bar');
|
||||
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet
|
||||
->setDuration(3600)
|
||||
->setRate(293.27)
|
||||
->setUser($user1)
|
||||
->setActivity($activity)
|
||||
->setProject($project)
|
||||
->setBegin(new \DateTime())
|
||||
->setEnd(new \DateTime())
|
||||
;
|
||||
|
||||
$entries = [$timesheet];
|
||||
|
||||
$query = new InvoiceQuery();
|
||||
$query->setActivity($activity);
|
||||
$query->setBegin(new \DateTime());
|
||||
$query->setEnd(new \DateTime());
|
||||
|
||||
$model = new InvoiceModel(new DebugFormatter());
|
||||
$model->setCustomer($customer);
|
||||
$model->setTemplate($template);
|
||||
$model->addEntries($entries);
|
||||
$model->setQuery($query);
|
||||
$model->setUser($user);
|
||||
$model->setInvoiceDate($created);
|
||||
|
||||
$calculator = new DefaultCalculator();
|
||||
$calculator->setModel($model);
|
||||
|
||||
$model->setCalculator($calculator);
|
||||
|
||||
$numberGenerator = new DateNumberGenerator();
|
||||
$numberGenerator->setModel($model);
|
||||
|
||||
$model->setNumberGenerator($numberGenerator);
|
||||
|
||||
return $model;
|
||||
}
|
||||
}
|
||||
@@ -33,33 +33,6 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
|
||||
$this->assertEmptyModel(new ActivityInvoiceCalculator());
|
||||
}
|
||||
|
||||
public function testExceptionNoActivity()
|
||||
{
|
||||
$this->expectException('Exception');
|
||||
$this->expectExceptionMessage('Cannot work with invoice items that do not have an activity');
|
||||
$timesheet = new Timesheet();
|
||||
|
||||
$sut = new ActivityInvoiceCalculator();
|
||||
$model = $this->getEmptyModel();
|
||||
$model->addEntries([$timesheet]);
|
||||
$sut->setModel($model);
|
||||
$sut->getEntries();
|
||||
}
|
||||
|
||||
public function testExceptionNoId()
|
||||
{
|
||||
$this->expectException('Exception');
|
||||
$this->expectExceptionMessage('Cannot handle un-persisted activities');
|
||||
$timesheet = new Timesheet();
|
||||
$timesheet->setActivity(new Activity());
|
||||
|
||||
$sut = new ActivityInvoiceCalculator();
|
||||
$model = $this->getEmptyModel();
|
||||
$model->addEntries([$timesheet]);
|
||||
$sut->setModel($model);
|
||||
$sut->getEntries();
|
||||
}
|
||||
|
||||
public function testWithMultipleEntries()
|
||||
{
|
||||
$customer = new Customer();
|
||||
@@ -128,7 +101,35 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
|
||||
->setActivity($activity3)
|
||||
->setProject((new Project())->setName('bar'));
|
||||
|
||||
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
|
||||
$timesheet6 = new Timesheet();
|
||||
$timesheet6
|
||||
->setBegin(new \DateTime())
|
||||
->setEnd(new \DateTime())
|
||||
->setDuration(0)
|
||||
->setRate(0)
|
||||
->setUser(new User())
|
||||
->setProject((new Project())->setName('bar'));
|
||||
|
||||
$timesheet7 = new Timesheet();
|
||||
$timesheet7
|
||||
->setBegin(new \DateTime())
|
||||
->setEnd(new \DateTime())
|
||||
->setDuration(0)
|
||||
->setRate(0)
|
||||
->setUser(new User())
|
||||
->setActivity(new Activity())
|
||||
->setProject((new Project())->setName('bar'));
|
||||
|
||||
$timesheet8 = new Timesheet();
|
||||
$timesheet8
|
||||
->setBegin(new \DateTime())
|
||||
->setEnd(new \DateTime())
|
||||
->setDuration(0)
|
||||
->setRate(0)
|
||||
->setUser(new User())
|
||||
->setProject((new Project())->setName('bar'));
|
||||
|
||||
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5, $timesheet6, $timesheet7, $timesheet8];
|
||||
|
||||
$query = new InvoiceQuery();
|
||||
$query->setActivity($activity1);
|
||||
@@ -148,7 +149,7 @@ class ActivityInvoiceCalculatorTest extends AbstractCalculatorTest
|
||||
$this->assertEquals('EUR', $model->getCurrency());
|
||||
$this->assertEquals(2521.12, $sut->getSubtotal());
|
||||
$this->assertEquals(6600, $sut->getTimeWorked());
|
||||
$this->assertEquals(3, count($sut->getEntries()));
|
||||
$this->assertEquals(5, count($sut->getEntries()));
|
||||
|
||||
$entries = $sut->getEntries();
|
||||
$this->assertEquals(404.38, $entries[0]->getRate());
|
||||
|
||||
53
tests/Invoice/InvoiceFilenameTest.php
Normal file
53
tests/Invoice/InvoiceFilenameTest.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?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;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Entity\InvoiceTemplate;
|
||||
use App\Invoice\InvoiceFilename;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\NumberGenerator\DateNumberGenerator;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Invoice\InvoiceFilename
|
||||
*/
|
||||
class InvoiceFilenameTest extends TestCase
|
||||
{
|
||||
public function testInvoiceFilename()
|
||||
{
|
||||
$customer = new Customer();
|
||||
$template = new InvoiceTemplate();
|
||||
|
||||
$model = new InvoiceModel(new DebugFormatter());
|
||||
$model->setNumberGenerator(new DateNumberGenerator());
|
||||
$model->setTemplate($template);
|
||||
$model->setCustomer($customer);
|
||||
|
||||
$datePrefix = date('ymd');
|
||||
|
||||
$sut = new InvoiceFilename($model);
|
||||
|
||||
self::assertEquals($datePrefix, $sut->getFilename());
|
||||
self::assertEquals($datePrefix, (string) $sut);
|
||||
|
||||
$customer->setName('foo');
|
||||
$sut = new InvoiceFilename($model);
|
||||
|
||||
self::assertEquals($datePrefix . '-foo', $sut->getFilename());
|
||||
self::assertEquals($datePrefix . '-foo', (string) $sut);
|
||||
|
||||
$customer->setCompany('barß / laölala # ldksjf 123');
|
||||
$sut = new InvoiceFilename($model);
|
||||
|
||||
self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', $sut->getFilename());
|
||||
self::assertEquals($datePrefix . '-barß_laölala_ldksjf123', (string) $sut);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
<?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\Configuration\SystemConfiguration;
|
||||
use App\Invoice\InvoiceModel;
|
||||
use App\Invoice\NumberGenerator\ConfigurableNumberGenerator;
|
||||
use App\Repository\InvoiceRepository;
|
||||
use App\Tests\Invoice\DebugFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Invoice\NumberGenerator\ConfigurableNumberGenerator
|
||||
*/
|
||||
class ConfigurableNumberGeneratorTest extends TestCase
|
||||
{
|
||||
private function getSut(string $format)
|
||||
{
|
||||
$config = $this->createMock(SystemConfiguration::class);
|
||||
$config->expects($this->any())
|
||||
->method('find')
|
||||
->willReturn($format);
|
||||
|
||||
$repository = $this->createMock(InvoiceRepository::class);
|
||||
$repository
|
||||
->expects($this->any())
|
||||
->method('getCounterForAllTime')
|
||||
->willReturn(1);
|
||||
$repository
|
||||
->expects($this->any())
|
||||
->method('getCounterForYear')
|
||||
->willReturn(1);
|
||||
$repository
|
||||
->expects($this->any())
|
||||
->method('getCounterForMonth')
|
||||
->willReturn(1);
|
||||
$repository
|
||||
->expects($this->any())
|
||||
->method('getCounterForDay')
|
||||
->willReturn(1);
|
||||
|
||||
return new ConfigurableNumberGenerator($repository, $config);
|
||||
}
|
||||
|
||||
public function getTestData()
|
||||
{
|
||||
$timestamp = time();
|
||||
|
||||
return [
|
||||
// simple tests for single calls
|
||||
['{date}', date('ymd'), $timestamp],
|
||||
['{Y}', date('Y'), $timestamp],
|
||||
['{y}', date('y'), $timestamp],
|
||||
['{M}', date('m'), $timestamp],
|
||||
['{m}', date('n'), $timestamp],
|
||||
['{D}', date('d'), $timestamp],
|
||||
['{d}', date('j'), $timestamp],
|
||||
['{c}', '2', $timestamp],
|
||||
['{cy}', '2', $timestamp],
|
||||
['{cm}', '2', $timestamp],
|
||||
['{cd}', '2', $timestamp],
|
||||
// number formatting (not testing the lower case versions, as the tests might break depending on the date)
|
||||
['{date,10}', '0000' . date('ymd'), $timestamp],
|
||||
['{Y,6}', '00' . date('Y'), $timestamp],
|
||||
['{M,3}', '0' . date('m'), $timestamp],
|
||||
['{D,3}', '0' . date('d'), $timestamp],
|
||||
['{c,2}', '02', $timestamp],
|
||||
['{cy,2}', '02', $timestamp],
|
||||
['{cm,2}', '02', $timestamp],
|
||||
['{cd,2}', '02', $timestamp],
|
||||
// mixing identifiers
|
||||
['{Y}{cy}', date('Y') . '2', $timestamp],
|
||||
['{Y}{cy}{m}', date('Y') . '2' . date('n'), $timestamp],
|
||||
['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp],
|
||||
['{Y}-{cy}/{m}', date('Y') . '-2/' . date('n'), $timestamp],
|
||||
['{Y,5}/{cy,5}', '0' . date('Y') . '/00002', $timestamp],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testGetInvoiceNumber(string $format, string $expectedInvoiceNumber, int $timestamp)
|
||||
{
|
||||
$sut = $this->getSut($format);
|
||||
$model = new InvoiceModel(new DebugFormatter());
|
||||
$model->setInvoiceDate((new \DateTime())->setTimestamp($timestamp));
|
||||
$sut->setModel($model);
|
||||
|
||||
$this->assertEquals($expectedInvoiceNumber, $sut->getInvoiceNumber());
|
||||
$this->assertEquals('default', $sut->getId());
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,6 @@ class DateNumberGeneratorTest extends TestCase
|
||||
$sut->setModel(new InvoiceModel(new DebugFormatter()));
|
||||
|
||||
$this->assertEquals(date('ymd'), $sut->getInvoiceNumber());
|
||||
$this->assertEquals('default', $sut->getId());
|
||||
$this->assertEquals('date', $sut->getId());
|
||||
}
|
||||
}
|
||||
|
||||
65
tests/Invoice/Renderer/PdfRendererTest.php
Normal file
65
tests/Invoice/Renderer/PdfRendererTest.php
Normal file
@@ -0,0 +1,65 @@
|
||||
<?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\Renderer;
|
||||
|
||||
use App\Invoice\Renderer\PdfRenderer;
|
||||
use App\Utils\MPdfConverter;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
/**
|
||||
* @covers \App\Invoice\Renderer\PdfRenderer
|
||||
* @group integration
|
||||
*/
|
||||
class PdfRendererTest extends KernelTestCase
|
||||
{
|
||||
use RendererTestTrait;
|
||||
|
||||
public function testSupports()
|
||||
{
|
||||
$loader = new FilesystemLoader();
|
||||
$env = new Environment($loader);
|
||||
$sut = new PdfRenderer($env, $this->createMock(MPdfConverter::class));
|
||||
$this->assertTrue($sut->supports($this->getInvoiceDocument('default.pdf.twig', true)));
|
||||
$this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
|
||||
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
|
||||
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
|
||||
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
|
||||
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
|
||||
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
|
||||
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
|
||||
}
|
||||
|
||||
public function testRender()
|
||||
{
|
||||
$kernel = self::bootKernel();
|
||||
/** @var Environment $twig */
|
||||
$twig = $kernel->getContainer()->get('twig');
|
||||
$stack = $kernel->getContainer()->get('request_stack');
|
||||
$cacheDir = $kernel->getContainer()->getParameter('kernel.cache_dir');
|
||||
|
||||
$request = new Request();
|
||||
$request->setLocale('en');
|
||||
$stack->push($request);
|
||||
|
||||
/** @var FilesystemLoader $loader */
|
||||
$loader = $twig->getLoader();
|
||||
$loader->addPath(__DIR__ . '/../templates/', 'invoice');
|
||||
|
||||
$sut = new PdfRenderer($twig, new MPdfConverter($cacheDir));
|
||||
$model = $this->getInvoiceModel();
|
||||
$document = $this->getInvoiceDocument('default.pdf.twig', true);
|
||||
|
||||
$response = $sut->render($document, $model);
|
||||
$this->assertEquals('application/pdf', $response->headers->get('Content-Type'));
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ use App\Invoice\NumberGenerator\DateNumberGenerator;
|
||||
use App\Invoice\Renderer\TwigRenderer;
|
||||
use App\Invoice\ServiceInvoice;
|
||||
use App\Repository\InvoiceDocumentRepository;
|
||||
use App\Utils\FileHelper;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\Environment;
|
||||
|
||||
@@ -23,10 +24,16 @@ use Twig\Environment;
|
||||
*/
|
||||
class ServiceInvoiceTest extends TestCase
|
||||
{
|
||||
private function getSut(array $paths): ServiceInvoice
|
||||
{
|
||||
$repo = new InvoiceDocumentRepository($paths);
|
||||
|
||||
return new ServiceInvoice($repo, new FileHelper(realpath(__DIR__ . '/../../var/data/')));
|
||||
}
|
||||
|
||||
public function testEmptyObject()
|
||||
{
|
||||
$repo = new InvoiceDocumentRepository([]);
|
||||
$sut = new ServiceInvoice($repo);
|
||||
$sut = $this->getSut([]);
|
||||
|
||||
$this->assertEmpty($sut->getCalculator());
|
||||
$this->assertIsArray($sut->getCalculator());
|
||||
@@ -44,8 +51,7 @@ class ServiceInvoiceTest extends TestCase
|
||||
|
||||
public function testWithDocumentDirectory()
|
||||
{
|
||||
$repo = new InvoiceDocumentRepository(['templates/invoice/renderer/']);
|
||||
$sut = new ServiceInvoice($repo);
|
||||
$sut = $this->getSut(['templates/invoice/renderer/']);
|
||||
|
||||
$actual = $sut->getDocuments();
|
||||
$this->assertNotEmpty($actual);
|
||||
@@ -59,8 +65,7 @@ class ServiceInvoiceTest extends TestCase
|
||||
|
||||
public function testAdd()
|
||||
{
|
||||
$repo = new InvoiceDocumentRepository([]);
|
||||
$sut = new ServiceInvoice($repo);
|
||||
$sut = $this->getSut([]);
|
||||
|
||||
$sut->addCalculator(new DefaultCalculator());
|
||||
$sut->addNumberGenerator(new DateNumberGenerator());
|
||||
@@ -74,7 +79,7 @@ class ServiceInvoiceTest extends TestCase
|
||||
$this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default'));
|
||||
|
||||
$this->assertEquals(1, count($sut->getNumberGenerator()));
|
||||
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('default'));
|
||||
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('date'));
|
||||
|
||||
$this->assertEquals(1, count($sut->getRenderer()));
|
||||
}
|
||||
|
||||
146
tests/Invoice/templates/default.pdf.twig
Normal file
146
tests/Invoice/templates/default.pdf.twig
Normal file
@@ -0,0 +1,146 @@
|
||||
{% extends 'invoice/layout.html.twig' %}
|
||||
{% set language = model.template.language|default(app.request.locale) %}
|
||||
{% set isDecimal = model.template.decimalDuration|default(false) %}
|
||||
|
||||
{% block invoice %}
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
<h2 class="page-header">
|
||||
<span contenteditable="true">{{ model.template.title }}</span>
|
||||
<small class="pull-right">{{ 'label.date'|trans({}, 'messages', language) }}: {{ model.invoiceDate|date_short }}</small>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-5">
|
||||
{{ 'invoice.from'|trans({}, 'messages', language) }}
|
||||
<address contenteditable="true">
|
||||
<strong>{{ model.template.company }}</strong><br>
|
||||
{{ model.template.address|trim|nl2br }}
|
||||
{% if model.template.vatId is not empty %}
|
||||
<br>
|
||||
{{ 'label.vat_id'|trans({}, 'messages', language) }}:
|
||||
{{ model.template.vatId }}
|
||||
{% endif %}
|
||||
</address>
|
||||
</div>
|
||||
<div class="col-sm-2"></div>
|
||||
<div class="col-sm-5">
|
||||
{{ 'invoice.to'|trans({}, 'messages', language) }}
|
||||
<address contenteditable="true">
|
||||
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br>
|
||||
{{ model.customer.address|nl2br }}
|
||||
{% if model.customer.vatId is not empty %}
|
||||
<br>
|
||||
{{ 'label.vat_id'|trans({}, 'messages', language) }}: {{ model.customer.vatId }}
|
||||
{% endif %}
|
||||
{% if model.customer.number is not empty %}
|
||||
<br>
|
||||
{{ 'label.number'|trans({}, 'messages', language) }}: {{ model.customer.number }}
|
||||
{% endif %}
|
||||
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
|
||||
<br>
|
||||
{{ 'label.orderNumber'|trans({}, 'messages', language) }}: {{ model.query.project.orderNumber }}
|
||||
{% endif %}
|
||||
</address>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-sm-5">
|
||||
<p contenteditable="true">
|
||||
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
|
||||
{{ model.numberGenerator.invoiceNumber }}
|
||||
|
||||
<br>
|
||||
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
|
||||
{{ model.dueDate|date_short }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-sm-7"></div>
|
||||
</div>
|
||||
|
||||
<div class="row invoice-items">
|
||||
<div class="col-xs-12 table-responsive">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'label.date'|trans({}, 'messages', language) }}</th>
|
||||
<th>{{ 'label.description'|trans({}, 'messages', language) }}</th>
|
||||
<th class="text-right">{{ 'label.unit_price'|trans({}, 'messages', language) }}</th>
|
||||
<th class="text-right">{{ 'label.amount'|trans({}, 'messages', language) }}</th>
|
||||
<th class="text-right">{{ 'label.total_rate'|trans({}, 'messages', language) }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in model.calculator.entries %}
|
||||
{% set duration = entry.duration|duration(isDecimal) %}
|
||||
{% if entry.fixedRate %}
|
||||
{% set rate = entry.fixedRate %}
|
||||
{% set duration = entry.amount|amount %}
|
||||
{% else %}
|
||||
{% set rate = entry.hourlyRate %}
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td>
|
||||
<td contenteditable="true">
|
||||
{% if entry.description is not empty %}
|
||||
{{ entry.description|nl2br }}
|
||||
{% else %}
|
||||
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td nowrap class="text-nowrap text-right">{{ rate|money(model.calculator.currency) }}</td>
|
||||
<td nowrap class="text-nowrap text-right">{{ duration }}</td>
|
||||
<td nowrap class="text-nowrap text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">
|
||||
{{ 'invoice.subtotal'|trans({}, 'messages', language) }}
|
||||
</td>
|
||||
<td class="text-right">{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right">
|
||||
{{ 'invoice.tax'|trans({}, 'messages', language) }} ({{ model.calculator.vat }}%)
|
||||
</td>
|
||||
<td class="text-right">{{ model.calculator.tax|money(model.calculator.currency) }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="4" class="text-right text-nowrap">
|
||||
<strong>{{ 'invoice.total'|trans({}, 'messages', language) }}</strong>
|
||||
</td>
|
||||
<td class="text-right">
|
||||
<strong>{{ model.calculator.total|money(model.calculator.currency) }}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-xs-12">
|
||||
{% if model.template.paymentTerms is not empty %}
|
||||
<div contenteditable="true" class="paymentTerms">
|
||||
{{ model.template.paymentTerms|nl2br|md2html }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="footer">
|
||||
<p>
|
||||
<strong>{{ 'label.address'|trans({}, 'messages', language) }}</strong>: {{ model.template.company }} – {{ model.template.address|replace({"\n": ' – ', "\r\n": ' – ', "\r": ' – '})|raw }}
|
||||
<br>
|
||||
<strong>{{ 'label.invoice_bank_account'|trans({}, 'messages', language) }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' – ', "\r\n": ' – ', "\r": ' – '})|raw }}
|
||||
<br>
|
||||
<strong>{{ 'label.contact'|trans({}, 'messages', language) }}</strong>: {{ model.template.contact|replace({"\n": ' – ', "\r\n": ' – ', "\r": ' – '})|raw }}
|
||||
</p>
|
||||
</footer>
|
||||
|
||||
{% endblock %}
|
||||
43
tests/Repository/Loader/AbstractLoaderTest.php
Normal file
43
tests/Repository/Loader/AbstractLoaderTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?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\Repository\Loader;
|
||||
|
||||
use Doctrine\ORM\AbstractQuery;
|
||||
use Doctrine\ORM\EntityManager;
|
||||
use Doctrine\ORM\Query\Expr;
|
||||
use Doctrine\ORM\QueryBuilder;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
abstract class AbstractLoaderTest extends TestCase
|
||||
{
|
||||
protected function getEntityManagerMock(int $createQueryBuilderCount)
|
||||
{
|
||||
$em = $this->createMock(EntityManager::class);
|
||||
$qb = $this->createMock(QueryBuilder::class);
|
||||
$query = $this->createMock(AbstractQuery::class);
|
||||
$expr = $this->createMock(Expr::class);
|
||||
|
||||
$expr->expects($this->any())->method('isNotNull')->willReturn('');
|
||||
$expr->expects($this->any())->method('in')->willReturn('');
|
||||
|
||||
$qb->expects($this->any())->method('andWhere')->willReturnSelf();
|
||||
$qb->expects($this->any())->method('from')->willReturnSelf();
|
||||
$qb->expects($this->any())->method('expr')->willReturn($expr);
|
||||
$qb->expects($this->any())->method('from')->willReturnSelf();
|
||||
$qb->expects($this->any())->method('select')->willReturnSelf();
|
||||
$qb->expects($this->any())->method('leftJoin')->willReturnSelf();
|
||||
$qb->expects($this->any())->method('getQuery')->willReturn($query);
|
||||
$query->expects($this->any())->method('execute')->willReturn(null);
|
||||
|
||||
$em->expects($this->exactly($createQueryBuilderCount))->method('createQueryBuilder')->willReturn($qb);
|
||||
|
||||
return $em;
|
||||
}
|
||||
}
|
||||
33
tests/Repository/Loader/ActivityLoaderTest.php
Normal file
33
tests/Repository/Loader/ActivityLoaderTest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?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\Repository\Loader;
|
||||
|
||||
use App\Entity\Activity;
|
||||
use App\Repository\Loader\ActivityLoader;
|
||||
|
||||
/**
|
||||
* @covers \App\Repository\Loader\ActivityLoader
|
||||
* @covers \App\Repository\Loader\ActivityIdLoader
|
||||
*/
|
||||
class ActivityLoaderTest extends AbstractLoaderTest
|
||||
{
|
||||
public function testLoadResults()
|
||||
{
|
||||
// mock needs improvements, because it should be 5
|
||||
$em = $this->getEntityManagerMock(2);
|
||||
|
||||
$sut = new ActivityLoader($em);
|
||||
|
||||
$entity = $this->createMock(Activity::class);
|
||||
$entity->expects($this->once())->method('getId')->willReturn(1);
|
||||
|
||||
$sut->loadResults([$entity]);
|
||||
}
|
||||
}
|
||||
32
tests/Repository/Loader/CustomerLoaderTest.php
Normal file
32
tests/Repository/Loader/CustomerLoaderTest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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\Repository\Loader;
|
||||
|
||||
use App\Entity\Customer;
|
||||
use App\Repository\Loader\CustomerLoader;
|
||||
|
||||
/**
|
||||
* @covers \App\Repository\Loader\CustomerLoader
|
||||
* @covers \App\Repository\Loader\CustomerIdLoader
|
||||
*/
|
||||
class CustomerLoaderTest extends AbstractLoaderTest
|
||||
{
|
||||
public function testLoadResults()
|
||||
{
|
||||
$em = $this->getEntityManagerMock(2);
|
||||
|
||||
$sut = new CustomerLoader($em);
|
||||
|
||||
$entity = $this->createMock(Customer::class);
|
||||
$entity->expects($this->once())->method('getId')->willReturn(1);
|
||||
|
||||
$sut->loadResults([$entity]);
|
||||
}
|
||||
}
|
||||
32
tests/Repository/Loader/InvoiceLoaderTest.php
Normal file
32
tests/Repository/Loader/InvoiceLoaderTest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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\Repository\Loader;
|
||||
|
||||
use App\Entity\Invoice;
|
||||
use App\Repository\Loader\InvoiceLoader;
|
||||
|
||||
/**
|
||||
* @covers \App\Repository\Loader\InvoiceLoader
|
||||
* @covers \App\Repository\Loader\InvoiceIdLoader
|
||||
*/
|
||||
class InvoiceLoaderTest extends AbstractLoaderTest
|
||||
{
|
||||
public function testLoadResults()
|
||||
{
|
||||
$em = $this->getEntityManagerMock(2);
|
||||
|
||||
$sut = new InvoiceLoader($em);
|
||||
|
||||
$entity = $this->createMock(Invoice::class);
|
||||
$entity->expects($this->once())->method('getId')->willReturn(1);
|
||||
|
||||
$sut->loadResults([$entity]);
|
||||
}
|
||||
}
|
||||
32
tests/Repository/Loader/ProjectLoaderTest.php
Normal file
32
tests/Repository/Loader/ProjectLoaderTest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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\Repository\Loader;
|
||||
|
||||
use App\Entity\Project;
|
||||
use App\Repository\Loader\ProjectLoader;
|
||||
|
||||
/**
|
||||
* @covers \App\Repository\Loader\ProjectLoader
|
||||
* @covers \App\Repository\Loader\ProjectIdLoader
|
||||
*/
|
||||
class ProjectLoaderTest extends AbstractLoaderTest
|
||||
{
|
||||
public function testLoadResults()
|
||||
{
|
||||
$em = $this->getEntityManagerMock(4);
|
||||
|
||||
$sut = new ProjectLoader($em);
|
||||
|
||||
$entity = $this->createMock(Project::class);
|
||||
$entity->expects($this->once())->method('getId')->willReturn(1);
|
||||
|
||||
$sut->loadResults([$entity]);
|
||||
}
|
||||
}
|
||||
32
tests/Repository/Loader/TeamLoaderTest.php
Normal file
32
tests/Repository/Loader/TeamLoaderTest.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?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\Repository\Loader;
|
||||
|
||||
use App\Entity\Team;
|
||||
use App\Repository\Loader\TeamLoader;
|
||||
|
||||
/**
|
||||
* @covers \App\Repository\Loader\TeamLoader
|
||||
* @covers \App\Repository\Loader\TeamIdLoader
|
||||
*/
|
||||
class TeamLoaderTest extends AbstractLoaderTest
|
||||
{
|
||||
public function testLoadResults()
|
||||
{
|
||||
$em = $this->getEntityManagerMock(1);
|
||||
|
||||
$sut = new TeamLoader($em);
|
||||
|
||||
$entity = $this->createMock(Team::class);
|
||||
$entity->expects($this->once())->method('getId')->willReturn(1);
|
||||
|
||||
$sut->loadResults([$entity]);
|
||||
}
|
||||
}
|
||||
0
tests/_data/.gitignore
vendored
Normal file
0
tests/_data/.gitignore
vendored
Normal file
@@ -78,6 +78,18 @@
|
||||
<source>details</source>
|
||||
<target>Anzeigen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="download">
|
||||
<source>download</source>
|
||||
<target>Herunterladen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.pending">
|
||||
<source>invoice.pending</source>
|
||||
<target>Warten auf Zahlungseingang</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.paid">
|
||||
<source>invoice.paid</source>
|
||||
<target>Rechnung bezahlt</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -78,6 +78,18 @@
|
||||
<source>details</source>
|
||||
<target>Show</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="download">
|
||||
<source>download</source>
|
||||
<target>Download</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.pending">
|
||||
<source>invoice.pending</source>
|
||||
<target>Waiting for payment</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.paid">
|
||||
<source>invoice.paid</source>
|
||||
<target>Invoice paid</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
</xliff>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>مولد رقم فاتورة </target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>التاريخ (افتراضي)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>التاريخ</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Generátor fakturačních čísel</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Data (Výchozí)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Data</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Fakturanummergenerator</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Dato (standard)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Dato</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Rechnungsnummern-Generator</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Datum</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Datum (Standard)</target>
|
||||
<target>Konfiguriertes Format</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,13 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Invoicenumber-Generator</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Date</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Date (default)</target>
|
||||
<target>Configured format</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Generador de número de factura</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Fecha (default)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Fecha</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Faktura zenbaki sortzailea</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Data (defektuz)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Data</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Générateur de numéros de facture</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Date (par defaut)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Date</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Számlaszám-generátor</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Dátum (alapértelmezett)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Dátum</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Generatore di numerazione fattura</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Data (default)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Data</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>請求書番号 ジェネレーター</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>日付 (デフォルト)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>日付</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target state="translated">인보이스번호-생성기</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target state="translated">날짜 (초기가)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target state="translated">날짜</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Factuurnummergenerator</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Datum (standaard)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Datum</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Generator numeru faktury</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Data (domyślny)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Data</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Gerador de Números de Fatura</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Data (Padrão)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Data</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Генератор номеров счета</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Дата (стандартная)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Дата</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>Generátor čísiel faktúr</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>Dátum (default)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>Dátum</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target state="translated">Fakturanummergenerator</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target state="translated">Datum (standard)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target state="translated">Datum</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target state="translated">Faturano-Oluşturucu</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target state="translated">Tarih (varsayılan)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target state="translated">Tarih</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
<source>label.invoice_number_generator</source>
|
||||
<target>发票数字生成器</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="default">
|
||||
<source>default</source>
|
||||
<target>日期(默认)</target>
|
||||
<trans-unit id="date">
|
||||
<source>date</source>
|
||||
<target>日期</target>
|
||||
</trans-unit>
|
||||
</body>
|
||||
</file>
|
||||
|
||||
@@ -561,10 +561,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>الفواتير</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>م بإنشاء فواتير من بيانات ورقة التوقيت الخاصة بك.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.print">
|
||||
<source>button.print</source>
|
||||
<target>طباعة</target>
|
||||
|
||||
@@ -752,10 +752,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Faktura</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Vytvářejte faktury z vašich zaznamenaných položek časového záznamu.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.preview">
|
||||
<source>invoice.preview</source>
|
||||
<target>Toto je náhled dat, která se zobrazí ve vašem fakturačním dokladu.</target>
|
||||
|
||||
@@ -784,10 +784,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Fakturaer</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Opret faktura for dine tidsregistreringsindlæg.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Filtrer fakturadata</target>
|
||||
|
||||
@@ -824,10 +824,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Rechnungen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Erstellen Sie Rechnungen für ihre aufgezeichneten Zeiten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Rechnungsdaten filtern</target>
|
||||
@@ -836,6 +832,10 @@
|
||||
<source>button.preview</source>
|
||||
<target>Vorschau</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.preview_print">
|
||||
<source>button.preview_print</source>
|
||||
<target>Druck Vorschau</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.print">
|
||||
<source>button.print</source>
|
||||
<target>Drucken</target>
|
||||
@@ -952,6 +952,22 @@
|
||||
<source>label.decimalDuration</source>
|
||||
<target>Dauer als Dezimalzahl anzeigen</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.status">
|
||||
<source>label.status</source>
|
||||
<target>Status</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="status.new">
|
||||
<source>status.new</source>
|
||||
<target>Neu</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="status.pending">
|
||||
<source>status.pending</source>
|
||||
<target>Ausstehend</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="status.paid">
|
||||
<source>status.paid</source>
|
||||
<target>Bezahlt</target>
|
||||
</trans-unit>
|
||||
|
||||
<!--
|
||||
Export
|
||||
|
||||
@@ -824,10 +824,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Invoices</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Create invoices from your recorded timesheet entries.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Filter invoice data</target>
|
||||
@@ -836,6 +832,10 @@
|
||||
<source>button.preview</source>
|
||||
<target>Preview</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.preview_print">
|
||||
<source>button.preview_print</source>
|
||||
<target>Print preview</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.print">
|
||||
<source>button.print</source>
|
||||
<target>Print</target>
|
||||
@@ -952,6 +952,22 @@
|
||||
<source>label.decimalDuration</source>
|
||||
<target>Display duration as decimal number</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="label.status">
|
||||
<source>label.status</source>
|
||||
<target>Status</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="status.new">
|
||||
<source>status.new</source>
|
||||
<target>New</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="status.pending">
|
||||
<source>status.pending</source>
|
||||
<target>Pending</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="status.paid">
|
||||
<source>status.paid</source>
|
||||
<target>Paid</target>
|
||||
</trans-unit>
|
||||
|
||||
<!--
|
||||
Export
|
||||
|
||||
@@ -756,10 +756,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Facturas</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Crear facturas a partir de registros de sus partes de horas.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Filtrar datos de factura</target>
|
||||
|
||||
@@ -792,10 +792,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Fakturak</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Faktura sortu ordu sarrerak erabiliaz.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Faktura datuak filtratu</target>
|
||||
|
||||
@@ -696,10 +696,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Factures</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Générer des factures à partir des fiches de temps.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.print">
|
||||
<source>button.print</source>
|
||||
<target>Imprimer</target>
|
||||
|
||||
@@ -696,10 +696,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Számlák</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Számlák készítése az időbejegyzéseidből</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.print">
|
||||
<source>button.print</source>
|
||||
<target>Nyomtatás</target>
|
||||
|
||||
@@ -112,7 +112,7 @@
|
||||
<source>login_required</source>
|
||||
<target>Permessi insufficenti. Ridirezionare all'accesso?</target>
|
||||
</trans-unit>
|
||||
|
||||
|
||||
<!--
|
||||
Menu / Navbar items
|
||||
-->
|
||||
@@ -196,7 +196,7 @@
|
||||
<source>error.no_comments_found</source>
|
||||
<target>Non ci sono commenti fino'ora.</target>
|
||||
</trans-unit>
|
||||
|
||||
|
||||
<!--
|
||||
General labels
|
||||
-->
|
||||
@@ -440,7 +440,7 @@
|
||||
<source>label.appendTags</source>
|
||||
<target>Aggiungi tags</target>
|
||||
</trans-unit>
|
||||
|
||||
|
||||
<!--
|
||||
User profile
|
||||
-->
|
||||
@@ -513,7 +513,7 @@
|
||||
<source>label.timesheet.export_decimal</source>
|
||||
<target>Usa durata decimale nell'esportazione</target>
|
||||
</trans-unit>
|
||||
|
||||
|
||||
<!--
|
||||
User timesheet calendar
|
||||
-->
|
||||
@@ -820,10 +820,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Fatture</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Crea fattura dalle registrazioni.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Filtra per data fattura</target>
|
||||
|
||||
@@ -696,10 +696,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>請求書</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>タイムシートに記録したエントリから請求書を作成します。</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.print">
|
||||
<source>button.print</source>
|
||||
<target>出力</target>
|
||||
|
||||
@@ -712,10 +712,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target state="translated">인보이스</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target state="translated">기록된 시간기록표를 바탕으로 인보이스 생성</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.preview">
|
||||
<source>invoice.preview</source>
|
||||
<target state="translated">인보이스에 표시되는 정보의 미리보기</target>
|
||||
|
||||
@@ -752,10 +752,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Facturen maken</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Zet uw prestaties om in een factuur</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Filter op factuurdatum</target>
|
||||
|
||||
@@ -804,10 +804,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Faktury</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Utwórz fakturę z twoich wpisów do ewidencji.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Filtruj po dacie faktury</target>
|
||||
|
||||
@@ -696,10 +696,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Faturas</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Criar faturas a partir de suas entradas de quadro de horários registradas.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.print">
|
||||
<source>button.print</source>
|
||||
<target>Imprimir</target>
|
||||
|
||||
@@ -584,10 +584,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Бланки счета</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Создайте счета для записанных периодов времени.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="button.print">
|
||||
<source>button.print</source>
|
||||
<target>Печать</target>
|
||||
|
||||
@@ -837,10 +837,6 @@
|
||||
<source>invoice.title</source>
|
||||
<target>Faktúry</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.subtitle">
|
||||
<source>invoice.subtitle</source>
|
||||
<target>Vytvorte faktúry z vašich položiek časového rozvrhu.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="invoice.filter">
|
||||
<source>invoice.filter</source>
|
||||
<target>Filtrovať dáta faktúr</target>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user