invoices: choose language and duration format (#1438)

This commit is contained in:
Kevin Papst
2020-02-04 20:27:36 +01:00
committed by GitHub
parent 75dcae6fbe
commit 986d922855
64 changed files with 1559 additions and 541 deletions

View File

@@ -22,10 +22,10 @@
"friendsofsymfony/rest-bundle": "^2.5",
"friendsofsymfony/user-bundle": "dev-master",
"gedmo/doctrine-extensions": "^2.4",
"hslavich/oneloginsaml-bundle": "^1.4",
"jms/metadata": "^2.0",
"jms/serializer-bundle": "^3.2",
"kevinpapst/adminlte-bundle": "^3.0",
"hslavich/oneloginsaml-bundle": "^1.4",
"kimai/kimai2-composer": "^0.1",
"laravolt/avatar": "^3.0",
"league/csv": "^9.4",
@@ -57,7 +57,9 @@
"symfony/validator": "^4.0",
"symfony/webpack-encore-bundle": "^1.5",
"symfony/yaml": "^4.0",
"twig/extensions": "^1.5",
"twig/extra-bundle": "^3.0",
"twig/intl-extra": "^3.0",
"twig/string-extra": "^3.0",
"white-october/pagerfanta-bundle": "^1.1"
},
"require-dev": {
@@ -86,7 +88,7 @@
],
"config": {
"platform": {
"php": "7.2"
"php": "7.2.9"
},
"preferred-install": {
"*": "dist"

349
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "0367a6d800c3a64f39ccd00564953d04",
"content-hash": "937e678d15c03f8508dd269116d57d82",
"packages": [
{
"name": "beberlei/doctrineextensions",
@@ -6942,6 +6942,66 @@
],
"time": "2019-11-27T13:56:44+00:00"
},
{
"name": "symfony/polyfill-intl-grapheme",
"version": "v1.13.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-grapheme.git",
"reference": "45c566a1ca16273f7ea6b930e013462e00e14502"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/45c566a1ca16273f7ea6b930e013462e00e14502",
"reference": "45c566a1ca16273f7ea6b930e013462e00e14502",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.13-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Polyfill\\Intl\\Grapheme\\": ""
},
"files": [
"bootstrap.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's grapheme_* functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"grapheme",
"intl",
"polyfill",
"portable",
"shim"
],
"time": "2019-11-27T13:56:44+00:00"
},
{
"name": "symfony/polyfill-intl-icu",
"version": "v1.13.1",
@@ -7062,6 +7122,69 @@
],
"time": "2019-11-27T13:56:44+00:00"
},
{
"name": "symfony/polyfill-intl-normalizer",
"version": "v1.13.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/polyfill-intl-normalizer.git",
"reference": "cfe6ad557c15f3797f667e9518ce759aa04ae4f3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/cfe6ad557c15f3797f667e9518ce759aa04ae4f3",
"reference": "cfe6ad557c15f3797f667e9518ce759aa04ae4f3",
"shasum": ""
},
"require": {
"php": ">=5.3.3"
},
"suggest": {
"ext-intl": "For best performance"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.13-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Polyfill\\Intl\\Normalizer\\": ""
},
"files": [
"bootstrap.php"
],
"classmap": [
"Resources/stubs"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony polyfill for intl's Normalizer class and related functions",
"homepage": "https://symfony.com",
"keywords": [
"compatibility",
"intl",
"normalizer",
"polyfill",
"portable",
"shim"
],
"time": "2019-11-27T13:56:44+00:00"
},
{
"name": "symfony/polyfill-mbstring",
"version": "v1.13.1",
@@ -7977,6 +8100,70 @@
"homepage": "https://symfony.com",
"time": "2020-01-04T13:00:46+00:00"
},
{
"name": "symfony/string",
"version": "v5.0.4",
"source": {
"type": "git",
"url": "https://github.com/symfony/string.git",
"reference": "978261d3c4ffd64f99dea0e78b026690a67fe7fe"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/string/zipball/978261d3c4ffd64f99dea0e78b026690a67fe7fe",
"reference": "978261d3c4ffd64f99dea0e78b026690a67fe7fe",
"shasum": ""
},
"require": {
"php": "^7.2.5",
"symfony/polyfill-intl-grapheme": "~1.0",
"symfony/polyfill-intl-normalizer": "~1.0",
"symfony/polyfill-mbstring": "~1.0",
"symfony/translation-contracts": "^1.1|^2"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "5.0-dev"
}
},
"autoload": {
"psr-4": {
"Symfony\\Component\\String\\": ""
},
"files": [
"Resources/functions.php"
],
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Nicolas Grekas",
"email": "p@tchwork.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Symfony String component",
"homepage": "https://symfony.com",
"keywords": [
"grapheme",
"i18n",
"string",
"unicode",
"utf-8",
"utf8"
],
"time": "2020-01-14T17:54:59+00:00"
},
{
"name": "symfony/swiftmailer-bundle",
"version": "v3.4.0",
@@ -8750,41 +8937,41 @@
"time": "2020-01-21T11:12:16+00:00"
},
{
"name": "twig/extensions",
"version": "v1.5.4",
"name": "twig/extra-bundle",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/twigphp/Twig-extensions.git",
"reference": "57873c8b0c1be51caa47df2cdb824490beb16202"
"url": "https://github.com/twigphp/twig-extra-bundle.git",
"reference": "ce5c97dd566d9acd5d1fbd5eb76b6d264614725a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/Twig-extensions/zipball/57873c8b0c1be51caa47df2cdb824490beb16202",
"reference": "57873c8b0c1be51caa47df2cdb824490beb16202",
"url": "https://api.github.com/repos/twigphp/twig-extra-bundle/zipball/ce5c97dd566d9acd5d1fbd5eb76b6d264614725a",
"reference": "ce5c97dd566d9acd5d1fbd5eb76b6d264614725a",
"shasum": ""
},
"require": {
"twig/twig": "^1.27|^2.0"
"php": "^7.1.3",
"symfony/framework-bundle": "^4.3|^5.0",
"symfony/twig-bundle": "^4.3|^5.0",
"twig/twig": "^2.4|^3.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^3.4",
"symfony/translation": "^2.7|^3.4"
"twig/cssinliner-extra": "^2.12|^3.0",
"twig/html-extra": "^2.12|^3.0",
"twig/inky-extra": "^2.12|^3.0",
"twig/intl-extra": "^2.12|^3.0",
"twig/markdown-extra": "^2.12|^3.0"
},
"suggest": {
"symfony/translation": "Allow the time_diff output to be translated"
},
"type": "library",
"type": "symfony-bundle",
"extra": {
"branch-alias": {
"dev-master": "1.5-dev"
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-0": {
"Twig_Extensions_": "lib/"
},
"psr-4": {
"Twig\\Extensions\\": "src/"
"Twig\\Extra\\TwigExtraBundle\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
@@ -8794,15 +8981,127 @@
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "Common additional features for Twig that do not directly belong in core",
"description": "A Symfony bundle for extra Twig extensions",
"homepage": "https://twig.symfony.com",
"keywords": [
"i18n",
"text"
"bundle",
"extra",
"twig"
],
"time": "2018-12-05T18:34:18+00:00"
"time": "2019-12-28T07:09:27+00:00"
},
{
"name": "twig/intl-extra",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/twigphp/intl-extra.git",
"reference": "291d79ef98891da3efe14f0771fbe03a25fe6bec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/intl-extra/zipball/291d79ef98891da3efe14f0771fbe03a25fe6bec",
"reference": "291d79ef98891da3efe14f0771fbe03a25fe6bec",
"shasum": ""
},
"require": {
"php": "^7.1.3",
"symfony/intl": "^4.3|^5.0",
"twig/twig": "^2.4|^3.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^4.4|^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"Twig\\Extra\\Intl\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "A Twig extension for Intl",
"homepage": "https://twig.symfony.com",
"keywords": [
"intl",
"twig"
],
"time": "2019-12-28T07:09:27+00:00"
},
{
"name": "twig/string-extra",
"version": "v3.0.1",
"source": {
"type": "git",
"url": "https://github.com/twigphp/string-extra.git",
"reference": "54fb5511c4a0189de1d48d0f7c98843c270084e8"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/twigphp/string-extra/zipball/54fb5511c4a0189de1d48d0f7c98843c270084e8",
"reference": "54fb5511c4a0189de1d48d0f7c98843c270084e8",
"shasum": ""
},
"require": {
"php": "^7.2.9",
"symfony/string": "^5.0",
"twig/twig": "^2.4|^3.0"
},
"require-dev": {
"symfony/phpunit-bridge": "^4.4|^5.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.12-dev"
}
},
"autoload": {
"psr-4": {
"Twig\\Extra\\String\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com",
"homepage": "http://fabien.potencier.org",
"role": "Lead Developer"
}
],
"description": "A Twig extension for Symfony String",
"homepage": "https://twig.symfony.com",
"keywords": [
"html",
"string",
"twig",
"unicode"
],
"time": "2019-12-27T07:33:44+00:00"
},
{
"name": "twig/twig",
@@ -11746,6 +12045,6 @@
},
"platform-dev": [],
"platform-overrides": {
"php": "7.2"
"php": "7.2.9"
}
}

View File

@@ -22,4 +22,5 @@ return [
Nelmio\ApiDocBundle\NelmioApiDocBundle::class => ['all' => true],
Nelmio\CorsBundle\NelmioCorsBundle::class => ['all' => true],
Symfony\WebpackEncoreBundle\WebpackEncoreBundle::class => ['all' => true],
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
];

View File

@@ -1,12 +0,0 @@
services:
_defaults:
public: false
autowire: true
autoconfigure: true
#Twig\Extensions\ArrayExtension: ~
#Twig\Extensions\DateExtension: ~
Twig\Extensions\IntlExtension: ~
#Twig\Extensions\TextExtension: ~
Twig\Extensions\StringLoader:
class: Twig\Extension\StringLoaderExtension

View File

@@ -9,44 +9,37 @@
namespace App\Entity;
class InvoiceDocument
final class InvoiceDocument
{
/**
* @var \SplFileInfo
*/
private $file;
/**
* @param \SplFileInfo $file
*/
public function __construct(\SplFileInfo $file)
{
$this->file = $file;
}
/**
* @return string
*/
public function getId()
public function getId(): string
{
$file = $this->file->getFilename();
return substr($file, 0, strpos($file, '.'));
}
/**
* @return string
*/
public function getName()
public function getName(): string
{
return basename($this->getFilename());
}
/**
* @return string
*/
public function getFilename(): string
{
return $this->file->getRealPath();
}
public function getFileExtension(): string
{
return $this->file->getExtension();
}
}

View File

@@ -134,6 +134,25 @@ class InvoiceTemplate
*/
private $paymentDetails;
/**
* Used when rendering HTML templates.
*
* @var bool
*
* @ORM\Column(name="decimal_duration", type="boolean", nullable=false, options={"default": false})
* @Assert\NotNull()
*/
private $decimalDuration = false;
/**
* Used when rendering HTML templates.
*
* @var string
*
* @ORM\Column(name="language", type="string", length=6, nullable=true)
*/
private $language;
public function getId(): ?int
{
return $this->id;
@@ -295,6 +314,30 @@ class InvoiceTemplate
return $this;
}
public function isDecimalDuration(): bool
{
return $this->decimalDuration;
}
public function setDecimalDuration(bool $decimalDuration): InvoiceTemplate
{
$this->decimalDuration = $decimalDuration;
return $this;
}
public function getLanguage(): ?string
{
return $this->language;
}
public function setLanguage(?string $language): InvoiceTemplate
{
$this->language = $language;
return $this;
}
/**
* @return string
*/

View File

@@ -13,6 +13,8 @@ use App\Entity\InvoiceTemplate;
use App\Form\Type\InvoiceCalculatorType;
use App\Form\Type\InvoiceNumberGeneratorType;
use App\Form\Type\InvoiceRendererType;
use App\Form\Type\LanguageType;
use App\Form\Type\YesNoType;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\NumberType;
use Symfony\Component\Form\Extension\Core\Type\TextareaType;
@@ -70,6 +72,12 @@ class InvoiceTemplateForm extends AbstractType
->add('renderer', InvoiceRendererType::class, [])
->add('calculator', InvoiceCalculatorType::class, [])
->add('numberGenerator', InvoiceNumberGeneratorType::class, [])
->add('language', LanguageType::class, [
'required' => false,
])
->add('decimalDuration', YesNoType::class, [
'label' => 'label.decimalDuration'
])
;
}

View File

@@ -0,0 +1,135 @@
<?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\Hydrator;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceItemHydrator;
use App\Invoice\InvoiceModel;
class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
{
/**
* @var InvoiceModel
*/
private $model;
public function setInvoiceModel(InvoiceModel $model)
{
$this->model = $model;
}
public function hydrate(InvoiceItem $item): array
{
$formatter = $this->model->getFormatter();
$rate = $item->getRate();
$appliedRate = $item->getHourlyRate();
$amount = $formatter->getFormattedDuration($item->getDuration());
$description = $item->getDescription();
if ($item->isFixedRate()) {
$appliedRate = $item->getFixedRate();
$amount = $item->getAmount();
}
$activity = $item->getActivity();
$project = $item->getProject();
$customer = $project->getCustomer();
$currency = $customer->getCurrency();
$user = $item->getUser();
$begin = $item->getBegin();
$end = $item->getEnd();
if (empty($description) && null !== $activity) {
$description = $activity->getName();
}
// this should never happen!
if (empty($appliedRate)) {
$appliedRate = 0;
}
$values = [
'entry.row' => '',
'entry.description' => $description,
'entry.amount' => $amount,
'entry.type' => $item->getType(),
'entry.category' => $item->getCategory(),
'entry.rate' => $formatter->getFormattedMoney($appliedRate, $currency),
'entry.rate_nc' => $formatter->getFormattedMoney($appliedRate, null),
'entry.rate_plain' => $appliedRate,
'entry.total' => $formatter->getFormattedMoney($rate, $currency),
'entry.total_nc' => $formatter->getFormattedMoney($rate, null),
'entry.total_plain' => $rate,
'entry.currency' => $currency,
'entry.duration' => $item->getDuration(),
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($item->getDuration()),
'entry.duration_minutes' => number_format($item->getDuration() / 60),
'entry.begin' => $formatter->getFormattedDateTime($begin),
'entry.begin_time' => $formatter->getFormattedTime($begin),
'entry.begin_timestamp' => $begin->getTimestamp(),
'entry.end' => $formatter->getFormattedDateTime($end),
'entry.end_time' => $formatter->getFormattedTime($end),
'entry.end_timestamp' => $end->getTimestamp(),
'entry.date' => $formatter->getFormattedDateTime($begin),
'entry.user_id' => $user->getId(),
'entry.user_name' => $user->getUsername(),
'entry.user_title' => $user->getTitle(),
'entry.user_alias' => $user->getAlias(),
];
if (null !== $activity) {
$values = array_merge($values, [
'entry.activity' => $activity->getName(),
'entry.activity_id' => $activity->getId(),
]);
foreach ($activity->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.activity.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
if (null !== $project) {
$values = array_merge($values, [
'entry.project' => $project->getName(),
'entry.project_id' => $project->getId(),
]);
foreach ($project->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.project.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
if (null !== $customer) {
$values = array_merge($values, [
'entry.customer' => $customer->getName(),
'entry.customer_id' => $customer->getId(),
]);
foreach ($customer->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.customer.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
foreach ($item->getAdditionalFields() as $name => $value) {
$values = array_merge($values, [
'entry.meta.' . $name => $value,
]);
}
return $values;
}
}

View File

@@ -0,0 +1,48 @@
<?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\Hydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
class InvoiceModelActivityHydrator implements InvoiceModelHydrator
{
public function hydrate(InvoiceModel $model): array
{
$activity = $model->getQuery()->getActivity();
if (null === $activity) {
return [];
}
$formatter = $model->getFormatter();
$currency = $model->getCurrency();
$values = [
'activity.id' => $activity->getId(),
'activity.name' => $activity->getName(),
'activity.comment' => $activity->getComment(),
'activity.fixed_rate' => $formatter->getFormattedMoney($activity->getFixedRate(), $currency),
'activity.fixed_rate_nc' => $formatter->getFormattedMoney($activity->getFixedRate(), null),
'activity.fixed_rate_plain' => $activity->getFixedRate(),
'activity.hourly_rate' => $formatter->getFormattedMoney($activity->getHourlyRate(), $currency),
'activity.hourly_rate_nc' => $formatter->getFormattedMoney($activity->getHourlyRate(), null),
'activity.hourly_rate_plain' => $activity->getHourlyRate(),
];
foreach ($activity->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'activity.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
return $values;
}
}

View File

@@ -0,0 +1,55 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Hydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
{
public function hydrate(InvoiceModel $model): array
{
$customer = $model->getCustomer();
if (null === $customer) {
return [];
}
$formatter = $model->getFormatter();
$currency = $model->getCurrency();
$values = [
'customer.id' => $customer->getId(),
'customer.address' => $customer->getAddress(),
'customer.name' => $customer->getName(),
'customer.contact' => $customer->getContact(),
'customer.company' => $customer->getCompany(),
'customer.vat' => $customer->getVatId(),
'customer.number' => $customer->getNumber(),
'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(),
'customer.comment' => $customer->getComment(),
'customer.fixed_rate' => $formatter->getFormattedMoney($customer->getFixedRate(), $currency),
'customer.fixed_rate_nc' => $formatter->getFormattedMoney($customer->getFixedRate(), null),
'customer.fixed_rate_plain' => $customer->getFixedRate(),
'customer.hourly_rate' => $formatter->getFormattedMoney($customer->getHourlyRate(), $currency),
'customer.hourly_rate_nc' => $formatter->getFormattedMoney($customer->getHourlyRate(), null),
'customer.hourly_rate_plain' => $customer->getHourlyRate(),
];
foreach ($customer->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'customer.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
return $values;
}
}

View File

@@ -0,0 +1,64 @@
<?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\Hydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
{
public function hydrate(InvoiceModel $model): array
{
$currency = $model->getCurrency();
$tax = $model->getCalculator()->getTax();
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$formatter = $model->getFormatter();
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
'invoice.currency' => $currency,
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency),
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, null),
'invoice.tax_plain' => $tax,
'invoice.total_time' => $formatter->getFormattedDuration($model->getCalculator()->getTimeWorked()),
'invoice.duration_decimal' => $formatter->getFormattedDecimalDuration($model->getCalculator()->getTimeWorked()),
'invoice.total' => $formatter->getFormattedMoney($total, $currency),
'invoice.total_nc' => $formatter->getFormattedMoney($total, null),
'invoice.total_plain' => $total,
'invoice.subtotal' => $formatter->getFormattedMoney($subtotal, $currency),
'invoice.subtotal_nc' => $formatter->getFormattedMoney($subtotal, null),
'invoice.subtotal_plain' => $subtotal,
'template.name' => $model->getTemplate()->getName(),
'template.company' => $model->getTemplate()->getCompany(),
'template.address' => $model->getTemplate()->getAddress(),
'template.title' => $model->getTemplate()->getTitle(),
'template.payment_terms' => $model->getTemplate()->getPaymentTerms(),
'template.due_days' => $model->getTemplate()->getDueDays(),
'template.vat_id' => $model->getTemplate()->getVatId(),
'template.contact' => $model->getTemplate()->getContact(),
'template.payment_details' => $model->getTemplate()->getPaymentDetails(),
'query.begin' => $formatter->getFormattedDateTime($model->getQuery()->getBegin()),
'query.day' => $model->getQuery()->getBegin()->format('d'),
'query.end' => $formatter->getFormattedDateTime($model->getQuery()->getEnd()),
'query.month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()),
'query.month_number' => $model->getQuery()->getBegin()->format('m'),
'query.year' => $model->getQuery()->getBegin()->format('Y'),
];
return $values;
}
}

View File

@@ -0,0 +1,58 @@
<?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\Hydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
class InvoiceModelProjectHydrator implements InvoiceModelHydrator
{
public function hydrate(InvoiceModel $model): array
{
$project = $model->getQuery()->getProject();
if (null === $project) {
return [];
}
$formatter = $model->getFormatter();
$currency = $model->getCurrency();
$values = [
'project.id' => $project->getId(),
'project.name' => $project->getName(),
'project.comment' => $project->getComment(),
'project.order_number' => $project->getOrderNumber(),
'project.start_date' => null !== $project->getStart() ? $formatter->getFormattedDateTime($project->getStart()) : '',
'project.end_date' => null !== $project->getEnd() ? $formatter->getFormattedDateTime($project->getEnd()) : '',
'project.order_date' => null !== $project->getOrderDate() ? $formatter->getFormattedDateTime($project->getOrderDate()) : '',
'project.fixed_rate' => $formatter->getFormattedMoney($project->getFixedRate(), $currency),
'project.fixed_rate_nc' => $formatter->getFormattedMoney($project->getFixedRate(), null),
'project.fixed_rate_plain' => $project->getFixedRate(),
'project.hourly_rate' => $formatter->getFormattedMoney($project->getHourlyRate(), $currency),
'project.hourly_rate_nc' => $formatter->getFormattedMoney($project->getHourlyRate(), null),
'project.hourly_rate_plain' => $project->getHourlyRate(),
'project.budget_money' => $formatter->getFormattedMoney($project->getBudget(), $currency),
'project.budget_money_nc' => $formatter->getFormattedMoney($project->getBudget(), null),
'project.budget_money_plain' => $project->getBudget(),
'project.budget_time' => $project->getTimeBudget(),
'project.budget_time_decimal' => $formatter->getFormattedDecimalDuration($project->getTimeBudget()),
'project.budget_time_minutes' => number_format($project->getTimeBudget() / 60),
];
foreach ($project->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'project.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
return $values;
}
}

View File

@@ -0,0 +1,42 @@
<?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\Hydrator;
use App\Entity\UserPreference;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
class InvoiceModelUserHydrator implements InvoiceModelHydrator
{
public function hydrate(InvoiceModel $model): array
{
$user = $model->getUser();
if (null === $user) {
return [];
}
$values = [
'user.name' => $user->getUsername(),
'user.email' => $user->getEmail(),
'user.title' => $user->getTitle(),
'user.alias' => $user->getAlias(),
];
/** @var UserPreference $metaField */
foreach ($user->getPreferences() as $metaField) {
$values = array_merge($values, [
'user.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
return $values;
}
}

View File

@@ -0,0 +1,17 @@
<?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;
interface InvoiceItemHydrator
{
public function setInvoiceModel(InvoiceModel $model);
public function hydrate(InvoiceItem $item): array;
}

View File

@@ -12,7 +12,12 @@ namespace App\Invoice;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\User;
use App\Entity\UserPreference;
use App\Invoice\Hydrator\InvoiceItemDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelActivityHydrator;
use App\Invoice\Hydrator\InvoiceModelCustomerHydrator;
use App\Invoice\Hydrator\InvoiceModelDefaultHydrator;
use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Repository\Query\InvoiceQuery;
/**
@@ -57,11 +62,25 @@ final class InvoiceModel
* @var InvoiceFormatter
*/
private $formatter;
/**
* @var InvoiceModelHydrator[]
*/
private $modelHydrator = [];
/**
* @var InvoiceItemHydrator[]
*/
private $itemHydrator = [];
public function __construct(InvoiceFormatter $formatter)
{
$this->invoiceDate = new \DateTime();
$this->formatter = $formatter;
$this->addModelHydrator(new InvoiceModelDefaultHydrator());
$this->addModelHydrator(new InvoiceModelCustomerHydrator());
$this->addModelHydrator(new InvoiceModelProjectHydrator());
$this->addModelHydrator(new InvoiceModelActivityHydrator());
$this->addModelHydrator(new InvoiceModelUserHydrator());
$this->addItemHydrator(new InvoiceItemDefaultHydrator());
}
/**
@@ -118,6 +137,22 @@ final class InvoiceModel
return $this;
}
public function addModelHydrator(InvoiceModelHydrator $hydrator): InvoiceModel
{
$this->modelHydrator[] = $hydrator;
return $this;
}
public function addItemHydrator(InvoiceItemHydrator $hydrator): InvoiceModel
{
$hydrator->setInvoiceModel($this);
$this->itemHydrator[] = $hydrator;
return $this;
}
public function getTemplate(): ?InvoiceTemplate
{
return $this->template;
@@ -136,7 +171,7 @@ final class InvoiceModel
}
/**
* @param Customer $customer
* @param Customer|null $customer
* @return InvoiceModel
*/
public function setCustomer($customer): InvoiceModel
@@ -223,146 +258,10 @@ final class InvoiceModel
public function toArray(): array
{
$model = $this;
$customer = $model->getCustomer();
$project = $model->getQuery()->getProject();
$activity = $model->getQuery()->getActivity();
$currency = $this->getCurrency();
$tax = $model->getCalculator()->getTax();
$total = $model->getCalculator()->getTotal();
$subtotal = $model->getCalculator()->getSubtotal();
$formatter = $model->getFormatter();
$values = [];
$values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
'invoice.currency' => $currency,
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency),
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, null),
'invoice.tax_plain' => $tax,
'invoice.total_time' => $formatter->getFormattedDuration($model->getCalculator()->getTimeWorked()),
'invoice.duration_decimal' => $formatter->getFormattedDecimalDuration($model->getCalculator()->getTimeWorked()),
'invoice.total' => $formatter->getFormattedMoney($total, $currency),
'invoice.total_nc' => $formatter->getFormattedMoney($total, null),
'invoice.total_plain' => $total,
'invoice.subtotal' => $formatter->getFormattedMoney($subtotal, $currency),
'invoice.subtotal_nc' => $formatter->getFormattedMoney($subtotal, null),
'invoice.subtotal_plain' => $subtotal,
'template.name' => $model->getTemplate()->getName(),
'template.company' => $model->getTemplate()->getCompany(),
'template.address' => $model->getTemplate()->getAddress(),
'template.title' => $model->getTemplate()->getTitle(),
'template.payment_terms' => $model->getTemplate()->getPaymentTerms(),
'template.due_days' => $model->getTemplate()->getDueDays(),
'template.vat_id' => $model->getTemplate()->getVatId(),
'template.contact' => $model->getTemplate()->getContact(),
'template.payment_details' => $model->getTemplate()->getPaymentDetails(),
'query.begin' => $formatter->getFormattedDateTime($model->getQuery()->getBegin()),
'query.day' => $model->getQuery()->getBegin()->format('d'),
'query.end' => $formatter->getFormattedDateTime($model->getQuery()->getEnd()),
'query.month' => $formatter->getFormattedMonthName($model->getQuery()->getBegin()),
'query.month_number' => $model->getQuery()->getBegin()->format('m'),
'query.year' => $model->getQuery()->getBegin()->format('Y'),
];
if (null !== $model->getUser()) {
$user = $model->getUser();
/** @var UserPreference $metaField */
foreach ($user->getPreferences() as $metaField) {
$values = array_merge($values, [
'user.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
$values = array_merge($values, [
'user.name' => $user->getUsername(),
'user.email' => $user->getEmail(),
'user.title' => $user->getTitle(),
'user.alias' => $user->getAlias(),
]);
}
if (null !== $activity) {
$values = array_merge($values, [
'activity.id' => $activity->getId(),
'activity.name' => $activity->getName(),
'activity.comment' => $activity->getComment(),
'activity.fixed_rate' => $formatter->getFormattedMoney($activity->getFixedRate(), $currency),
'activity.fixed_rate_nc' => $formatter->getFormattedMoney($activity->getFixedRate(), null),
'activity.fixed_rate_plain' => $activity->getFixedRate(),
'activity.hourly_rate' => $formatter->getFormattedMoney($activity->getHourlyRate(), $currency),
'activity.hourly_rate_nc' => $formatter->getFormattedMoney($activity->getHourlyRate(), null),
'activity.hourly_rate_plain' => $activity->getHourlyRate(),
]);
foreach ($activity->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'activity.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
if (null !== $project) {
$values = array_merge($values, [
'project.id' => $project->getId(),
'project.name' => $project->getName(),
'project.comment' => $project->getComment(),
'project.order_number' => $project->getOrderNumber(),
'project.start_date' => null !== $project->getStart() ? $formatter->getFormattedDateTime($project->getStart()) : '',
'project.end_date' => null !== $project->getEnd() ? $formatter->getFormattedDateTime($project->getEnd()) : '',
'project.order_date' => null !== $project->getOrderDate() ? $formatter->getFormattedDateTime($project->getOrderDate()) : '',
'project.fixed_rate' => $formatter->getFormattedMoney($project->getFixedRate(), $currency),
'project.fixed_rate_nc' => $formatter->getFormattedMoney($project->getFixedRate(), null),
'project.fixed_rate_plain' => $project->getFixedRate(),
'project.hourly_rate' => $formatter->getFormattedMoney($project->getHourlyRate(), $currency),
'project.hourly_rate_nc' => $formatter->getFormattedMoney($project->getHourlyRate(), null),
'project.hourly_rate_plain' => $project->getHourlyRate(),
'project.budget_money' => $formatter->getFormattedMoney($project->getBudget(), $currency),
'project.budget_money_nc' => $formatter->getFormattedMoney($project->getBudget(), null),
'project.budget_money_plain' => $project->getBudget(),
'project.budget_time' => $project->getTimeBudget(),
'project.budget_time_decimal' => $formatter->getFormattedDecimalDuration($project->getTimeBudget()),
'project.budget_time_minutes' => number_format($project->getTimeBudget() / 60),
]);
foreach ($project->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'project.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
if (null !== $customer) {
$values = array_merge($values, [
'customer.id' => $customer->getId(),
'customer.address' => $customer->getAddress(),
'customer.name' => $customer->getName(),
'customer.contact' => $customer->getContact(),
'customer.company' => $customer->getCompany(),
'customer.vat' => $customer->getVatId(),
'customer.number' => $customer->getNumber(),
'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage(),
'customer.comment' => $customer->getComment(),
'customer.fixed_rate' => $formatter->getFormattedMoney($customer->getFixedRate(), $currency),
'customer.fixed_rate_nc' => $formatter->getFormattedMoney($customer->getFixedRate(), null),
'customer.fixed_rate_plain' => $customer->getFixedRate(),
'customer.hourly_rate' => $formatter->getFormattedMoney($customer->getHourlyRate(), $currency),
'customer.hourly_rate_nc' => $formatter->getFormattedMoney($customer->getHourlyRate(), null),
'customer.hourly_rate_plain' => $customer->getHourlyRate(),
]);
foreach ($customer->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'customer.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
foreach ($this->modelHydrator as $hydrator) {
$values = array_merge($values, $hydrator->hydrate($this));
}
return $values;
@@ -370,107 +269,10 @@ final class InvoiceModel
public function itemToArray(InvoiceItem $invoiceItem): array
{
$formatter = $this->getFormatter();
$values = [];
$rate = $invoiceItem->getRate();
$appliedRate = $invoiceItem->getHourlyRate();
$amount = $formatter->getFormattedDuration($invoiceItem->getDuration());
$description = $invoiceItem->getDescription();
if ($invoiceItem->isFixedRate()) {
$appliedRate = $invoiceItem->getFixedRate();
$amount = $invoiceItem->getAmount();
}
$activity = $invoiceItem->getActivity();
$project = $invoiceItem->getProject();
$customer = $project->getCustomer();
$currency = $customer->getCurrency();
$user = $invoiceItem->getUser();
$begin = $invoiceItem->getBegin();
$end = $invoiceItem->getEnd();
if (empty($description) && null !== $activity) {
$description = $activity->getName();
}
// this should never happen!
if (empty($appliedRate)) {
$appliedRate = 0;
}
$values = [
'entry.row' => '',
'entry.description' => $description,
'entry.amount' => $amount,
'entry.type' => $invoiceItem->getType(),
'entry.category' => $invoiceItem->getCategory(),
'entry.rate' => $formatter->getFormattedMoney($appliedRate, $currency),
'entry.rate_nc' => $formatter->getFormattedMoney($appliedRate, null),
'entry.rate_plain' => $appliedRate,
'entry.total' => $formatter->getFormattedMoney($rate, $currency),
'entry.total_nc' => $formatter->getFormattedMoney($rate, null),
'entry.total_plain' => $rate,
'entry.currency' => $currency,
'entry.duration' => $invoiceItem->getDuration(),
'entry.duration_decimal' => $formatter->getFormattedDecimalDuration($invoiceItem->getDuration()),
'entry.duration_minutes' => number_format($invoiceItem->getDuration() / 60),
'entry.begin' => $formatter->getFormattedDateTime($begin),
'entry.begin_time' => $formatter->getFormattedTime($begin),
'entry.begin_timestamp' => $begin->getTimestamp(),
'entry.end' => $formatter->getFormattedDateTime($end),
'entry.end_time' => $formatter->getFormattedTime($end),
'entry.end_timestamp' => $end->getTimestamp(),
'entry.date' => $formatter->getFormattedDateTime($begin),
'entry.user_id' => $user->getId(),
'entry.user_name' => $user->getUsername(),
'entry.user_title' => $user->getTitle(),
'entry.user_alias' => $user->getAlias(),
];
if (null !== $activity) {
$values = array_merge($values, [
'entry.activity' => $activity->getName(),
'entry.activity_id' => $activity->getId(),
]);
foreach ($activity->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.activity.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
if (null !== $project) {
$values = array_merge($values, [
'entry.project' => $project->getName(),
'entry.project_id' => $project->getId(),
]);
foreach ($project->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.project.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
if (null !== $customer) {
$values = array_merge($values, [
'entry.customer' => $customer->getName(),
'entry.customer_id' => $customer->getId(),
]);
foreach ($customer->getVisibleMetaFields() as $metaField) {
$values = array_merge($values, [
'entry.customer.meta.' . $metaField->getName() => $metaField->getValue(),
]);
}
}
foreach ($invoiceItem->getAdditionalFields() as $name => $value) {
$values = array_merge($values, [
'entry.meta.' . $name => $value,
]);
foreach ($this->itemHydrator as $hydrator) {
$values = array_merge($values, $hydrator->hydrate($invoiceItem));
}
return $values;

View File

@@ -0,0 +1,15 @@
<?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;
interface InvoiceModelHydrator
{
public function hydrate(InvoiceModel $model): array;
}

View File

@@ -10,8 +10,10 @@
namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceModel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\String\UnicodeString;
/**
* @internal
@@ -43,6 +45,23 @@ abstract class AbstractRenderer
return false;
}
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;
}
/**
* @param mixed $file
* @param string $filename

View File

@@ -99,8 +99,9 @@ abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
}
$filename = $this->saveSpreadsheet($spreadsheet);
$userFilename = $this->buildFilename($model) . '.' . $document->getFileExtension();
return $this->getFileResponse($filename, basename($document->getFilename()));
return $this->getFileResponse($filename, $userFilename);
}
/**

View File

@@ -30,8 +30,6 @@ final class DocxRenderer extends AbstractRenderer implements RendererInterface
{
Settings::setOutputEscapingEnabled(false);
$filename = basename($document->getFilename());
$xmlEscaper = new Xml();
$template = new TemplateProcessor($document->getFilename());
@@ -70,6 +68,8 @@ final class DocxRenderer extends AbstractRenderer implements RendererInterface
clearstatcache(true, $cacheFile);
$filename = $this->buildFilename($model) . '.' . $document->getFileExtension();
return $this->getFileResponse(new Stream($cacheFile), $filename);
}

View File

@@ -0,0 +1,52 @@
<?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;
final class Version20200204124425 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds language and decimal_duration column to invoice template table';
}
protected function isSupportingForeignKeys(): bool
{
return false;
}
public function isTransactional(): bool
{
if ($this->isPlatformSqlite()) {
// does fail if we use transactions, as tables are re-created and foreign keys would fail
return false;
}
return true;
}
public function up(Schema $schema): void
{
$invoiceTemplates = $schema->getTable('kimai2_invoice_templates');
$invoiceTemplates->addColumn('decimal_duration', 'boolean', ['notnull' => true, 'default' => false]);
$invoiceTemplates->addColumn('language', 'string', ['notnull' => false, 'length' => 6]);
}
public function down(Schema $schema): void
{
$invoiceTemplates = $schema->getTable('kimai2_invoice_templates');
$invoiceTemplates->dropColumn('language');
$invoiceTemplates->dropColumn('decimal_duration');
}
}

View File

@@ -102,17 +102,18 @@ class Extensions extends AbstractExtension
* Transforms seconds into a duration string.
*
* @param int|Timesheet $duration
* @param string $format
* @param bool $decimal
* @return string
*/
public function duration($duration, $format = null)
public function duration($duration, $decimal = false)
{
$duration = $this->getSecondsForDuration($duration);
if (null === $format) {
$format = $this->localeSettings->getDurationFormat();
if ($decimal) {
return $this->durationDecimal($duration);
}
$duration = $this->getSecondsForDuration($duration);
$format = $this->localeSettings->getDurationFormat();
return $this->formatDuration($duration, $format);
}

View File

@@ -286,7 +286,7 @@
"version": "1.0.1"
},
"php": {
"version": "7.2"
"version": "7.2.9"
},
"php-cs-fixer/diff": {
"version": "v1.2.0"
@@ -567,12 +567,18 @@
"symfony/polyfill-ctype": {
"version": "v1.8.0"
},
"symfony/polyfill-intl-grapheme": {
"version": "v1.13.1"
},
"symfony/polyfill-intl-icu": {
"version": "v1.6.0"
},
"symfony/polyfill-intl-idn": {
"version": "v1.11.0"
},
"symfony/polyfill-intl-normalizer": {
"version": "v1.13.1"
},
"symfony/polyfill-mbstring": {
"version": "v1.6.0"
},
@@ -633,6 +639,9 @@
"symfony/stopwatch": {
"version": "v4.0.3"
},
"symfony/string": {
"version": "v5.0.4"
},
"symfony/swiftmailer-bundle": {
"version": "2.5",
"recipe": {
@@ -711,14 +720,14 @@
"theseer/tokenizer": {
"version": "1.1.0"
},
"twig/extensions": {
"version": "1.0",
"recipe": {
"repo": "github.com/symfony/recipes",
"branch": "master",
"version": "1.0",
"ref": "4851df0afc426b8f07204379d21fca25b6df5d68"
}
"twig/extra-bundle": {
"version": "v3.0.1"
},
"twig/intl-extra": {
"version": "v3.0.1"
},
"twig/string-extra": {
"version": "v3.0.1"
},
"twig/twig": {
"version": "v2.4.4"

View File

@@ -58,7 +58,7 @@
{% endblock %}
{% block footer %}
<!-- Page rendered on {{ 'now'|localizeddate('long', 'long', null, 'UTC') }} -->
<!-- Page rendered on {{ 'now'|date_full }} -->
{% endblock %}
{% block navbar_start %}

View File

@@ -1,31 +1,33 @@
{% if form_theme is defined %}
{% form_theme form form_theme %}
{% endif %}
<div class="box box-primary">
{% block form_before %}{% endblock %}
{{ form_start(form) }}
<div class="box-header with-border">
<h3 class="box-title">
{{ title }}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %}
<a href="{{ form.vars.docu_chapter|docu_link }}" target="_blank"><i class="{{ 'help'|icon }}"></i></a>
{% endif %}
</h3>
</div>
<div class="box-body">
{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'boxtype': 'primary'} %}
{% block box_before %}
{% block form_before %}{% endblock %}
{{ form_start(form) }}
{% endblock %}
{% block box_title %}
{{ title }}
{% if form.vars.docu_chapter is defined and form.vars.docu_chapter is not empty %}
<a href="{{ form.vars.docu_chapter|docu_link }}" target="_blank"><i class="{{ 'help'|icon }}"></i></a>
{% endif %}
{% endblock %}
{% block box_body %}
{% block form_body %}
{{ form_widget(form) }}
{% endblock %}
</div>
<div class="box-footer">
{% endblock %}
{% block box_footer %}
<input type="submit" value="{{ 'action.save'|trans }}" class="btn btn-primary" />
{% if back|default(false) %}
<a href="{{ back }}" class="btn btn-link">{{ 'action.back'|trans }}</a>
{% endif %}
{% if reset|default(true) %}
<input type="reset" value="{{ 'action.reset'|trans }}" class="btn btn-link pull-right" />
<input type="reset" value="{{ 'action.reset'|trans }}" class="btn btn-link pull-right" />
{% endif %}
</div>
{{ form_end(form) }}
{% block form_after %}{% endblock %}
</div>
{% endblock %}
{% block box_after %}
{{ form_end(form) }}
{% block form_after %}{% endblock %}
{% endblock %}
{% endembed %}

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<title>{% block title %}{{ model.template.title }}{% endblock %}</title>
<title>{% block title %}{{ model.numberGenerator.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
{{ encore_entry_link_tags('app') }}
{{ encore_entry_script_tags('app') }}
</head>

View File

@@ -1,46 +1,47 @@
{% 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 }}: {{ model.invoiceDate|date_short }}</small>
<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 }}
{{ '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 }}:
{{ 'label.vat_id'|trans({}, 'messages', language) }}:
{{ model.template.vatId }}
{% endif %}
</address>
</div>
<div class="col-sm-2"></div>
<div class="col-sm-5">
{% set customerPhone = model.customer.phone|default(model.customer.mobile) %}
{{ 'invoice.to'|trans }}
{{ '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 }}: {{ model.customer.vatId }}
{{ 'label.vat_id'|trans({}, 'messages', language) }}: {{ model.customer.vatId }}
{% endif %}
{% if model.customer.number is not empty %}
<br>
{{ 'label.number'|trans }}: {{ model.customer.number }}
{{ '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 }}: {{ model.query.project.orderNumber }}
{{ 'label.orderNumber'|trans({}, 'messages', language) }}: {{ model.query.project.orderNumber }}
{% endif %}
</address>
</div>
@@ -49,11 +50,11 @@
<div class="row">
<div class="col-sm-5">
<p contenteditable="true">
<strong>{{ 'invoice.number'|trans }}:</strong>
<strong>{{ 'invoice.number'|trans({}, 'messages', language) }}:</strong>
{{ model.numberGenerator.invoiceNumber }}
<br>
<strong>{{ 'invoice.due_days'|trans }}:</strong>
<strong>{{ 'invoice.due_days'|trans({}, 'messages', language) }}:</strong>
{{ model.dueDate|date_short }}
</p>
</div>
@@ -65,16 +66,16 @@
<table class="table">
<thead>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.description'|trans }}</th>
<th class="text-right">{{ 'label.unit_price'|trans }}</th>
<th class="text-right">{{ 'label.amount'|trans }}</th>
<th class="text-right">{{ 'label.total_rate'|trans }}</th>
<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_decimal %}
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
@@ -99,19 +100,19 @@
<tfoot>
<tr>
<td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans }}
{{ '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 }} ({{ model.calculator.vat }}%)
{{ '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 }}</strong>
<strong>{{ 'invoice.total'|trans({}, 'messages', language) }}</strong>
</td>
<td class="text-right">
<strong>{{ model.calculator.total|money(model.calculator.currency) }}</strong>
@@ -134,11 +135,11 @@
<footer class="footer">
<p>
<strong>{{ 'label.address'|trans }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<strong>{{ 'label.address'|trans({}, 'messages', language) }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.invoice_bank_account'|trans }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<strong>{{ 'label.invoice_bank_account'|trans({}, 'messages', language) }}</strong>: {{ model.template.paymentDetails|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<br>
<strong>{{ 'label.contact'|trans }}</strong>: {{ model.template.contact|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
<strong>{{ 'label.contact'|trans({}, 'messages', language) }}</strong>: {{ model.template.contact|replace({"\n": ' &ndash; ', "\r\n": ' &ndash; ', "\r": ' &ndash; '})|raw }}
</p>
</footer>

View File

@@ -1,9 +1,9 @@
{% import "macros/widgets.html.twig" as widgets %}
{% 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" id="freelancer-invoice">
<div class="col-xs-12">
<header>
@@ -12,33 +12,33 @@
</address>
</header>
<article class="address">
<h1>{{ 'invoice.to'|trans }}</h1>
<h1>{{ 'invoice.to'|trans({}, 'messages', language) }}</h1>
<address>
<p>
<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 }}: {{ model.customer.vatId }}
{{ 'label.vat_id'|trans({}, 'messages', language) }}: {{ model.customer.vatId }}
{% endif %}
</p>
</address>
<table class="meta">
<tr>
<th>{{ 'label.date'|trans }}:</th>
<th>{{ 'label.date'|trans({}, 'messages', language) }}:</th>
<td contenteditable="true">{{ model.invoiceDate|date_short }}</td>
</tr>
<tr>
<th>{{ 'invoice.service_date'|trans }}:</th>
<td><span contenteditable="true">{{ model.query.end|month_name|trans }} {{ model.query.end|date('Y') }}</td>
<th>{{ 'invoice.service_date'|trans({}, 'messages', language) }}:</th>
<td><span contenteditable="true">{{ model.query.end|month_name|trans({}, 'messages', language) }} {{ model.query.end|date('Y') }}</td>
</tr>
<tr>
<th>{{ 'invoice.number'|trans }}:</th>
<th>{{ 'invoice.number'|trans({}, 'messages', language) }}:</th>
<td><span contenteditable="true">{{ model.numberGenerator.invoiceNumber }}</td>
</tr>
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<tr>
<th>{{ 'label.orderNumber'|trans }}</th>
<th>{{ 'label.orderNumber'|trans({}, 'messages', language) }}</th>
<td contenteditable="true">
{{ model.query.project.orderNumber }}
</td>
@@ -46,7 +46,7 @@
{% endif %}
{% if model.template.vatId is not empty %}
<tr>
<th>{{ 'label.vat_id'|trans }}:</th>
<th>{{ 'label.vat_id'|trans({}, 'messages', language) }}:</th>
<td>
{{ model.template.vatId }}
</td>
@@ -55,22 +55,19 @@
</table>
</article>
<article class="invoice-items">
<h2>{{ model.template.title|default('timesheet'|trans({}, 'invoice-renderer')) }}</h2>
<p contenteditable="true">
{{ 'label.invoice_salutation'|trans|nl2br }}
</p>
<h2>{{ model.template.title }}</h2>
<table class="inventory">
<thead>
<tr>
<th>{{ 'label.description'|trans }}</th>
<th class="text-right">{{ 'label.unit_price'|trans }}</th>
<th class="text-right">{{ 'label.amount'|trans }}</th>
<th class="text-right">{{ 'label.total_rate'|trans }}</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 %}
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
@@ -95,15 +92,15 @@
</table>
<table class="balance">
<tr>
<th>{{ 'invoice.subtotal'|trans }}</th>
<th>{{ 'invoice.subtotal'|trans({}, 'messages', language) }}</th>
<td>{{ model.calculator.subtotal|money(model.calculator.currency) }}</td>
</tr>
<tr>
<th>{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)</th>
<th>{{ 'invoice.tax'|trans({}, 'messages', language) }} ({{ model.calculator.vat }}%)</th>
<td>{{ model.calculator.tax|money(model.calculator.currency) }}</td>
</tr>
<tr>
<th class="total">{{ 'invoice.total'|trans }}</th>
<th class="total">{{ 'invoice.total'|trans({}, 'messages', language) }}</th>
<td class="total">{{ model.calculator.total|money(model.calculator.currency) }}</td>
</tr>
</table>
@@ -119,7 +116,7 @@
<div class="row">
<div class="col-sm-4" contenteditable="true">
<p>
<strong>{{ 'label.address'|trans }}</strong>
<strong>{{ 'label.address'|trans({}, 'messages', language) }}</strong>
</p>
<p>
{{ model.template.company }}<br>
@@ -128,7 +125,7 @@
</div>
<div class="col-sm-4 text-center" contenteditable="true">
<p>
<strong>{{ 'label.invoice_bank_account'|trans }}</strong>
<strong>{{ 'label.invoice_bank_account'|trans({}, 'messages', language) }}</strong>
</p>
<p>
{{ model.template.paymentDetails|nl2br }}
@@ -136,7 +133,7 @@
</div>
<div class="col-sm-4 text-right" contenteditable="true">
<p>
<strong>{{ 'label.contact'|trans }}</strong>
<strong>{{ 'label.contact'|trans({}, 'messages', language) }}</strong>
</p>
<p>
{{ model.template.contact|nl2br }}
@@ -146,5 +143,4 @@
</footer>
</div>
</div>
{% endblock %}

View File

@@ -1,11 +1,13 @@
{% import "macros/widgets.html.twig" as widgets %}
{% 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|default('timesheet'|trans({}, 'invoice-renderer')) }}</span>
<span contenteditable="true">{{ model.template.title }}</span>
</h2>
</div>
</div>
@@ -14,7 +16,7 @@
<div class="col-xs-12">
<table class="table no-border table-condensed">
<tr>
<th>{{ 'invoice.from'|trans }}</th>
<th>{{ 'invoice.from'|trans({}, 'messages', language) }}</th>
<td contenteditable="true">
{% if model.query.user is not empty %}
{{ widgets.username(model.query.user) }}
@@ -24,17 +26,17 @@
</td>
</tr>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.date'|trans({}, 'messages', language) }}</th>
<td contenteditable="true">
{% if model.query.begin|date('m') != model.query.end|date('m') or model.query.begin|date('Y') != model.query.end|date('Y') %}
{{ model.query.begin|date_short }} - {{ model.query.end|date_short }}
{% else %}
{{ model.query.end|month_name|trans }} {{ model.query.end|date('Y') }}
{{ model.query.end|month_name|trans({}, 'messages', language) }} {{ model.query.end|date('Y') }}
{% endif %}
</td>
</tr>
<tr>
<th>{{ 'label.customer'|trans }}</th>
<th>{{ 'label.customer'|trans({}, 'messages', language) }}</th>
<td contenteditable="true">
{% if model.customer.number is not empty %}[{{ model.customer.number }}]{% endif %}
{{ model.customer.name }}{% if model.customer.contact is not empty %} / {{ model.customer.contact }}{% endif %}
@@ -42,7 +44,7 @@
</tr>
{% if model.query.project is not empty and model.query.project.orderNumber is not empty %}
<tr>
<th>{{ 'label.orderNumber'|trans }}</th>
<th>{{ 'label.orderNumber'|trans({}, 'messages', language) }}</th>
<td contenteditable="true">
{{ model.query.project.orderNumber }}
</td>
@@ -57,12 +59,12 @@
<table class="table table-striped">
<thead>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th>{{ 'label.date'|trans({}, 'messages', language) }}</th>
{% if model.query.user is empty %}
<th>{{ 'label.user'|trans }}</th>
<th>{{ 'label.user'|trans({}, 'messages', language) }}</th>
{% endif %}
<th>{{ 'label.activity'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th>
<th>{{ 'label.activity'|trans({}, 'messages', language) }}</th>
<th>{{ 'label.hours'|trans({}, 'messages', language) }}</th>
</tr>
</thead>
<tbody>
@@ -79,7 +81,7 @@
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }}
{% endif %}
</td>
<td>{{ entry.duration|duration }}</td>
<td>{{ entry.duration|duration(isDecimal) }}</td>
</tr>
{% endfor %}
</tbody>
@@ -89,8 +91,8 @@
{% if model.query.user is empty %}
<th></th>
{% endif %}
<th>{{ 'invoice.total_working_time'|trans }}</th>
<th>{{ model.calculator.timeWorked|duration }}</th>
<th>{{ 'invoice.total_working_time'|trans({}, 'messages', language) }}</th>
<th>{{ model.calculator.timeWorked|duration(isDecimal) }}</th>
</tr>
</tfoot>
</table>
@@ -100,7 +102,7 @@
<div class="row">
<div class="col-xs-12">
{% if model.template.paymentTerms is not empty %}
<p class="lead">{{ 'label.payment_terms'|trans }}</p>
<p class="lead">{{ 'label.payment_terms'|trans({}, 'messages', language) }}</p>
<p class="text-muted well well-sm no-shadow" contenteditable="true" style="margin-bottom: 100px">
{{ model.template.paymentTerms|trim|nl2br }}
@@ -111,10 +113,10 @@
<table class="table">
<tbody>
<tr>
<th style="padding-bottom: 60px">{{ 'invoice.signature_user'|trans }}</th>
<th style="padding-bottom: 60px">{{ 'invoice.signature_user'|trans({}, 'messages', language) }}</th>
</tr>
<tr>
<th>{{ 'invoice.signature_customer'|trans }}</th>
<th>{{ 'invoice.signature_customer'|trans({}, 'messages', language) }}</th>
</tr>
</tbody>
</table>

View File

@@ -46,10 +46,22 @@
{{ form_row(form.vat) }}
</div>
</div>
{{ form_row(form.renderer) }}
{{ form_row(form.calculator) }}
{{ form_row(form.numberGenerator) }}
<div class="row">
<div class="col-md-6">
{{ form_row(form.renderer) }}
</div>
<div class="col-md-6">
{{ form_row(form.language) }}
</div>
</div>
<div class="row">
<div class="col-md-6">
{{ form_row(form.calculator) }}
</div>
<div class="col-md-6">
{{ form_row(form.numberGenerator) }}
</div>
</div>
{{ form_widget(form) }}
{% endblock %}
{% endembed %}

View File

@@ -33,7 +33,7 @@ class SystemConfigurationControllerTest extends ControllerBaseTest
$result = $client->getCrawler()->filter('section.content div.box.box-primary');
$this->assertEquals(count($expectedForms), count($result));
$result = $client->getCrawler()->filter('section.content div.box.box-primary form');
$result = $client->getCrawler()->filter('section.content form div.box.box-primary');
$this->assertEquals(count($expectedForms), count($result));
foreach ($expectedForms as $formConfig) {

View File

@@ -0,0 +1,30 @@
<?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\InvoiceDocument;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Entity\InvoiceDocument
*/
class InvoiceDocumentTest extends TestCase
{
public function testDefaultValues()
{
$dir = realpath(__DIR__ . '/../../templates/invoice/renderer');
$sut = new InvoiceDocument(new \SplFileInfo($dir . '/default.html.twig'));
self::assertEquals('twig', $sut->getFileExtension());
self::assertStringContainsString('templates/invoice/renderer/default.html.twig', $sut->getFilename());
self::assertEquals('default', $sut->getId());
self::assertEquals('default.html.twig', $sut->getName());
}
}

View File

@@ -35,6 +35,8 @@ class InvoiceTemplateTest extends TestCase
self::assertEquals('default', $sut->getNumberGenerator());
self::assertEquals('default', $sut->getRenderer());
self::assertEquals(30, $sut->getDueDays());
self::assertFalse($sut->isDecimalDuration());
self::assertNull($sut->getLanguage());
}
public function testSetNullForOptionalValues()
@@ -46,6 +48,7 @@ class InvoiceTemplateTest extends TestCase
self::assertInstanceOf(InvoiceTemplate::class, $sut->setContact(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setAddress(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setPaymentTerms(null));
self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage(null));
}
public function testSetterAndGetter()
@@ -66,6 +69,12 @@ class InvoiceTemplateTest extends TestCase
self::assertInstanceOf(InvoiceTemplate::class, $sut->setVatId('1234567890'));
self::assertEquals('1234567890', $sut->getVatId());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setDecimalDuration(true));
self::assertTrue($sut->isDecimalDuration());
self::assertInstanceOf(InvoiceTemplate::class, $sut->setLanguage('de'));
self::assertEquals('de', $sut->getLanguage());
}
public function testToString()

View File

@@ -0,0 +1,93 @@
<?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\Hydrator;
use App\Invoice\Hydrator\InvoiceItemDefaultHydrator;
use App\Tests\Invoice\Renderer\RendererTestTrait;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\Hydrator\InvoiceItemDefaultHydrator
*/
class InvoiceItemDefaultHydratorTest extends TestCase
{
use RendererTestTrait;
public function testHydrate()
{
$model = $this->getInvoiceModel();
$sut = new InvoiceItemDefaultHydrator();
$sut->setInvoiceModel($model);
$result = $sut->hydrate($model->getCalculator()->getEntries()[0]);
$metaFields = ['entry.meta.foo-timesheet'];
$this->assertEntryStructure($result, $metaFields);
$result = $sut->hydrate($model->getCalculator()->getEntries()[1]);
$metaFields = ['entry.meta.foo-timesheet2'];
$this->assertEntryStructure($result, $metaFields);
}
protected function assertEntryStructure(array $model, array $metaFields)
{
$keys = [
'entry.row',
'entry.description',
'entry.amount',
'entry.rate',
'entry.rate_nc',
'entry.rate_plain',
'entry.total',
'entry.total_nc',
'entry.total_plain',
'entry.currency',
'entry.duration',
'entry.duration_decimal',
'entry.duration_minutes',
'entry.begin',
'entry.begin_time',
'entry.begin_timestamp',
'entry.end',
'entry.end_time',
'entry.end_timestamp',
'entry.date',
'entry.user_id',
'entry.user_name',
'entry.user_alias',
'entry.user_title',
'entry.activity',
'entry.activity_id',
'entry.activity.meta.foo-activity',
'entry.project',
'entry.project_id',
'entry.project.meta.foo-project',
'entry.customer',
'entry.customer_id',
'entry.customer.meta.foo-customer',
'entry.category',
'entry.type',
];
$keys = array_merge($keys, $metaFields);
foreach ($keys as $key) {
$this->assertArrayHasKey($key, $model);
}
$expectedKeys = array_merge([], $keys);
sort($expectedKeys);
$givenKeys = array_keys($model);
sort($givenKeys);
$this->assertEquals($expectedKeys, $givenKeys);
$this->assertEquals(count($keys), count($givenKeys));
}
}

View File

@@ -0,0 +1,58 @@
<?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\Hydrator;
use App\Invoice\Hydrator\InvoiceModelActivityHydrator;
use App\Tests\Invoice\Renderer\RendererTestTrait;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\Hydrator\InvoiceModelActivityHydrator
*/
class InvoiceModelActivityHydratorTest extends TestCase
{
use RendererTestTrait;
public function testHydrate()
{
$model = $this->getInvoiceModel();
$sut = new InvoiceModelActivityHydrator();
$result = $sut->hydrate($model);
$this->assertModelStructure($result);
$model->getQuery()->setActivity(null);
$result = $sut->hydrate($model);
self::assertEmpty($result);
}
protected function assertModelStructure(array $model)
{
$keys = [
'activity.id',
'activity.name',
'activity.comment',
'activity.fixed_rate',
'activity.fixed_rate_nc',
'activity.fixed_rate_plain',
'activity.hourly_rate',
'activity.hourly_rate_nc',
'activity.hourly_rate_plain',
'activity.meta.foo-activity',
];
$givenKeys = array_keys($model);
sort($keys);
sort($givenKeys);
$this->assertEquals($keys, $givenKeys);
}
}

View 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\Hydrator;
use App\Invoice\Hydrator\InvoiceModelCustomerHydrator;
use App\Tests\Invoice\Renderer\RendererTestTrait;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\Hydrator\InvoiceModelCustomerHydrator
*/
class InvoiceModelCustomerHydratorTest extends TestCase
{
use RendererTestTrait;
public function testHydrate()
{
$model = $this->getInvoiceModel();
$sut = new InvoiceModelCustomerHydrator();
$result = $sut->hydrate($model);
$this->assertModelStructure($result);
$model->setCustomer(null);
$result = $sut->hydrate($model);
self::assertEmpty($result);
}
protected function assertModelStructure(array $model)
{
$keys = [
'customer.id',
'customer.address',
'customer.name',
'customer.contact',
'customer.company',
'customer.vat',
'customer.country',
'customer.number',
'customer.homepage',
'customer.comment',
'customer.fixed_rate',
'customer.fixed_rate_nc',
'customer.fixed_rate_plain',
'customer.hourly_rate',
'customer.hourly_rate_nc',
'customer.hourly_rate_plain',
'customer.meta.foo-customer',
];
$givenKeys = array_keys($model);
sort($keys);
sort($givenKeys);
$this->assertEquals($keys, $givenKeys);
}
}

View File

@@ -0,0 +1,76 @@
<?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\Hydrator;
use App\Invoice\Hydrator\InvoiceModelDefaultHydrator;
use App\Tests\Invoice\Renderer\RendererTestTrait;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\Hydrator\InvoiceModelDefaultHydrator
*/
class InvoiceModelDefaultHydratorTest extends TestCase
{
use RendererTestTrait;
public function testHydrate()
{
$model = $this->getInvoiceModel();
$sut = new InvoiceModelDefaultHydrator();
$result = $sut->hydrate($model);
$this->assertModelStructure($result);
}
protected function assertModelStructure(array $model, $hasProject = true)
{
$keys = [
'invoice.due_date',
'invoice.date',
'invoice.number',
'invoice.currency',
'invoice.currency_symbol',
'invoice.vat',
'invoice.tax',
'invoice.tax_nc',
'invoice.tax_plain',
'invoice.total_time',
'invoice.duration_decimal',
'invoice.total',
'invoice.total_nc',
'invoice.total_plain',
'invoice.subtotal',
'invoice.subtotal_nc',
'invoice.subtotal_plain',
'template.name',
'template.company',
'template.address',
'template.title',
'template.payment_terms',
'template.due_days',
'template.vat_id',
'template.contact',
'template.payment_details',
'query.begin',
'query.day',
'query.end',
'query.month',
'query.month_number',
'query.year',
];
$givenKeys = array_keys($model);
sort($keys);
sort($givenKeys);
$this->assertEquals($keys, $givenKeys);
}
}

View File

@@ -0,0 +1,68 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Hydrator;
use App\Invoice\Hydrator\InvoiceModelProjectHydrator;
use App\Tests\Invoice\Renderer\RendererTestTrait;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\Hydrator\InvoiceModelProjectHydrator
*/
class InvoiceModelProjectHydratorTest extends TestCase
{
use RendererTestTrait;
public function testHydrate()
{
$model = $this->getInvoiceModel();
$sut = new InvoiceModelProjectHydrator();
$result = $sut->hydrate($model);
$this->assertModelStructure($result);
$model->getQuery()->setProject(null);
$result = $sut->hydrate($model);
self::assertEmpty($result);
}
protected function assertModelStructure(array $model)
{
$keys = [
'project.id',
'project.name',
'project.comment',
'project.order_date',
'project.order_number',
'project.fixed_rate',
'project.fixed_rate_nc',
'project.fixed_rate_plain',
'project.hourly_rate',
'project.hourly_rate_nc',
'project.hourly_rate_plain',
'project.meta.foo-project',
'project.start_date',
'project.end_date',
'project.budget_money',
'project.budget_money_nc',
'project.budget_money_plain',
'project.budget_time',
'project.budget_time_decimal',
'project.budget_time_minutes',
];
$givenKeys = array_keys($model);
sort($keys);
sort($givenKeys);
$this->assertEquals($keys, $givenKeys);
}
}

View File

@@ -0,0 +1,50 @@
<?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\Hydrator;
use App\Invoice\Hydrator\InvoiceModelUserHydrator;
use App\Tests\Invoice\Renderer\RendererTestTrait;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\Hydrator\InvoiceModelUserHydrator
*/
class InvoiceModelUserHydratorTest extends TestCase
{
use RendererTestTrait;
public function testHydrate()
{
$model = $this->getInvoiceModel();
$sut = new InvoiceModelUserHydrator();
$result = $sut->hydrate($model);
$this->assertModelStructure($result);
}
protected function assertModelStructure(array $model)
{
$keys = [
'user.alias',
'user.email',
'user.name',
'user.title',
'user.meta.hello',
'user.meta.kitty',
];
$givenKeys = array_keys($model);
sort($keys);
sort($givenKeys);
$this->assertEquals($keys, $givenKeys);
}
}

View File

@@ -57,7 +57,7 @@ class CsvRendererTest extends TestCase
$file = $response->getFile();
$this->assertEquals('text/csv', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=export.csv', $response->headers->get('Content-Disposition'));
$this->assertEquals('attachment; filename=200204-customer_with_special_name.csv', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
$content = file_get_contents($file->getRealPath());

View File

@@ -10,7 +10,10 @@
namespace App\Tests\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\InvoiceItem;
use App\Invoice\InvoiceItemHydrator;
use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Response;
@@ -29,6 +32,26 @@ class DebugRendererTest extends TestCase
*/
public function testRender(InvoiceModel $model, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3, $hasProject, $metaFields = [])
{
$itemHydrator = new class() implements InvoiceItemHydrator {
public function setInvoiceModel(InvoiceModel $model)
{
}
public function hydrate(InvoiceItem $item): array
{
return ['testFromItemHydrator' => 'foo'];
}
};
$model->addItemHydrator($itemHydrator);
$modelHydrator = new class() implements InvoiceModelHydrator {
public function hydrate(InvoiceModel $model): array
{
return ['testFromModelHydrator' => 'foo'];
}
};
$model->addModelHydrator($modelHydrator);
$document = new InvoiceDocument(new \SplFileInfo(__DIR__ . '/DebugRenderer.php'));
$sut = new DebugRenderer();
/** @var Response $response */
@@ -119,6 +142,7 @@ class DebugRendererTest extends TestCase
'user.title',
'user.meta.hello',
'user.meta.kitty',
'testFromModelHydrator'
];
if ($hasProject) {
@@ -191,6 +215,7 @@ class DebugRendererTest extends TestCase
'entry.customer.meta.foo-customer',
'entry.category',
'entry.type',
'testFromItemHydrator'
];
$keys = array_merge($keys, $metaFields);

View File

@@ -47,7 +47,7 @@ class DocxRendererTest extends TestCase
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.wordprocessingml.document', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=company.docx', $response->headers->get('Content-Disposition'));
$this->assertEquals('attachment; filename=200204-customer_with_special_name.docx', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));

View File

@@ -58,7 +58,7 @@ class OdsRendererTest extends TestCase
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=open-spreadsheet.ods', $response->headers->get('Content-Disposition'));
$this->assertEquals('attachment; filename=200204-customer_with_special_name.ods', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));

View File

@@ -107,6 +107,7 @@ trait RendererTestTrait
$user->addPreference((new UserPreference())->setName('hello')->setValue('world'));
$customer = new Customer();
$customer->setName('customer,with/special#name');
$customer->setCurrency('EUR');
$customer->setMetaField((new CustomerMeta())->setName('foo-customer')->setValue('bar-customer')->setIsVisible(true));
@@ -232,6 +233,7 @@ trait RendererTestTrait
$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));

View File

@@ -62,6 +62,7 @@ class TwigRendererTest extends KernelTestCase
$content = $response->getContent();
$this->assertStringContainsString('<title>200204-customer_with_special_name</title>', $content);
$this->assertStringContainsString('<h2 class="page-header">
<span contenteditable="true">a very *long* test invoice / template title with [special] character</span>
</h2>', $content);

View File

@@ -58,7 +58,7 @@ class XlsxRendererTest extends TestCase
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=spreadsheet.xlsx', $response->headers->get('Content-Disposition'));
$this->assertEquals('attachment; filename=200204-customer_with_special_name.xlsx', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));

View File

@@ -216,11 +216,11 @@ class ExtensionsTest extends TestCase
$sut = $this->getSut($this->localeEn);
$this->assertEquals('02:37 h', $sut->duration($record->getDuration()));
$this->assertEquals('02:37:17 h', $sut->duration($record->getDuration(), '%h:%m:%s h'));
$this->assertEquals('2.62', $sut->duration($record->getDuration(), true));
// test Timesheet object
$this->assertEquals('02:37 h', $sut->duration($record));
$this->assertEquals('02:37:17', $sut->duration($record, '%h:%m:%s'));
$this->assertEquals('2.62', $sut->duration($record, true));
// test extended format
$sut = $this->getSut($this->localeFake, 'XX');
@@ -237,6 +237,7 @@ class ExtensionsTest extends TestCase
$sut = $this->getSut($this->localeEn, 'en');
$this->assertEquals('00:00 h', $sut->duration(null));
$this->assertEquals('0', $sut->duration(null, true));
}
public function testDurationDecimal()

View File

@@ -653,14 +653,6 @@
<source>label.invoice_bank_account</source>
<target>حساب البنك</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
السيدات والسادة،
أشكرك على طلبك ورسوم خدماتي:
</target>
</trans-unit>
<!--
Month names

View File

@@ -864,14 +864,6 @@
<source>label.invoice_bank_account</source>
<target>Bankovní účet</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Dámi a pánové,
Děkuji za vaši objednávku a využití mých složeb:
</target>
</trans-unit>
<!--
Export

View File

@@ -908,14 +908,6 @@
<source>label.invoice_bank_account</source>
<target>Bankkonto</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Damer og herrer,
Jeg takker jer for jeres ordre og anmoder for min ydelse:
</target>
</trans-unit>
<!--
Export

View File

@@ -928,13 +928,9 @@
<source>label.invoice_bank_account</source>
<target>Bankverbindung</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Sehr geehrte Damen und Herren,
für Ihren Auftrag bedanke ich mich und berechne für meine Leistungen:
</target>
<trans-unit id="label.decimalDuration">
<source>label.decimalDuration</source>
<target>Dauer als Dezimalzahl anzeigen</target>
</trans-unit>
<!--

View File

@@ -928,13 +928,9 @@
<source>label.invoice_bank_account</source>
<target>Bank account</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Ladies and gentlemen,
I thank you for your order and charge for my services:
</target>
<trans-unit id="label.decimalDuration">
<source>label.decimalDuration</source>
<target>Display duration as decimal number</target>
</trans-unit>
<!--

View File

@@ -876,14 +876,6 @@
<source>label.invoice_bank_account</source>
<target>Cuenta bancaria</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Señoras y señores,
Le agradezco su pedido y cobro por mis servicios:
</target>
</trans-unit>
<!--
Export

View File

@@ -916,14 +916,6 @@
<source>label.invoice_bank_account</source>
<target>Kontu Korronte Zenbakia</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Jaun-Andreok,
Zuen eskaria eskertzen dut eta ondorengoa ordaindu beharko duzue:
</target>
</trans-unit>
<!--
Export

View File

@@ -804,16 +804,7 @@
<source>label.invoice_bank_account</source>
<target>N° de compte bancaire</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Madame, Monsieur,
Je vous remercie pour votre commande.
Vous trouverez ci-après la facture pour mes services :
</target>
</trans-unit>
<!--
Export
-->

View File

@@ -804,14 +804,6 @@
<source>label.invoice_bank_account</source>
<target>Bankszámlaszám</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Tisztelt Hölgyem/Uram!
Köszönöm megbízását, alább találja a szolgáltatásaim árát részletezve:
</target>
</trans-unit>
<!--
Export

View File

@@ -804,12 +804,6 @@
<source>label.invoice_bank_account</source>
<target>銀行口座</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
ご注文を頂きましたことならびにお支払い頂きますことに対して深く感謝申し上げます:
</target>
</trans-unit>
<!--
Export

View File

@@ -824,14 +824,6 @@
<source>label.invoice_bank_account</source>
<target state="translated">계좌 번호</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target state="translated">
고객님께,
저희 사무실에 업무를 맡겨 주셔서 감사드립니다:
</target>
</trans-unit>
<!--
Export

View File

@@ -876,14 +876,6 @@
<source>label.invoice_bank_account</source>
<target>Bankrekening</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Dames en heren,
Ik dank u voor uw bestelling en reken voor mijn diensten aan:
</target>
</trans-unit>
<!--
Export

View File

@@ -804,14 +804,6 @@
<source>label.invoice_bank_account</source>
<target>Conta Bancária</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Senhoras e senhores,
Agradeço pelo seu pedido e cobro pelos meus serviços:
</target>
</trans-unit>
<!--
Export

View File

@@ -953,14 +953,6 @@
<source>label.invoice_bank_account</source>
<target>Bankový účet</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
Dámy a páni,
Ďakujem vám za vašu objednávku a poplatok za moje služby:
</target>
</trans-unit>
<!--
Export

View File

@@ -799,12 +799,6 @@ För närvarande har %user% användare %records% tidsrekord som räknas upp till
<source>label.invoice_bank_account</source>
<target state="translated">Bankkonto</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target state="translated">Damer och herrar,
Jag tackar för din beställning och tar betalt för mina tjänster:</target>
</trans-unit>
<!--
Export

View File

@@ -876,14 +876,6 @@
<source>label.invoice_bank_account</source>
<target state="translated">Banka hesabı</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target state="translated">
Beyler ve bayanlar,
Siparişiniz ve ödemeleriniz için teşekkür ederim:
</target>
</trans-unit>
<!--
Export

View File

@@ -907,14 +907,6 @@
<source>label.invoice_bank_account</source>
<target>银行账户</target>
</trans-unit>
<trans-unit id="label.invoice_salutation">
<source>label.invoice_salutation</source>
<target>
女士们、先生们,
感谢您的订购并为我的服务收费:
</target>
</trans-unit>
<!--
Export

Binary file not shown.