Release 2.0.15 (#3970)

This commit is contained in:
Kevin Papst
2023-04-16 00:44:00 +02:00
committed by GitHub
parent ecd8ee85f3
commit 046ed313c9
42 changed files with 796 additions and 935 deletions

View File

@@ -34,10 +34,10 @@ p {
.text-center { .text-center {
text-align: center; text-align: center;
} }
.text-left { .text-left, .text-begin {
text-align: left; text-align: left;
} }
.text-right { .text-right, .text-end {
text-align: right; text-align: right;
} }
.text-nowrap { .text-nowrap {

View File

@@ -80,7 +80,6 @@ body {
font-size: 20px; font-size: 20px;
> small { > small {
color: #666;
display: block; display: block;
margin-top: 5px; margin-top: 5px;
} }
@@ -107,6 +106,13 @@ body {
text-align: right; text-align: right;
} }
.text-left {
text-align: left;
}
.text-right {
text-align: right;
}
.invoice-items { .invoice-items {
.table { .table {
thead th { thead th {

98
composer.lock generated
View File

@@ -1449,16 +1449,16 @@
}, },
{ {
"name": "doctrine/orm", "name": "doctrine/orm",
"version": "2.14.1", "version": "2.14.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/orm.git", "url": "https://github.com/doctrine/orm.git",
"reference": "de7eee5ed7b1b35c99b118f26f210a8281e6db8e" "reference": "e5fb1a4a8f5aa4e37612f66a22aac10291bcbb52"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/orm/zipball/de7eee5ed7b1b35c99b118f26f210a8281e6db8e", "url": "https://api.github.com/repos/doctrine/orm/zipball/e5fb1a4a8f5aa4e37612f66a22aac10291bcbb52",
"reference": "de7eee5ed7b1b35c99b118f26f210a8281e6db8e", "reference": "e5fb1a4a8f5aa4e37612f66a22aac10291bcbb52",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -1487,14 +1487,14 @@
"doctrine/annotations": "^1.13 || ^2", "doctrine/annotations": "^1.13 || ^2",
"doctrine/coding-standard": "^9.0.2 || ^11.0", "doctrine/coding-standard": "^9.0.2 || ^11.0",
"phpbench/phpbench": "^0.16.10 || ^1.0", "phpbench/phpbench": "^0.16.10 || ^1.0",
"phpstan/phpstan": "~1.4.10 || 1.9.8", "phpstan/phpstan": "~1.4.10 || 1.10.6",
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.5", "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6",
"psr/log": "^1 || ^2 || ^3", "psr/log": "^1 || ^2 || ^3",
"squizlabs/php_codesniffer": "3.7.1", "squizlabs/php_codesniffer": "3.7.2",
"symfony/cache": "^4.4 || ^5.4 || ^6.0", "symfony/cache": "^4.4 || ^5.4 || ^6.0",
"symfony/var-exporter": "^4.4 || ^5.4 || ^6.2", "symfony/var-exporter": "^4.4 || ^5.4 || ^6.2",
"symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0", "symfony/yaml": "^3.4 || ^4.0 || ^5.0 || ^6.0",
"vimeo/psalm": "4.30.0 || 5.4.0" "vimeo/psalm": "4.30.0 || 5.9.0"
}, },
"suggest": { "suggest": {
"ext-dom": "Provides support for XSD validation for XML mapping files", "ext-dom": "Provides support for XSD validation for XML mapping files",
@@ -1544,9 +1544,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/orm/issues", "issues": "https://github.com/doctrine/orm/issues",
"source": "https://github.com/doctrine/orm/tree/2.14.1" "source": "https://github.com/doctrine/orm/tree/2.14.2"
}, },
"time": "2023-01-16T18:36:59+00:00" "time": "2023-03-30T15:18:54+00:00"
}, },
{ {
"name": "doctrine/persistence", "name": "doctrine/persistence",
@@ -4068,16 +4068,16 @@
}, },
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "1.17.1", "version": "1.18.1",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpdoc-parser.git", "url": "https://github.com/phpstan/phpdoc-parser.git",
"reference": "d3753fcb3abc6f78f5de6f72153d4b9c99c72dee" "reference": "22dcdfd725ddf99583bfe398fc624ad6c5004a0f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/d3753fcb3abc6f78f5de6f72153d4b9c99c72dee", "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/22dcdfd725ddf99583bfe398fc624ad6c5004a0f",
"reference": "d3753fcb3abc6f78f5de6f72153d4b9c99c72dee", "reference": "22dcdfd725ddf99583bfe398fc624ad6c5004a0f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -4107,9 +4107,9 @@
"description": "PHPDoc parser with support for nullable, intersection and generic types", "description": "PHPDoc parser with support for nullable, intersection and generic types",
"support": { "support": {
"issues": "https://github.com/phpstan/phpdoc-parser/issues", "issues": "https://github.com/phpstan/phpdoc-parser/issues",
"source": "https://github.com/phpstan/phpdoc-parser/tree/1.17.1" "source": "https://github.com/phpstan/phpdoc-parser/tree/1.18.1"
}, },
"time": "2023-04-04T11:11:22+00:00" "time": "2023-04-07T11:51:11+00:00"
}, },
{ {
"name": "psr/cache", "name": "psr/cache",
@@ -4265,21 +4265,21 @@
}, },
{ {
"name": "psr/http-client", "name": "psr/http-client",
"version": "1.0.1", "version": "1.0.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/php-fig/http-client.git", "url": "https://github.com/php-fig/http-client.git",
"reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621" "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", "url": "https://api.github.com/repos/php-fig/http-client/zipball/0955afe48220520692d2d09f7ab7e0f93ffd6a31",
"reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621", "reference": "0955afe48220520692d2d09f7ab7e0f93ffd6a31",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": "^7.0 || ^8.0", "php": "^7.0 || ^8.0",
"psr/http-message": "^1.0" "psr/http-message": "^1.0 || ^2.0"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -4299,7 +4299,7 @@
"authors": [ "authors": [
{ {
"name": "PHP-FIG", "name": "PHP-FIG",
"homepage": "http://www.php-fig.org/" "homepage": "https://www.php-fig.org/"
} }
], ],
"description": "Common interface for HTTP clients", "description": "Common interface for HTTP clients",
@@ -4311,27 +4311,27 @@
"psr-18" "psr-18"
], ],
"support": { "support": {
"source": "https://github.com/php-fig/http-client/tree/master" "source": "https://github.com/php-fig/http-client/tree/1.0.2"
}, },
"time": "2020-06-29T06:28:15+00:00" "time": "2023-04-10T20:12:12+00:00"
}, },
{ {
"name": "psr/http-factory", "name": "psr/http-factory",
"version": "1.0.1", "version": "1.0.2",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/php-fig/http-factory.git", "url": "https://github.com/php-fig/http-factory.git",
"reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be" "reference": "e616d01114759c4c489f93b099585439f795fe35"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be", "url": "https://api.github.com/repos/php-fig/http-factory/zipball/e616d01114759c4c489f93b099585439f795fe35",
"reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be", "reference": "e616d01114759c4c489f93b099585439f795fe35",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"php": ">=7.0.0", "php": ">=7.0.0",
"psr/http-message": "^1.0" "psr/http-message": "^1.0 || ^2.0"
}, },
"type": "library", "type": "library",
"extra": { "extra": {
@@ -4351,7 +4351,7 @@
"authors": [ "authors": [
{ {
"name": "PHP-FIG", "name": "PHP-FIG",
"homepage": "http://www.php-fig.org/" "homepage": "https://www.php-fig.org/"
} }
], ],
"description": "Common interfaces for PSR-7 HTTP message factories", "description": "Common interfaces for PSR-7 HTTP message factories",
@@ -4366,9 +4366,9 @@
"response" "response"
], ],
"support": { "support": {
"source": "https://github.com/php-fig/http-factory/tree/master" "source": "https://github.com/php-fig/http-factory/tree/1.0.2"
}, },
"time": "2019-04-30T12:38:16+00:00" "time": "2023-04-10T20:10:41+00:00"
}, },
{ {
"name": "psr/http-message", "name": "psr/http-message",
@@ -10691,16 +10691,16 @@
}, },
{ {
"name": "doctrine/doctrine-fixtures-bundle", "name": "doctrine/doctrine-fixtures-bundle",
"version": "3.4.2", "version": "3.4.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/doctrine/DoctrineFixturesBundle.git", "url": "https://github.com/doctrine/DoctrineFixturesBundle.git",
"reference": "601988c5b46dbd20a0f886f967210aba378a6fd5" "reference": "fd39829fed8f090ef6e185d33449d47c2fb59c9c"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/601988c5b46dbd20a0f886f967210aba378a6fd5", "url": "https://api.github.com/repos/doctrine/DoctrineFixturesBundle/zipball/fd39829fed8f090ef6e185d33449d47c2fb59c9c",
"reference": "601988c5b46dbd20a0f886f967210aba378a6fd5", "reference": "fd39829fed8f090ef6e185d33449d47c2fb59c9c",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10754,7 +10754,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues", "issues": "https://github.com/doctrine/DoctrineFixturesBundle/issues",
"source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/3.4.2" "source": "https://github.com/doctrine/DoctrineFixturesBundle/tree/3.4.3"
}, },
"funding": [ "funding": [
{ {
@@ -10770,7 +10770,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2022-04-28T17:58:29+00:00" "time": "2023-04-11T12:37:36+00:00"
}, },
{ {
"name": "fakerphp/faker", "name": "fakerphp/faker",
@@ -11174,16 +11174,16 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "1.10.11", "version": "1.10.12",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan.git", "url": "https://github.com/phpstan/phpstan.git",
"reference": "8aa62e6ea8b58ffb650e02940e55a788cbc3fe21" "reference": "7b2aaf999e522e2b10011a594cf06dc37de7fbd8"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/8aa62e6ea8b58ffb650e02940e55a788cbc3fe21", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/7b2aaf999e522e2b10011a594cf06dc37de7fbd8",
"reference": "8aa62e6ea8b58ffb650e02940e55a788cbc3fe21", "reference": "7b2aaf999e522e2b10011a594cf06dc37de7fbd8",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11232,7 +11232,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2023-04-04T19:17:42+00:00" "time": "2023-04-12T10:33:29+00:00"
}, },
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",
@@ -11407,16 +11407,16 @@
}, },
{ {
"name": "phpstan/phpstan-symfony", "name": "phpstan/phpstan-symfony",
"version": "1.2.24", "version": "1.2.25",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan-symfony.git", "url": "https://github.com/phpstan/phpstan-symfony.git",
"reference": "db81b1861aac7cc2e66115cb33b4d1ea2a73d096" "reference": "1da7bf450c6b351fec08ca0aa97298473d4f6ab3"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/db81b1861aac7cc2e66115cb33b4d1ea2a73d096", "url": "https://api.github.com/repos/phpstan/phpstan-symfony/zipball/1da7bf450c6b351fec08ca0aa97298473d4f6ab3",
"reference": "db81b1861aac7cc2e66115cb33b4d1ea2a73d096", "reference": "1da7bf450c6b351fec08ca0aa97298473d4f6ab3",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11472,9 +11472,9 @@
"description": "Symfony Framework extensions and rules for PHPStan", "description": "Symfony Framework extensions and rules for PHPStan",
"support": { "support": {
"issues": "https://github.com/phpstan/phpstan-symfony/issues", "issues": "https://github.com/phpstan/phpstan-symfony/issues",
"source": "https://github.com/phpstan/phpstan-symfony/tree/1.2.24" "source": "https://github.com/phpstan/phpstan-symfony/tree/1.2.25"
}, },
"time": "2023-03-30T08:38:10+00:00" "time": "2023-04-05T12:16:20+00:00"
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",

View File

@@ -4330,11 +4330,6 @@ parameters:
count: 2 count: 2
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
-
message: "#^Cannot call method getAlias\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
- -
message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#" message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
count: 1 count: 1
@@ -4345,26 +4340,11 @@ parameters:
count: 1 count: 1
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
-
message: "#^Cannot call method getId\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
- -
message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#" message: "#^Cannot call method getTimestamp\\(\\) on DateTime\\|null\\.$#"
count: 2 count: 2
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
-
message: "#^Cannot call method getTitle\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
-
message: "#^Cannot call method getUserIdentifier\\(\\) on App\\\\Entity\\\\User\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceItemDefaultHydrator.php
- -
message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceItemDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Invoice\\\\Hydrator\\\\InvoiceItemDefaultHydrator\\:\\:hydrate\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1 count: 1
@@ -4430,11 +4410,6 @@ parameters:
count: 1 count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getBegin\\(\\) on App\\\\Repository\\\\Query\\\\InvoiceQuery\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
- -
message: "#^Cannot call method getCompany\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#" message: "#^Cannot call method getCompany\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1 count: 1
@@ -4450,11 +4425,6 @@ parameters:
count: 1 count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
-
message: "#^Cannot call method getEnd\\(\\) on App\\\\Repository\\\\Query\\\\InvoiceQuery\\|null\\.$#"
count: 1
path: src/Invoice/Hydrator/InvoiceModelDefaultHydrator.php
- -
message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#" message: "#^Cannot call method getLanguage\\(\\) on App\\\\Entity\\\\InvoiceTemplate\\|null\\.$#"
count: 1 count: 1
@@ -4790,11 +4760,6 @@ parameters:
count: 1 count: 1
path: src/Kernel.php path: src/Kernel.php
-
message: "#^Method App\\\\Ldap\\\\FormLoginLdapFactory\\:\\:createAuthenticator\\(\\) has parameter \\$config with no value type specified in iterable type array\\.$#"
count: 1
path: src/Ldap/FormLoginLdapFactory.php
- -
message: "#^Method App\\\\Ldap\\\\LdapAuthenticator\\:\\:supports\\(\\) should return bool but returns bool\\|null\\.$#" message: "#^Method App\\\\Ldap\\\\LdapAuthenticator\\:\\:supports\\(\\) should return bool but returns bool\\|null\\.$#"
count: 1 count: 1
@@ -5735,11 +5700,6 @@ parameters:
count: 1 count: 1
path: src/Repository/TagRepository.php path: src/Repository/TagRepository.php
-
message: "#^Method App\\\\Repository\\\\TagRepository\\:\\:findIdsByTagNameList\\(\\) return type has no value type specified in iterable type array\\.$#"
count: 1
path: src/Repository/TagRepository.php
- -
message: "#^Method App\\\\Repository\\\\TagRepository\\:\\:findTagsByName\\(\\) has parameter \\$tagNames with no value type specified in iterable type array\\.$#" message: "#^Method App\\\\Repository\\\\TagRepository\\:\\:findTagsByName\\(\\) has parameter \\$tagNames with no value type specified in iterable type array\\.$#"
count: 1 count: 1

View File

@@ -24,7 +24,7 @@
"/build/invoice.c8ae95ad.js" "/build/invoice.c8ae95ad.js"
], ],
"css": [ "css": [
"/build/invoice.3c80ee80.css" "/build/invoice.d0ae14e1.css"
] ]
}, },
"invoice-pdf": { "invoice-pdf": {
@@ -33,7 +33,7 @@
"/build/invoice-pdf.d86b82ee.js" "/build/invoice-pdf.d86b82ee.js"
], ],
"css": [ "css": [
"/build/invoice-pdf.c88953bb.css" "/build/invoice-pdf.2b749265.css"
] ]
}, },
"chart": { "chart": {
@@ -68,9 +68,9 @@
"/build/export-pdf.587575e7.js": "sha384-J50GStmmfVwUTN4dIRQ02eg9hyzGFPSzpTtpPody92j0V6zCqw+s5l8+ZhVTugeW", "/build/export-pdf.587575e7.js": "sha384-J50GStmmfVwUTN4dIRQ02eg9hyzGFPSzpTtpPody92j0V6zCqw+s5l8+ZhVTugeW",
"/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg", "/build/export-pdf.d8a6c23b.css": "sha384-ztepocHE4rnGE9eKZ4kL6jTKaePUyiwiB9TjJjstjpf/ckcKg1HedrEOOk/8ElJg",
"/build/invoice.c8ae95ad.js": "sha384-2eVY7MBiMQxo1vhfizU+fYfEZbz9bYUdzqxnpTQhxpYiLaxeSV4WnaObq2G7/Pks", "/build/invoice.c8ae95ad.js": "sha384-2eVY7MBiMQxo1vhfizU+fYfEZbz9bYUdzqxnpTQhxpYiLaxeSV4WnaObq2G7/Pks",
"/build/invoice.3c80ee80.css": "sha384-xjFM2m/EeN7z42ygpt77ll5zAcHTeOjbZFAw2Qcu3IJutgebiAbrXjnH5aJfF5Z6", "/build/invoice.d0ae14e1.css": "sha384-DZ9f2EsTAIOVoFBZe4jMaW3R6yNVGCtcn3Dq3hxZnWRDdFzhyt1O1iQqRZUyQFTM",
"/build/invoice-pdf.d86b82ee.js": "sha384-A0HJqP+MvEqQr1uG8wViCeEWxBRKyS6l8D+Ao4pFYHUA12gCC1gRYhk9I+SJPvZq", "/build/invoice-pdf.d86b82ee.js": "sha384-A0HJqP+MvEqQr1uG8wViCeEWxBRKyS6l8D+Ao4pFYHUA12gCC1gRYhk9I+SJPvZq",
"/build/invoice-pdf.c88953bb.css": "sha384-ZvSi1e+ZKGzvZJUtAPLjzOSTh13N9zRevq44GKdYdBja/DAplGE55saY2Ur+83yv", "/build/invoice-pdf.2b749265.css": "sha384-DXXgkz2WWnrWnfBnXX5fmfPQSPb98upMnWxYKwTGYS04EhrPIWfDCutB2unIrWh7",
"/build/chart.f5becfac.js": "sha384-GSqETm8wULiVXyizvwRompfwu63r/C0Qd/AvrHDE4cqAKiIGCssb3QyBtGu1WN+W", "/build/chart.f5becfac.js": "sha384-GSqETm8wULiVXyizvwRompfwu63r/C0Qd/AvrHDE4cqAKiIGCssb3QyBtGu1WN+W",
"/build/calendar.8577d9cd.js": "sha384-1qlF+4tr7/zve+wvZef7sVcbUxhK0/yEm5Hbuct8K/ZV4JtENdNY7YRLqOcZ/8Y3", "/build/calendar.8577d9cd.js": "sha384-1qlF+4tr7/zve+wvZef7sVcbUxhK0/yEm5Hbuct8K/ZV4JtENdNY7YRLqOcZ/8Y3",
"/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK", "/build/calendar.bb473428.css": "sha384-900W9o8666Qpw21rLP7hn5Ql8tWd40k0IcE3QpFm7E7V6bKva7xQgGlyzVuuh4GK",

View File

@@ -0,0 +1 @@
body{font-family:sans-serif;font-size:10pt;margin:4px;padding:0}h1,h2,h3,h4,h5,p,table,td,th,tr{margin:0;padding:0}th{border:none;text-align:left}td{vertical-align:top}p{margin-bottom:12px}.text-small{font-size:7pt}.padding-left{padding-left:15px}.text-center{text-align:center}.text-begin,.text-left{text-align:left}.text-end,.text-right{text-align:right}.text-nowrap{white-space:nowrap}.date{font-size:80%;padding-top:6px;text-align:right}table{border-spacing:0;width:100%}p,table.addresses{line-height:14pt}table.addresses{margin-bottom:30px}table.header{border-bottom:1px solid #999;padding-bottom:4px}table.header .title{font-size:140%;font-weight:400}table.footer{border-top:1px solid #999;font-size:80%;line-height:14px;padding-top:8px}table.footer td{vertical-align:top}table.items{border-collapse:collapse;margin-bottom:60px;margin-top:50px}table.items tbody td,table.items thead th{border-bottom:1px solid #ccc}table.items tbody td.first,table.items thead th.first{padding-left:0}table.items tbody td.last,table.items tfoot td.last,table.items thead th.last{padding-right:0}table.items td,table.items th{padding:8px 6px}table.items tfoot td,table.items tfoot th{line-height:1px;padding:20px 0 0}table.items tfoot td.last{padding-left:10px}table.items tr.odd{background-color:#f5f5f5}

View File

@@ -1 +0,0 @@
body{font-family:sans-serif;font-size:10pt;margin:4px;padding:0}h1,h2,h3,h4,h5,p,table,td,th,tr{margin:0;padding:0}th{border:none;text-align:left}td{vertical-align:top}p{margin-bottom:12px}.text-small{font-size:7pt}.padding-left{padding-left:15px}.text-center{text-align:center}.text-left{text-align:left}.text-right{text-align:right}.text-nowrap{white-space:nowrap}.date{font-size:80%;padding-top:6px;text-align:right}table{border-spacing:0;width:100%}p,table.addresses{line-height:14pt}table.addresses{margin-bottom:30px}table.header{border-bottom:1px solid #999;padding-bottom:4px}table.header .title{font-size:140%;font-weight:400}table.footer{border-top:1px solid #999;font-size:80%;line-height:14px;padding-top:8px}table.footer td{vertical-align:top}table.items{border-collapse:collapse;margin-bottom:60px;margin-top:50px}table.items tbody td,table.items thead th{border-bottom:1px solid #ccc}table.items tbody td.first,table.items thead th.first{padding-left:0}table.items tbody td.last,table.items tfoot td.last,table.items thead th.last{padding-right:0}table.items td,table.items th{padding:8px 6px}table.items tfoot td,table.items tfoot th{line-height:1px;padding:20px 0 0}table.items tfoot td.last{padding-left:10px}table.items tr.odd{background-color:#f5f5f5}

File diff suppressed because one or more lines are too long

View File

@@ -3,9 +3,9 @@
"build/app.js": "/build/app.cd312ca8.js", "build/app.js": "/build/app.cd312ca8.js",
"build/export-pdf.css": "/build/export-pdf.d8a6c23b.css", "build/export-pdf.css": "/build/export-pdf.d8a6c23b.css",
"build/export-pdf.js": "/build/export-pdf.587575e7.js", "build/export-pdf.js": "/build/export-pdf.587575e7.js",
"build/invoice.css": "/build/invoice.3c80ee80.css", "build/invoice.css": "/build/invoice.d0ae14e1.css",
"build/invoice.js": "/build/invoice.c8ae95ad.js", "build/invoice.js": "/build/invoice.c8ae95ad.js",
"build/invoice-pdf.css": "/build/invoice-pdf.c88953bb.css", "build/invoice-pdf.css": "/build/invoice-pdf.2b749265.css",
"build/invoice-pdf.js": "/build/invoice-pdf.d86b82ee.js", "build/invoice-pdf.js": "/build/invoice-pdf.d86b82ee.js",
"build/chart.js": "/build/chart.f5becfac.js", "build/chart.js": "/build/chart.f5becfac.js",
"build/calendar.css": "/build/calendar.bb473428.css", "build/calendar.css": "/build/calendar.bb473428.css",

View File

@@ -76,6 +76,7 @@ final class TimesheetController extends BaseApiController
#[ApiSecurity(name: 'apiUser')] #[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')] #[ApiSecurity(name: 'apiToken')]
#[Rest\QueryParam(name: 'user', requirements: '\d+|all', strict: true, nullable: true, description: "User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")] #[Rest\QueryParam(name: 'user', requirements: '\d+|all', strict: true, nullable: true, description: "User ID to filter timesheets. Needs permission 'view_other_timesheet', pass 'all' to fetch data for all user (default: current user)")]
#[Rest\QueryParam(name: 'users', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of user IDs to filter, e.g.: users[]=1&users[]=2 (ignored if user=all)')]
#[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter timesheets')] #[Rest\QueryParam(name: 'customer', requirements: '\d+', strict: true, nullable: true, description: 'Customer ID to filter timesheets')]
#[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')] #[Rest\QueryParam(name: 'customers', map: true, requirements: '\d+', strict: true, nullable: true, default: [], description: 'List of customer IDs to filter, e.g.: customers[]=1&customers[]=2')]
#[Rest\QueryParam(name: 'project', requirements: '\d+', strict: true, nullable: true, description: 'Project ID to filter timesheets')] #[Rest\QueryParam(name: 'project', requirements: '\d+', strict: true, nullable: true, description: 'Project ID to filter timesheets')]
@@ -98,24 +99,32 @@ final class TimesheetController extends BaseApiController
public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerRepository $customerRepository, ProjectRepository $projectRepository, ActivityRepository $activityRepository, UserRepository $userRepository): Response
{ {
$query = new TimesheetQuery(false); $query = new TimesheetQuery(false);
$query->setUser($this->getUser()); $seeAll = false;
if ($this->isGranted('view_other_timesheet')) { if ($this->isGranted('view_other_timesheet')) {
/** @var array<int> $users */
$users = $paramFetcher->get('users');
$userId = $paramFetcher->get('user'); $userId = $paramFetcher->get('user');
if (\is_string($userId) && $userId !== '') {
if ('all' === $userId) { if ('all' === $userId) {
$query->setUser(null); $seeAll = true;
} else { } elseif (\is_string($userId) && $userId !== '') {
/** @var User|null $user */ $users[] = (int) $userId;
$user = $userRepository->find($userId); }
if ($user === null) {
throw $this->createNotFoundException('Unknown user: ' . $userId); if (!$seeAll) {
} foreach ($userRepository->findByIds($users) as $user) {
$query->setUser($user); $query->addUser($user);
} }
} }
} }
if ($seeAll) {
$query->setUser(null);
} elseif (!$query->hasUsers()) {
$query->setUser($this->getUser());
}
/** @var array<int> $customers */ /** @var array<int> $customers */
$customers = $paramFetcher->get('customers'); $customers = $paramFetcher->get('customers');
$customer = $paramFetcher->get('customer'); $customer = $paramFetcher->get('customer');
@@ -476,6 +485,7 @@ final class TimesheetController extends BaseApiController
#[IsGranted('start', 'timesheet')] #[IsGranted('start', 'timesheet')]
#[OA\Response(response: 200, description: 'Restarts a timesheet record for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))] #[OA\Response(response: 200, description: 'Restarts a timesheet record for the same customer, project, activity combination. The current user will be the owner of the new record. Kimai tries to stop running records, which is expected to fail depending on the configured rules. Data will be copied from the original record if requested.', content: new OA\JsonContent(ref: '#/components/schemas/TimesheetEntity'))]
#[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to restart', required: true)] #[OA\Parameter(name: 'id', in: 'path', description: 'Timesheet record ID to restart', required: true)]
#[Rest\Get(path: '/{id}/restart', name: 'restart_timesheet_get', requirements: ['id' => '\d+'])]
#[Rest\Patch(path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])] #[Rest\Patch(path: '/{id}/restart', name: 'restart_timesheet', requirements: ['id' => '\d+'])]
#[ApiSecurity(name: 'apiUser')] #[ApiSecurity(name: 'apiUser')]
#[ApiSecurity(name: 'apiToken')] #[ApiSecurity(name: 'apiToken')]

View File

@@ -17,11 +17,11 @@ class Constants
/** /**
* The current release version * The current release version
*/ */
public const VERSION = '2.0.14'; public const VERSION = '2.0.15';
/** /**
* The current release: major * 10000 + minor * 100 + patch * The current release: major * 10000 + minor * 100 + patch
*/ */
public const VERSION_ID = 20014; public const VERSION_ID = 20015;
/** /**
* The software name * The software name
*/ */

View File

@@ -672,6 +672,8 @@ class Timesheet implements EntityWithMetaFields, ExportableItem
$this->id = null; $this->id = null;
} }
// field will not be set, if it contains a value
$this->modifiedAt = null;
$this->exported = false; $this->exported = false;
$currentMeta = $this->meta; $currentMeta = $this->meta;

View File

@@ -25,7 +25,7 @@ final class ExportToolbarForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$this->addSearchTermInputField($builder); $this->addSearchTermInputField($builder);
$this->addDateRange($builder, ['timezone' => $options['timezone']], false, true); $this->addDateRange($builder, ['timezone' => $options['timezone']]);
$this->addCustomerMultiChoice($builder, ['start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true], true); $this->addCustomerMultiChoice($builder, ['start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true], true);
$this->addProjectMultiChoice($builder, ['ignore_date' => true], true, true); $this->addProjectMultiChoice($builder, ['ignore_date' => true], true, true);
$this->addActivitySelect($builder, [], true, true, false); $this->addActivitySelect($builder, [], true, true, false);

View File

@@ -26,7 +26,7 @@ final class InvoiceToolbarForm extends AbstractType
public function buildForm(FormBuilderInterface $builder, array $options): void public function buildForm(FormBuilderInterface $builder, array $options): void
{ {
$this->addSearchTermInputField($builder); $this->addSearchTermInputField($builder);
$this->addDateRange($builder, ['timezone' => $options['timezone']], false, true); $this->addDateRange($builder, ['timezone' => $options['timezone']]);
$this->addCustomerMultiChoice($builder, ['start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true], true); $this->addCustomerMultiChoice($builder, ['start_date_param' => null, 'end_date_param' => null, 'ignore_date' => true], true);
$this->addProjectMultiChoice($builder, ['ignore_date' => true], true, true); $this->addProjectMultiChoice($builder, ['ignore_date' => true], true, true);
$this->addActivitySelect($builder, [], true, true, false); $this->addActivitySelect($builder, [], true, true, false);

View File

@@ -155,10 +155,10 @@ trait ToolbarFormTrait
]); ]);
} }
protected function addDateRange(FormBuilderInterface $builder, array $options, bool $allowEmpty = true, bool $required = false): void protected function addDateRange(FormBuilderInterface $builder, array $options, bool $allowEmpty = true): void
{ {
$params = [ $params = [
'required' => $required, 'required' => !$allowEmpty,
'allow_empty' => $allowEmpty, 'allow_empty' => $allowEmpty,
]; ];

View File

@@ -134,6 +134,10 @@ final class DateRangeType extends AbstractType
return $range; return $range;
} }
if ($dates === null) {
throw new TransformationFailedException('Date range missing');
}
if (preg_match($pattern, $dates) !== 1) { if (preg_match($pattern, $dates) !== 1) {
throw new TransformationFailedException('Invalid date range given'); throw new TransformationFailedException('Invalid date range given');
} }

View File

@@ -76,9 +76,20 @@ final class TagsSelectType extends AbstractType
$foundTagNames[] = $tag->getName(); $foundTagNames[] = $tag->getName();
} }
/** @var array<string> $newNames */ /** @var array<string> $newNamesCreate */
$newNames = array_diff($newNames, $foundTagNames); $newNamesCreate = array_udiff($newNames, $foundTagNames, function (mixed $userTag, mixed $existingTag) {
foreach ($newNames as $name) { if (!\is_string($userTag) || !\is_string($existingTag)) {
return -1;
}
if (mb_strtolower($userTag) === mb_strtolower($existingTag)) {
return 0;
}
return strcmp($userTag, $existingTag);
});
foreach ($newNamesCreate as $name) {
$tag = new Tag(); $tag = new Tag();
$tag->setName(mb_substr($name, 0, 100)); $tag->setName(mb_substr($name, 0, 100));
$this->tagRepository->saveTag($tag); $this->tagRepository->saveTag($tag);

View File

@@ -77,4 +77,9 @@ final class DefaultInvoiceFormatter implements InvoiceFormatter
$this->locale = $locale; $this->locale = $locale;
$this->formatter = null; $this->formatter = null;
} }
public function getFormattedAmount(float $amount): string
{
return $this->getFormatter()->amount($amount);
}
} }

View File

@@ -34,7 +34,7 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
if ($item->isFixedRate()) { if ($item->isFixedRate()) {
$appliedRate = $item->getFixedRate(); $appliedRate = $item->getFixedRate();
$amount = $item->getAmount(); $amount = $formatter->getFormattedAmount($item->getAmount());
} }
$activity = $item->getActivity(); $activity = $item->getActivity();
@@ -83,12 +83,18 @@ final class InvoiceItemDefaultHydrator implements InvoiceItemHydrator
'entry.date' => $formatter->getFormattedDateTime($begin), 'entry.date' => $formatter->getFormattedDateTime($begin),
'entry.week' => \intval($begin->format('W')), 'entry.week' => \intval($begin->format('W')),
'entry.weekyear' => $begin->format('o'), 'entry.weekyear' => $begin->format('o'),
'entry.user_id' => $user->getId(),
'entry.user_name' => $user->getUserIdentifier(),
'entry.user_title' => $user->getTitle() ?? '',
'entry.user_alias' => $user->getAlias() ?? '',
]; ];
if (null !== $user) {
$values = array_merge($values, [
'entry.user_id' => $user->getId(),
'entry.user_name' => $user->getUserIdentifier(),
'entry.user_title' => $user->getTitle() ?? '',
'entry.user_alias' => $user->getAlias() ?? '',
'entry.user_display' => $user->getDisplayName() ?? '',
]);
}
if (null !== $activity) { if (null !== $activity) {
$values = array_merge($values, [ $values = array_merge($values, [
'entry.activity' => $activity->getName(), 'entry.activity' => $activity->getName(),

View File

@@ -35,7 +35,8 @@ final class InvoiceModelCustomerHydrator implements InvoiceModelHydrator
'customer.name' => $customer->getName() ?? '', 'customer.name' => $customer->getName() ?? '',
'customer.contact' => $customer->getContact() ?? '', 'customer.contact' => $customer->getContact() ?? '',
'customer.company' => $customer->getCompany() ?? '', 'customer.company' => $customer->getCompany() ?? '',
'customer.vat' => $customer->getVatId() ?? '', 'customer.vat' => $customer->getVatId() ?? '', // deprecated since 2.0.15
'customer.vat_id' => $customer->getVatId() ?? '',
'customer.number' => $customer->getNumber() ?? '', 'customer.number' => $customer->getNumber() ?? '',
'customer.country' => $customer->getCountry(), 'customer.country' => $customer->getCountry(),
'customer.homepage' => $customer->getHomepage() ?? '', 'customer.homepage' => $customer->getHomepage() ?? '',

View File

@@ -22,9 +22,6 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
$subtotal = $model->getCalculator()->getSubtotal(); $subtotal = $model->getCalculator()->getSubtotal();
$formatter = $model->getFormatter(); $formatter = $model->getFormatter();
$begin = $model->getQuery()->getBegin();
$end = $model->getQuery()->getEnd();
$values = [ $values = [
'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()), 'invoice.due_date' => $formatter->getFormattedDateTime($model->getDueDate()),
'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()), 'invoice.date' => $formatter->getFormattedDateTime($model->getInvoiceDate()),
@@ -33,6 +30,7 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'invoice.language' => $model->getTemplate()->getLanguage(), // since 1.9 'invoice.language' => $model->getTemplate()->getLanguage(), // since 1.9
'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency), 'invoice.currency_symbol' => $formatter->getCurrencySymbol($currency),
'invoice.vat' => $model->getCalculator()->getVat(), 'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.tax_hide' => $model->isHideZeroTax() && $tax === 0.00,
'invoice.tax' => $formatter->getFormattedMoney($tax, $currency), 'invoice.tax' => $formatter->getFormattedMoney($tax, $currency),
'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false), 'invoice.tax_nc' => $formatter->getFormattedMoney($tax, $currency, false),
'invoice.tax_plain' => $tax, 'invoice.tax_plain' => $tax,
@@ -65,25 +63,66 @@ final class InvoiceModelDefaultHydrator implements InvoiceModelHydrator
'query.end_month' => '', // since 1.9 'query.end_month' => '', // since 1.9
'query.end_month_number' => '', // since 1.9 'query.end_month_number' => '', // since 1.9
'query.end_year' => '', // since 1.9 'query.end_year' => '', // since 1.9
// since 2.0.15
'user.see_others' => ($model->getQuery()?->getUser() === null),
]; ];
if ($begin !== null) { $query = $model->getQuery();
$values = array_merge($values, [ if ($query !== null) {
'query.day' => $begin->format('d'), // @deprecated - but impossible to delete $begin = $query->getBegin();
'query.month' => $formatter->getFormattedMonthName($begin), // @deprecated - but impossible to delete $end = $query->getEnd();
'query.month_number' => $begin->format('m'), // @deprecated - but impossible to delete
'query.year' => $begin->format('Y'), // @deprecated - but impossible to delete if ($begin !== null) {
'query.begin' => $formatter->getFormattedDateTime($begin), $values = array_merge($values, [
'query.begin_day' => $begin->format('d'), 'query.day' => $begin->format('d'),
'query.begin_month' => $formatter->getFormattedMonthName($begin), // @deprecated - but impossible to delete
'query.begin_month_number' => $begin->format('m'), 'query.month' => $formatter->getFormattedMonthName($begin),
'query.begin_year' => $begin->format('Y'), // @deprecated - but impossible to delete
'query.end' => $formatter->getFormattedDateTime($end), // since 1.9 'query.month_number' => $begin->format('m'),
'query.end_day' => $end->format('d'), // since 1.9 // @deprecated - but impossible to delete
'query.end_month' => $formatter->getFormattedMonthName($end), // since 1.9 'query.year' => $begin->format('Y'),
'query.end_month_number' => $end->format('m'), // since 1.9 // @deprecated - but impossible to delete
'query.end_year' => $end->format('Y'), // since 1.9 'query.begin' => $formatter->getFormattedDateTime($begin),
]); 'query.begin_day' => $begin->format('d'),
'query.begin_month' => $formatter->getFormattedMonthName($begin),
'query.begin_month_number' => $begin->format('m'),
'query.begin_year' => $begin->format('Y'),
'query.end' => $formatter->getFormattedDateTime($end),
// since 1.9
'query.end_day' => $end->format('d'),
// since 1.9
'query.end_month' => $formatter->getFormattedMonthName($end),
// since 1.9
'query.end_month_number' => $end->format('m'),
// since 1.9
'query.end_year' => $end->format('Y'),
// since 1.9
]);
}
// since 2.0.15
$activity = $query->getActivity();
if ($activity !== null) {
$prefix = 'query.activity.';
$values = array_merge($values, [
$prefix . 'name' => $activity->getName() ?? '',
$prefix . 'comment' => $activity->getComment() ?? '',
]);
}
// since 2.0.15
$project = $query->getProject();
if ($project !== null) {
$prefix = 'query.project.';
$values = array_merge($values, [
$prefix . 'name' => $project->getName() ?? '',
$prefix . 'comment' => $project->getComment() ?? '',
$prefix . 'order_number' => $project->getOrderNumber(),
]);
}
} }
$entries = $model->getEntries(); $entries = $model->getEntries();

View File

@@ -9,7 +9,6 @@
namespace App\Invoice\Hydrator; namespace App\Invoice\Hydrator;
use App\Entity\UserPreference;
use App\Invoice\InvoiceModel; use App\Invoice\InvoiceModel;
use App\Invoice\InvoiceModelHydrator; use App\Invoice\InvoiceModelHydrator;
@@ -28,9 +27,9 @@ final class InvoiceModelUserHydrator implements InvoiceModelHydrator
'user.email' => $user->getEmail(), 'user.email' => $user->getEmail(),
'user.title' => $user->getTitle() ?? '', 'user.title' => $user->getTitle() ?? '',
'user.alias' => $user->getAlias() ?? '', 'user.alias' => $user->getAlias() ?? '',
'user.display' => $user->getDisplayName() ?? '',
]; ];
/** @var UserPreference $metaField */
foreach ($user->getPreferences() as $metaField) { foreach ($user->getPreferences() as $metaField) {
$values = array_merge($values, [ $values = array_merge($values, [
'user.meta.' . $metaField->getName() => $metaField->getValue(), 'user.meta.' . $metaField->getName() => $metaField->getValue(),

View File

@@ -24,6 +24,8 @@ interface InvoiceFormatter
public function getFormattedTime(DateTime $date): string; public function getFormattedTime(DateTime $date): string;
public function getFormattedAmount(float $amount): string;
public function getFormattedMoney(float $amount, ?string $currency, bool $withCurrency = true): string; public function getFormattedMoney(float $amount, ?string $currency, bool $withCurrency = true): string;
public function getFormattedMonthName(DateTime $date): string; public function getFormattedMonthName(DateTime $date): string;

View File

@@ -278,7 +278,7 @@ final class ServiceInvoice
{ {
$document = $this->getDocumentByName($model->getTemplate()->getRenderer()); $document = $this->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) { if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer()); throw new \Exception('Please adjust your invoice template, the renderer is invalid: ' . $model->getTemplate()->getRenderer());
} }
foreach ($this->getRenderer() as $renderer) { foreach ($this->getRenderer() as $renderer) {
@@ -426,7 +426,7 @@ final class ServiceInvoice
$calculator = $this->getCalculatorByName($template->getCalculator()); $calculator = $this->getCalculatorByName($template->getCalculator());
if (null === $calculator) { if (null === $calculator) {
throw new \Exception('Please adjust your invoice template, the invoice calculator is invalid: ' . $template->getCalculator()); throw new \Exception('Please adjust your invoice template, the sum calculator is invalid: ' . $template->getCalculator());
} }
$model->setCalculator($calculator); $model->setCalculator($calculator);

View File

@@ -93,6 +93,14 @@ class TimesheetQuery extends ActivityQuery implements BillableInterface
return array_values($this->users); return array_values($this->users);
} }
/**
* Check if there is one or more users in the query
*/
public function hasUsers(): bool
{
return $this->timesheetUser !== null || \count($this->users) > 0;
}
/** /**
* Limit the data exclusively to the user. * Limit the data exclusively to the user.
*/ */

View File

@@ -66,28 +66,6 @@ class TagRepository extends EntityRepository
return $this->findOneBy(['name' => $tagName]); return $this->findOneBy(['name' => $tagName]);
} }
/**
* Find ids of the given tagNames separated by comma
* @param string $tagNames
* @return array
*/
public function findIdsByTagNameList(string $tagNames): array
{
$qb = $this
->createQueryBuilder('t')
->select('t.id');
$list = array_filter(array_unique(array_map('trim', explode(',', $tagNames))));
$cnt = 0;
foreach ($list as $listElem) {
$qb
->orWhere('t.name like :elem' . $cnt)
->setParameter('elem' . $cnt, '%' . $listElem . '%');
$cnt++;
}
return array_column($qb->getQuery()->getScalarResult(), 'id');
}
/** /**
* Find all tag names in an alphabetical order * Find all tag names in an alphabetical order
* *

View File

@@ -129,7 +129,7 @@
<td class="text-nowrap text-end">{{ stats.internalRate|money(currency) }}</td> <td class="text-nowrap text-end">{{ stats.internalRate|money(currency) }}</td>
<td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td> <td class="text-nowrap text-end">{{ percentReached|number_format(2) }}%</td>
</tr> </tr>
{% if not entity.isMonthlyBudget() and timeBudgetVisible and stats.duration > 0 %} {% if not entity.isMonthlyBudget() and timeBudgetVisible and stats.duration > 0 and stats.duration|chart_duration > 0.00 %}
{% set realHourlyRate = stats.rateBillable / stats.duration|chart_duration %} {% set realHourlyRate = stats.rateBillable / stats.duration|chart_duration %}
<tr> <tr>
<td>{{ 'hourlyRate'|trans }} ({{ 'billable'|trans }} / {{ durationTrans|trans }})</td> <td>{{ 'hourlyRate'|trans }} ({{ 'billable'|trans }} / {{ durationTrans|trans }})</td>

View File

@@ -1,13 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
{%- set fallback = app.request is not null ? app.request.locale : 'en' -%} <html lang="{{ invoice['invoice.language'] }}">
{%- set language = model.template.language|default(fallback) -%}
{%- set isDecimal = true -%}
{%- set currency = model.currency -%}
{%- set project = model.query.project -%}
<html lang="{{ language }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="utf-8">
<title>{% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title> <title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<style> <style>
{{ encore_entry_css_source('invoice-pdf')|raw }} {{ encore_entry_css_source('invoice-pdf')|raw }}
</style> </style>
@@ -18,10 +13,10 @@
<table class="header"> <table class="header">
<tr> <tr>
<td class="title"> <td class="title">
{{ model.template.title }} {{ invoice['template.title'] }}
</td> </td>
<td class="date text-right"> <td class="date text-right">
{{ 'date'|trans }}: {{ model.invoiceDate|date_short }} {{ 'date'|trans }}: {{ invoice['invoice.date'] }}
</td> </td>
</tr> </tr>
</table> </table>
@@ -32,10 +27,10 @@
<tr> <tr>
<td> <td>
<strong>{{ 'contact'|trans }}</strong>: <strong>{{ 'contact'|trans }}</strong>:
{{ model.template.contact|nl2str(' – ') }} {{ invoice['template.contact']|nl2str(' – ') }}
<br> <br>
<strong>{{ 'invoice_bank_account'|trans }}</strong>: <strong>{{ 'invoice_bank_account'|trans }}</strong>:
{{ model.template.paymentDetails|nl2str(' – ') }} {{ invoice['template.payment_details']|nl2str(' – ') }}
</td> </td>
<td align="right"> <td align="right">
{{ 'export.page_of'|trans({'%page%': '{PAGENO}', '%pages%': '{nb}'}) }} {{ 'export.page_of'|trans({'%page%': '{PAGENO}', '%pages%': '{nb}'}) }}
@@ -50,24 +45,24 @@ mpdf-->
<td style="width:60%"> <td style="width:60%">
{{ 'invoice.to'|trans }} {{ 'invoice.to'|trans }}
<br> <br>
<strong>{{ model.customer.company|default(model.customer.name) }}</strong> <strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong>
<br> <br>
{{ model.customer.address|nl2br }} {{ invoice['customer.address']|nl2br }}
{% if model.customer.vatId is not empty %} {% if invoice['customer.vat_id'] is not empty %}
<br> <br>
{{ 'vat_id'|trans }}: {{ model.customer.vatId }} {{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
{% endif %} {% endif %}
</td> </td>
<td> <td>
{{ 'invoice.from'|trans }} {{ 'invoice.from'|trans }}
<br> <br>
<strong>{{ model.template.company }}</strong> <strong>{{ invoice['template.company'] }}</strong>
<br> <br>
{{ model.template.address|trim|nl2br }} {{ invoice['template.address']|trim|nl2br }}
{% if model.template.vatId is not empty %} {% if invoice['template.vat_id'] is not empty %}
<br> <br>
{{ 'vat_id'|trans }}: {{ 'vat_id'|trans }}:
{{ model.template.vatId }} {{ invoice['template.vat_id'] }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
@@ -75,27 +70,27 @@ mpdf-->
<p> <p>
{{ 'invoice.number'|trans }}: {{ 'invoice.number'|trans }}:
{{ model.invoiceNumber }} {{ invoice['invoice.number'] }}
<br> <br>
{{ 'invoice.due_days'|trans }}: {{ 'invoice.due_days'|trans }}:
{{ model.dueDate|date_short }} {{ invoice['invoice.due_date'] }}
{% if model.customer.number is not empty %} {% if invoice['customer.number'] is not empty %}
<br> <br>
{{ 'number'|trans }}: {{ 'number'|trans }}:
{{ model.customer.number }} {{ invoice['customer.number'] }}
{% endif %} {% endif %}
{% if project is not null and project.orderNumber is not empty %} {% if invoice['query.project.order_number'] is defined and invoice['query.project.order_number'] is not empty %}
<br> <br>
{{ 'orderNumber'|trans }}: {{ 'orderNumber'|trans }}:
{{ project.orderNumber }} {{ invoice['query.project.order_number'] }}
{% endif %} {% endif %}
</p> </p>
{% if model.customer.invoiceText is not empty%} {% if invoice['customer.invoice_text'] is not empty%}
<p>{{ model.customer.invoiceText|md2html }}</p> <p>{{ invoice['customer.invoice_text']|md2html }}</p>
{% endif %} {% endif %}
<table class="items"> <table class="items">
@@ -109,43 +104,39 @@ mpdf-->
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for id, entry in model.calculator.entries %} {% for invoiceLineItem in entries %}
<!-- CONTENT_PART --> <!-- CONTENT_PART -->
{% 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> <tr>
<td nowrap class="first text-nowrap">{{ entry.begin|date_short }}</td> <td nowrap class="first text-nowrap">{{ invoiceLineItem['entry.begin'] }}</td>
<td contenteditable="true"> <td>
{% if entry.description is not empty %} {% if invoiceLineItem['entry.description'] is not empty %}
{{ entry.description|nl2br }} {{ invoiceLineItem['entry.description']|nl2br }}
{% else %} {% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }} {% if invoiceLineItem['entry.activity'] is defined %}
{{ invoiceLineItem['entry.activity'] }} /
{% endif %}
{{ invoiceLineItem['entry.project'] }}
{% endif %} {% endif %}
</td> </td>
<td nowrap class="text-nowrap text-right">{{ rate|money(currency) }}</td> <td nowrap class="text-nowrap text-right">{{ invoiceLineItem['entry.rate'] }}</td>
<td nowrap class="text-nowrap text-right">{{ duration }}</td> <td nowrap class="text-nowrap text-right">{{ invoiceLineItem['entry.amount'] }}</td>
<td nowrap class="last text-nowrap text-right">{{ entry.rate|money(currency) }}</td> <td nowrap class="last text-nowrap text-right">{{ invoiceLineItem['entry.total'] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
{% if model.calculator.tax > 0 or not model.isHideZeroTax() %} {% if not invoice['invoice.tax_hide'] %}
<tr> <tr>
<td colspan="4" class="text-right"> <td colspan="4" class="text-right">
{{ 'invoice.subtotal'|trans }} {{ 'invoice.subtotal'|trans }}
</td> </td>
<td class="last text-right">{{ model.calculator.subtotal|money(currency) }}</td> <td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
</tr> </tr>
<tr> <tr>
<td colspan="4" class="text-right"> <td colspan="4" class="text-right">
{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%) {{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td> </td>
<td class="last text-right">{{ model.calculator.tax|money(currency) }}</td> <td class="last text-right">{{ invoice['invoice.tax'] }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
@@ -153,16 +144,16 @@ mpdf-->
<strong>{{ 'invoice.total'|trans }}</strong> <strong>{{ 'invoice.total'|trans }}</strong>
</td> </td>
<td class="last text-right"> <td class="last text-right">
<strong>{{ model.calculator.total|money(currency) }}</strong> <strong>{{ invoice['invoice.total'] }}</strong>
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
{% if model.template.paymentTerms is not empty %} {% if invoice['template.payment_terms'] is not empty %}
<div class="paymentTerms"> <div class="paymentTerms">
{{ model.template.paymentTerms|md2html }} {{ invoice['template.payment_terms']|md2html }}
</div> </div>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@@ -1,166 +1,158 @@
<!DOCTYPE html> <!DOCTYPE html>
{% set fallback = app.request is not null ? app.request.locale : 'en' %} <html lang="{{ invoice['invoice.language'] }}">
{% set language = model.template.language|default(fallback) %}
{% set isDecimal = true %}
{% set currency = model.currency %}
{% set project = model.query.project %}
<html lang="{{ language }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"> <style>
<title>{% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title>
<style type="text/css">
{{ encore_entry_css_source('invoice')|raw }} {{ encore_entry_css_source('invoice')|raw }}
</style> </style>
</head> </head>
<body class="invoice_print"> <body class="invoice_print">
<div class="wrapper"> <div class="wrapper">
<section class="invoice"> <section class="invoice">
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
<h2 class="page-header"> <h2 class="page-header">
<span contenteditable="true">{{ model.template.title }}</span> <span contenteditable="true">{{ invoice['template.title'] }}</span>
<small class="pull-right">{{ 'date'|trans }}: {{ model.invoiceDate|date_short }}</small> </h2>
</h2> </div>
</div> </div>
</div>
<div class="row"> <div class="row">
<div class="col-sm-5"> <div class="col-sm-5">
{{ 'invoice.to'|trans }} {{ 'invoice.to'|trans }}
<address contenteditable="true"> <address contenteditable="true">
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br> <strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ model.customer.address|nl2br }} {{ invoice['customer.address']|nl2br }}
{% if model.customer.vatId is not empty %} {% if invoice['customer.vat_id'] is not empty %}
<br> <br>
{{ 'vat_id'|trans }}: {{ model.customer.vatId }} {{ 'vat_id'|trans }}: {{ invoice['customer.vat_id'] }}
{% endif %} {% endif %}
{% if model.customer.number is not empty %} {% if invoice['customer.number'] is not empty %}
<br> <br>
{{ 'number'|trans }}: {{ model.customer.number }} {{ 'number'|trans }}: {{ invoice['customer.number'] }}
{% endif %} {% endif %}
{% if project is not null and project.orderNumber is not empty %} {% if invoice['query.project.order_number'] is defined and invoice['query.project.order_number'] is not empty %}
<br> <br>
{{ 'orderNumber'|trans }}: {{ project.orderNumber }} {{ 'orderNumber'|trans }}: {{ invoice['query.project.order_number'] }}
{% endif %} {% endif %}
</address> </address>
</div>
<div class="col-sm-2"></div>
<div class="col-sm-5">
{{ 'invoice.from'|trans }}
<address contenteditable="true">
<strong>{{ invoice['template.company'] }}</strong><br>
{{ invoice['template.address']|trim|nl2br }}
{% if invoice['template.vat_id'] is not empty %}
<br>
{{ 'vat_id'|trans }}:
{{ invoice['template.vat_id'] }}
{% endif %}
</address>
</div>
</div> </div>
<div class="col-sm-2"></div>
<div class="col-sm-5"> <div class="row">
{{ 'invoice.from'|trans }} <div class="col-sm-5">
<address contenteditable="true"> <p contenteditable="true">
<strong>{{ model.template.company }}</strong><br> <strong>{{ 'date'|trans }}:</strong>
{{ model.template.address|trim|nl2br }} {{ invoice['invoice.date'] }}
{% if model.template.vatId is not empty %}
<br> <br>
{{ 'vat_id'|trans }}: <strong>{{ 'invoice.number'|trans }}:</strong>
{{ model.template.vatId }} {{ invoice['invoice.number'] }}
{% endif %}
</address> <br>
<strong>{{ 'invoice.due_days'|trans }}:</strong>
{{ invoice['invoice.due_date'] }}
</p>
</div>
<div class="col-sm-7"></div>
</div> </div>
</div>
<div class="row"> <div class="row invoice-items mt-2 mb-3">
<div class="col-sm-5"> <div class="col-xs-12 table-responsive">
<p contenteditable="true"> <table class="table">
<strong>{{ 'invoice.number'|trans }}:</strong> <thead>
{{ model.invoiceNumber }} <tr>
<th>{{ 'date'|trans }}</th>
<br> <th>{{ 'description'|trans }}</th>
<strong>{{ 'invoice.due_days'|trans }}:</strong> <th class="text-end">{{ 'unit_price'|trans }}</th>
{{ model.dueDate|date_short }} <th class="text-end">{{ 'amount'|trans }}</th>
</p> <th class="text-end">{{ 'total_rate'|trans }}</th>
</div> </tr>
<div class="col-sm-7"></div> </thead>
</div> <tbody>
{% for invoiceLineItem in entries %}
<div class="row invoice-items mt-2 mb-3"> <tr>
<div class="col-xs-12 table-responsive"> <td nowrap class="text-nowrap">{{ invoiceLineItem['entry.begin'] }}</td>
<table class="table"> <td contenteditable="true">
<thead> {% if invoiceLineItem['entry.description'] is not empty %}
<tr> {{ invoiceLineItem['entry.description']|nl2br }}
<th>{{ 'date'|trans }}</th> {% else %}
<th>{{ 'description'|trans }}</th> {% if invoiceLineItem['entry.activity'] is defined %}
<th class="text-end">{{ 'unit_price'|trans }}</th> {{ invoiceLineItem['entry.activity'] }} /
<th class="text-end">{{ 'amount'|trans }}</th> {% endif %}
<th class="text-end">{{ 'total_rate'|trans }}</th> {{ invoiceLineItem['entry.project'] }}
</tr> {% endif %}
</thead> </td>
<tbody> <td nowrap class="text-nowrap text-end">{{ invoiceLineItem['entry.rate'] }}</td>
{% for entry in model.calculator.entries %} <td nowrap class="text-nowrap text-end">{{ invoiceLineItem['entry.amount'] }}</td>
{% set duration = entry.duration|duration(isDecimal) %} <td nowrap class="text-nowrap text-end">{{ invoiceLineItem['entry.total'] }}</td>
{% if entry.fixedRate %} </tr>
{% set rate = entry.fixedRate %} {% endfor %}
{% set duration = entry.amount|amount %} </tbody>
{% else %} <tfoot>
{% set rate = entry.hourlyRate %} {% if not invoice['invoice.tax_hide'] %}
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.subtotal'|trans }}
</td>
<td class="text-end">{{ invoice['invoice.subtotal'] }}</td>
</tr>
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td>
<td class="text-end">{{ invoice['invoice.tax'] }}</td>
</tr>
{% endif %} {% endif %}
<tr> <tr>
<td nowrap class="text-nowrap">{{ entry.begin|date_short }}</td> <td colspan="4" class="text-end text-nowrap">
<td contenteditable="true"> <strong>{{ 'invoice.total'|trans }}</strong>
{% 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>
<td nowrap class="text-nowrap text-end">{{ rate|money(currency) }}</td> <td class="text-end">
<td nowrap class="text-nowrap text-end">{{ duration }}</td> <strong>{{ invoice['invoice.total'] }}</strong>
<td nowrap class="text-nowrap text-end">{{ entry.rate|money(currency) }}</td>
</tr>
{% endfor %}
</tbody>
<tfoot>
{% if model.calculator.tax > 0 or not model.isHideZeroTax() %}
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.subtotal'|trans }}
</td> </td>
<td class="text-end">{{ model.calculator.subtotal|money(currency) }}</td>
</tr>
<tr>
<td colspan="4" class="text-end">
{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%)
</td>
<td class="text-end">{{ model.calculator.tax|money(currency) }}</td>
</tr> </tr>
</tfoot>
</table>
</div>
</div>
<div class="row">
<div class="col-xs-12">
{% if invoice['template.payment_terms'] is not empty %}
<div contenteditable="true" class="paymentTerms">
{{ invoice['template.payment_terms']|md2html }}
</div>
{% endif %} {% endif %}
<tr> </div>
<td colspan="4" class="text-end text-nowrap">
<strong>{{ 'invoice.total'|trans }}</strong>
</td>
<td class="text-end">
<strong>{{ model.calculator.total|money(currency) }}</strong>
</td>
</tr>
</tfoot>
</table>
</div> </div>
</div>
<div class="row"> <footer class="footer">
<div class="col-xs-12"> <p>
{% if model.template.paymentTerms is not empty %} <strong>{{ 'address'|trans }}</strong>: {{ invoice['template.company'] }} &ndash; {{ invoice['template.address']|nl2str(' – ') }}
<div contenteditable="true" class="paymentTerms"> <br>
{{ model.template.paymentTerms|md2html }} <strong>{{ 'invoice_bank_account'|trans }}</strong>: {{ invoice['template.payment_details']|nl2str(' – ') }}
</div> <br>
{% endif %} <strong>{{ 'contact'|trans }}</strong>: {{ invoice['template.contact']|nl2str(' – ') }}
</div> </p>
</div> </footer>
<footer class="footer"> </section>
<p> </div>
<strong>{{ 'address'|trans }}</strong>: {{ model.template.company }} &ndash; {{ model.template.address|nl2str(' – ') }}
<br>
<strong>{{ 'invoice_bank_account'|trans }}</strong>: {{ model.template.paymentDetails|nl2str(' – ') }}
<br>
<strong>{{ 'contact'|trans }}</strong>: {{ model.template.contact|nl2str(' – ') }}
</p>
</footer>
</section>
</div>
</body> </body>
</html> </html>

View File

@@ -1,13 +1,8 @@
<!DOCTYPE html> <!DOCTYPE html>
{%- set fallback = app.request is not null ? app.request.locale : 'en' -%} <html lang="{{ invoice['invoice.language'] }}">
{%- set language = model.template.language|default(fallback) -%}
{%- set isDecimal = true -%}
{%- set currency = model.currency -%}
{%- set project = model.query.project -%}
<html lang="{{ language }}">
<head> <head>
<meta charset="UTF-8"> <meta charset="utf-8">
<title>{% block title %}{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}{% endblock %}</title> <title>{% block title %}{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}{% endblock %}</title>
<style> <style>
{{ encore_entry_css_source('invoice-pdf')|raw }} {{ encore_entry_css_source('invoice-pdf')|raw }}
</style> </style>
@@ -18,9 +13,9 @@
<table class="header"> <table class="header">
<tr> <tr>
<td class="text-small"> <td class="text-small">
{{ model.template.company }} &ndash; {{ model.template.address|nl2str(' – ') }} {{ invoice['template.company'] }} &ndash; {{ invoice['template.address']|nl2str(' – ') }}
{% if model.template.vatId is not empty %} {% if invoice['template.vat_id'] is not empty %}
&ndash; {{ 'vat_id'|trans }}: {{ model.template.vatId }} &ndash; {{ 'vat_id'|trans }}: {{ invoice['template.vat_id'] }}
{% endif %} {% endif %}
</td> </td>
<td class="text-small text-right"> <td class="text-small text-right">
@@ -36,18 +31,18 @@
<td style="width: 33%"> <td style="width: 33%">
<strong>{{ 'address'|trans }}</strong> <strong>{{ 'address'|trans }}</strong>
<br> <br>
{{ model.template.company }}<br> {{ invoice['template.company'] }}<br>
{{ model.template.address|nl2br }} {{ invoice['template.address']|nl2br }}
</td> </td>
<td class="text-center"> <td class="text-center">
<strong>{{ 'invoice_bank_account'|trans }}</strong> <strong>{{ 'invoice_bank_account'|trans }}</strong>
<br> <br>
{{ model.template.paymentDetails|nl2br }} {{ invoice['template.payment_details']|nl2br }}
</td> </td>
<td class="text-right" style="width: 33%"> <td class="text-right" style="width: 33%">
<strong>{{ 'contact'|trans }}</strong> <strong>{{ 'contact'|trans }}</strong>
<br> <br>
{{ model.template.contact|nl2br }} {{ invoice['template.contact']|nl2br }}
</td> </td>
</tr> </tr>
</table> </table>
@@ -57,8 +52,8 @@ mpdf-->
<table class="addresses"> <table class="addresses">
<tr> <tr>
<td> <td>
<strong>{{ model.customer.company|default(model.customer.name) }}</strong><br> <strong>{{ invoice['customer.company']|default(invoice['customer.name']) }}</strong><br>
{{ model.customer.address|nl2br }} {{ invoice['customer.address']|nl2br }}
</td> </td>
<td class="text-right"> <td class="text-right">
{% set classLeft = 'text-left' %} {% set classLeft = 'text-left' %}
@@ -66,36 +61,36 @@ mpdf-->
<table style="width: 240px"> <table style="width: 240px">
<tr> <tr>
<td class="{{ classLeft }}">{{ 'date'|trans }}</td> <td class="{{ classLeft }}">{{ 'date'|trans }}</td>
<td class="{{ classRight }}">{{ model.invoiceDate|date_short }}</td> <td class="{{ classRight }}">{{ invoice['invoice.date'] }}</td>
</tr> </tr>
<tr> <tr>
<td class="{{ classLeft }}">{{ 'invoice.service_date'|trans }}</td> <td class="{{ classLeft }}">{{ 'invoice.service_date'|trans }}</td>
<td class="{{ classRight }}">{{ model.query.end|month_name }} {{ model.query.end|date('Y') }}</td> <td class="{{ classRight }}">{{ invoice['query.end_month'] }} {{ invoice['query.end_year'] }}</td>
</tr> </tr>
<tr> <tr>
<td class="{{ classLeft }}">{{ 'invoice.number'|trans }}</td> <td class="{{ classLeft }}">{{ 'invoice.number'|trans }}</td>
<td class="{{ classRight }}">{{ model.invoiceNumber }}</td> <td class="{{ classRight }}">{{ invoice['invoice.number'] }}</td>
</tr> </tr>
<tr> <tr>
<td class="{{ classLeft }}">{{ 'invoice.due_days'|trans }}</td> <td class="{{ classLeft }}">{{ 'invoice.due_days'|trans }}</td>
<td class="{{ classRight }}">{{ model.dueDate|date_short }}</td> <td class="{{ classRight }}">{{ invoice['invoice.due_date'] }}</td>
</tr> </tr>
{% if model.customer.number is not empty %} {% if invoice['customer.number'] is not empty %}
<tr> <tr>
<td class="{{ classLeft }}">{{ 'number'|trans }}</td> <td class="{{ classLeft }}">{{ 'number'|trans }}</td>
<td class="{{ classRight }}">{{ model.customer.number }}</td> <td class="{{ classRight }}">{{ invoice['customer.number'] }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if project is not null and project.orderNumber is not empty %} {% if invoice['query.project.order_number'] is defined and invoice['query.project.order_number'] is not empty %}
<tr> <tr>
<td class="{{ classLeft }}">{{ 'orderNumber'|trans }}</td> <td class="{{ classLeft }}">{{ 'orderNumber'|trans }}</td>
<td class="{{ classRight }}">{{ project.orderNumber }}</td> <td class="{{ classRight }}">{{ invoice['query.project.order_number'] }}</td>
</tr> </tr>
{% endif %} {% endif %}
{% if model.customer.vatId is not empty %} {% if invoice['customer.vat_id'] is not empty %}
<tr> <tr>
<td class="{{ classLeft }}">{{ 'vat_id'|trans }}</td> <td class="{{ classLeft }}">{{ 'vat_id'|trans }}</td>
<td class="{{ classRight }}">{{ model.customer.vatId }}</td> <td class="{{ classRight }}">{{ invoice['customer.vat_id'] }}</td>
</tr> </tr>
{% endif %} {% endif %}
</table> </table>
@@ -103,10 +98,10 @@ mpdf-->
</tr> </tr>
</table> </table>
<h2 style="margin-top: 60px">{{ model.template.title }}</h2> <h2 style="margin-top: 60px">{{ invoice['template.title'] }}</h2>
{% if model.customer.invoiceText is not empty%} {% if invoice['customer.invoice_text'] is not empty%}
<p>{{ model.customer.invoiceText|md2html }}</p> <p>{{ invoice['customer.invoice_text']|md2html }}</p>
{% endif %} {% endif %}
<table class="items"> <table class="items">
@@ -119,43 +114,38 @@ mpdf-->
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for entry in model.calculator.entries %} {% for invoiceLineItem in entries %}
<!-- CONTENT_PART --> <!-- CONTENT_PART -->
{% set duration = entry.duration|duration(isDecimal) %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = entry.amount|amount %}
{% else %}
{% set rate = entry.hourlyRate %}
{% endif %}
<tr> <tr>
<td class="first"> <td class="first">
{% if entry.description is not empty %} {% if invoiceLineItem['entry.description'] is not empty %}
{{ entry.description|nl2br }} {{ invoiceLineItem['entry.description']|nl2br }}
{% else %} {% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }} {% if invoiceLineItem['entry.activity'] is defined %}
{{ invoiceLineItem['entry.activity'] }} /
{% endif %}
{{ invoiceLineItem['entry.project'] }}
{% endif %} {% endif %}
</td> </td>
<td class="text-right text-nowrap">{{ rate|money(currency) }}</td> <td class="text-right text-nowrap">{{ invoiceLineItem['entry.rate'] }}</td>
<td class="text-right text-nowrap">{{ duration }}</td> <td class="text-right text-nowrap">{{ invoiceLineItem['entry.amount'] }}</td>
<td class="last text-right text-nowrap">{{ entry.rate|money(currency) }}</td> <td class="last text-right text-nowrap">{{ invoiceLineItem['entry.total'] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
{% if model.calculator.tax > 0 or not model.isHideZeroTax() %} {% if not invoice['invoice.tax_hide'] %}
<tr> <tr>
<td colspan="3" class="text-right"> <td colspan="3" class="text-right">
{{ 'invoice.subtotal'|trans }} {{ 'invoice.subtotal'|trans }}
</td> </td>
<td class="last text-right">{{ model.calculator.subtotal|money(currency) }}</td> <td class="last text-right">{{ invoice['invoice.subtotal'] }}</td>
</tr> </tr>
<tr> <tr>
<td colspan="3" class="text-right"> <td colspan="3" class="text-right">
{{ 'invoice.tax'|trans }} ({{ model.calculator.vat }}%) {{ 'invoice.tax'|trans }} ({{ invoice['invoice.vat'] }}%)
</td> </td>
<td class="last text-right">{{ model.calculator.tax|money(currency) }}</td> <td class="last text-right">{{ invoice['invoice.tax'] }}</td>
</tr> </tr>
{% endif %} {% endif %}
<tr> <tr>
@@ -163,16 +153,16 @@ mpdf-->
<strong>{{ 'invoice.total'|trans }}</strong> <strong>{{ 'invoice.total'|trans }}</strong>
</td> </td>
<td class="last text-right"> <td class="last text-right">
<strong>{{ model.calculator.total|money(currency) }}</strong> <strong>{{ invoice['invoice.total'] }}</strong>
</td> </td>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
{% if model.template.paymentTerms is not empty %} {% if invoice['template.payment_terms'] is not empty %}
<p> <div class="paymentTerms">
{{ model.template.paymentTerms|md2html }} {{ invoice['template.payment_terms']|md2html }}
</p> </div>
{% endif %} {% endif %}
</body> </body>
</html> </html>

View File

@@ -1,88 +1,91 @@
<!DOCTYPE html> <!DOCTYPE html>
{% set fallback = app.request is not null ? app.request.locale : 'en' %} <html lang="{{ invoice['invoice.language'] }}">
{% set language = model.template.language|default(fallback) %}
{% set isDecimal = true %}
{% set project = model.query.project %}
{% set activity = model.query.activity %}
<html lang="{{ language }}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport"> <meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<title>{{ model.invoiceNumber }}-{{ model.customer.company|default(model.customer.name)|u.snake }}</title> <title>{{ invoice['invoice.number'] }}-{{ invoice['customer.company']|default(invoice['customer.name'])|u.snake }}</title>
<style type="text/css"> <style>
{{ encore_entry_css_source('invoice')|raw }} {{ encore_entry_css_source('invoice')|raw }}
</style> </style>
</head> </head>
<body class="invoice_print"> <body class="invoice_print">
<div class="wrapper"> <div class="wrapper">
<section class="invoice"> <section class="invoice">
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
<h2 class="page-header"> <h2 class="page-header">
<span contenteditable="true">{{ model.template.title }}</span> <span contenteditable="true">{{ invoice['template.title'] }}</span>
</h2> </h2>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
<table class="table no-border table-sm"> <table class="table no-border table-sm">
<tr> <tr>
<th class="ps-0">{{ 'invoice.from'|trans }}</th> <th class="ps-0">{{ 'invoice.from'|trans }}</th>
<td contenteditable="true"> <td contenteditable="true">
{% if model.query.user is not empty %} {% if not invoice['user.see_others'] and invoice['user.display'] is defined %}
{{ model.query.user.displayName }} {{ invoice['user.display'] }}
{% else %} {% else %}
{{ model.template.company }} {{ invoice['template.company'] }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="ps-0">{{ 'date'|trans }}</th> <th class="ps-0">{{ 'date'|trans }}</th>
<td contenteditable="true"> <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') %} {% if invoice['query.begin_month_number'] != invoice['query.end_month_number'] or invoice['query.begin_year'] != invoice['query.end_year'] %}
{{ model.query.begin|date_short }} - {{ model.query.end|date_short }} {{ invoice['query.begin'] }} - {{ invoice['query.end'] }}
{% else %} {% else %}
{{ model.query.end|month_name }} {{ model.query.end|date('Y') }} {{ invoice['query.end_month'] }} {{ invoice['query.end_year'] }}
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>
<th class="ps-0">{{ 'customer'|trans }}</th> <th class="ps-0">{{ 'customer'|trans }}</th>
<td contenteditable="true"> <td contenteditable="true">
{% if model.customer.number is not empty %}[{{ model.customer.number }}]{% endif %} {% if invoice['customer.number'] is not empty %}
{{ model.customer.name }}{% if model.customer.contact is not empty %} / {{ model.customer.contact }}{% endif %} [{{ invoice['customer.number'] }}]
{% endif %}
{{ invoice['customer.name'] }}
{% if invoice['customer.contact'] is not empty %}
/ {{ invoice['customer.contact'] }}
{% endif %}
</td> </td>
</tr> </tr>
{% if project is not null %} {% if invoice['query.project.name'] is defined %}
<tr> <tr>
<th class="ps-0">{{ 'project'|trans }}</th> <th class="ps-0">{{ 'project'|trans }}</th>
<td contenteditable="true"> <td contenteditable="true">
{{ project.name }} {{ invoice['query.project.name'] }}
{% if project.orderNumber is not empty %} {% if invoice['query.project.order_number'] is not empty %}
({{ 'orderNumber'|trans }}: {{ project.orderNumber }}) ({{ 'orderNumber'|trans }}: {{ invoice['query.project.order_number'] }})
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% if activity is not null %} {% if invoice['query.activity.name'] is defined %}
<tr> <tr>
<th class="ps-0">{{ 'activity'|trans }}</th> <th class="ps-0">{{ 'activity'|trans }}</th>
<td contenteditable="true"> <td contenteditable="true">
{{ activity.name }} {{ invoice['query.activity.name'] }}
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
</table> </table>
</div> </div>
</div> </div>
<div class="row invoice-items mt-2 mb-3"> <div class="row invoice-items mt-2 mb-3">
<div class="col-xs-12 table-responsive"> <div class="col-xs-12 table-responsive">
<table class="table table-striped"> <table class="table table-striped">
<thead> <thead>
<tr> <tr>
<th>{{ 'date'|trans }}</th> <th>{{ 'date'|trans }}</th>
{% if model.query.user is empty %} {% if invoice['user.see_others'] %}
<th>{{ 'user'|trans }}</th> <th>{{ 'user'|trans }}</th>
{% endif %} {% endif %}
<th>{{ 'activity'|trans }}</th> <th>{{ 'activity'|trans }}</th>
@@ -90,44 +93,48 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for entry in model.calculator.entries %} {% for timesheet in entries %}
<tr> <tr>
<td>{{ entry.begin|date_short }}</td> <td>{{ timesheet['entry.begin'] }}</td>
{% if model.query.user is empty %} {% if invoice['user.see_others'] %}
<td>{{ entry.user.displayName }}</td> <td>{{ timesheet['entry.user_display'] }}</td>
{% endif %} {% endif %}
<td contenteditable="true"> <td contenteditable="true">
{% if entry.description is not empty %} {% if timesheet['entry.description'] is not empty %}
{{ entry.description|nl2br }} {{ timesheet['entry.description']|nl2br }}
{% else %} {% else %}
{% if entry.activity is not null %}{{ entry.activity.name }} / {% endif %}{{ entry.project.name }} {% if timesheet['entry.activity'] is defined %}
{{ timesheet['entry.activity'] }} /
{% endif %}
{{ timesheet['entry.project'] }}
{% endif %} {% endif %}
</td> </td>
<td class="text-right text-nowrap">{{ entry.duration|duration(isDecimal) }}</td> <td class="text-right text-nowrap">{{ timesheet['entry.amount'] }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot> <tfoot>
<tr> <tr>
<th></th> <th></th>
{% if model.query.user is empty %} {% if invoice['user.see_others'] %}
<th></th> <th></th>
{% endif %} {% endif %}
<th>{{ 'invoice.total_working_time'|trans }}</th> <th>{{ 'invoice.total_working_time'|trans }}</th>
<th class="text-right text-nowrap">{{ model.calculator.timeWorked|duration(isDecimal) }}</th> <th class="text-right text-nowrap">{{ invoice['invoice.duration_decimal'] }}</th>
</tr> </tr>
</tfoot> </tfoot>
</table> </table>
</div> </div>
</div> </div>
<div class="row"> <div class="row">
<div class="col-xs-12"> <div class="col-xs-12">
{% if model.template.paymentTerms is not empty %} {% if invoice['template.payment_terms'] is not empty %}
<p class="lead">{{ 'payment_terms'|trans }}</p> <p class="lead">{{ 'payment_terms'|trans }}</p>
<p class="text-muted well well-sm no-shadow" contenteditable="true" style="margin-bottom: 100px"> <div class="paymentTerms" contenteditable="true" style="margin-bottom: 100px">
{{ model.template.paymentTerms|trim|nl2br }} {{ invoice['template.payment_terms']|md2html }}
</p> </div>
{% endif %} {% endif %}
<div class="table-responsive"> <div class="table-responsive">
@@ -136,6 +143,7 @@
</div> </div>
</div> </div>
</div> </div>
</section> </section>
</div> </div>
</body> </body>

View File

@@ -49,38 +49,44 @@ class TimesheetControllerTest extends APIControllerBaseTest
return $this->importFixture($fixture); return $this->importFixture($fixture);
} }
public function testIsSecure() public function testIsSecure(): void
{ {
$this->assertUrlIsSecured('/api/timesheets'); $this->assertUrlIsSecured('/api/timesheets');
} }
public function testGetCollection() public function testGetCollection(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/timesheets'); $this->assertAccessIsGranted($client, '/api/timesheets');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
$this->assertNotEmpty($result); self::assertIsString($content);
$this->assertEquals(10, \count($result)); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(10, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
} }
public function testGetCollectionFull() public function testGetCollectionFull(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', ['full' => 'true']); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', ['full' => 'true']);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
$this->assertNotEmpty($result); self::assertIsString($content);
$this->assertEquals(10, \count($result)); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(10, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollectionFull', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollectionFull', $result[0]);
} }
public function testGetCollectionForOtherUser() public function testGetCollectionForOtherUser(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -97,15 +103,30 @@ class TimesheetControllerTest extends APIControllerBaseTest
$query = ['user' => 2]; $query = ['user' => 2];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
$this->assertNotEmpty($result); self::assertIsString($content);
$this->assertEquals(10, \count($result)); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(10, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
$query = ['users' => [2]];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(10, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
} }
public function testGetCollectionForAllUser() public function testGetCollectionForAllUser(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -122,26 +143,32 @@ class TimesheetControllerTest extends APIControllerBaseTest
$query = ['user' => 'all']; $query = ['user' => 'all'];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
$this->assertNotEmpty($result); self::assertIsString($content);
$this->assertEquals(17, \count($result)); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(17, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
} }
public function testGetCollectionForEmptyResult() public function testGetCollectionForEmptyResult(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->assertAccessIsGranted($client, '/api/timesheets'); $this->assertAccessIsGranted($client, '/api/timesheets');
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
$this->assertEmpty($result); $this->assertEmpty($result);
} }
public function testGetCollectionWithQuery() public function testGetCollectionWithQuery(): void
{ {
$modifiedAfter = new \DateTime('-1 hour'); $modifiedAfter = new \DateTime('-1 hour');
$begin = new \DateTime('first day of this month'); $begin = new \DateTime('first day of this month');
@@ -167,16 +194,18 @@ class TimesheetControllerTest extends APIControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER, 22); $this->importFixtureForUser(User::ROLE_USER, 22);
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
$this->assertPagination($client->getResponse(), 2, 4, 6, 22); $this->assertPagination($client->getResponse(), 2, 4, 6, 22);
$this->assertIsArray($result); self::assertIsArray($result);
$this->assertNotEmpty($result); self::assertNotEmpty($result);
$this->assertEquals(4, \count($result)); self::assertEquals(4, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
} }
public function testGetCollectionWithQueryFailsWith404OnOutOfRangedPage() public function testGetCollectionWithQueryFailsWith404OnOutOfRangedPage(): void
{ {
$begin = new \DateTime('first day of this month'); $begin = new \DateTime('first day of this month');
$begin->setTime(0, 0, 0); $begin->setTime(0, 0, 0);
@@ -194,7 +223,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiException($client->getResponse(), ['code' => 404, 'message' => 'Not Found']); $this->assertApiException($client->getResponse(), ['code' => 404, 'message' => 'Not Found']);
} }
public function testGetCollectionWithSingleParamsQuery() public function testGetCollectionWithSingleParamsQuery(): void
{ {
$begin = new \DateTime('first day of this month'); $begin = new \DateTime('first day of this month');
$begin->setTime(0, 0, 0); $begin->setTime(0, 0, 0);
@@ -218,15 +247,17 @@ class TimesheetControllerTest extends APIControllerBaseTest
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
$this->assertIsArray($result); self::assertIsArray($result);
$this->assertNotEmpty($result); self::assertNotEmpty($result);
$this->assertEquals(5, \count($result)); self::assertEquals(5, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
} }
public function testExportedFilter() public function testExportedFilter(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -255,11 +286,13 @@ class TimesheetControllerTest extends APIControllerBaseTest
]; ];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
$this->assertIsArray($result); self::assertIsArray($result);
$this->assertNotEmpty($result); self::assertNotEmpty($result);
$this->assertEquals(7, \count($result)); self::assertEquals(7, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
$query = [ $query = [
@@ -271,11 +304,13 @@ class TimesheetControllerTest extends APIControllerBaseTest
]; ];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
$this->assertIsArray($result); self::assertIsArray($result);
$this->assertNotEmpty($result); self::assertNotEmpty($result);
$this->assertEquals(10, \count($result)); self::assertEquals(10, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
$query = [ $query = [
@@ -285,15 +320,17 @@ class TimesheetControllerTest extends APIControllerBaseTest
'end' => $end->format(self::DATE_FORMAT_HTML5), 'end' => $end->format(self::DATE_FORMAT_HTML5),
]; ];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
$this->assertIsArray($result); self::assertIsArray($result);
$this->assertNotEmpty($result); self::assertNotEmpty($result);
$this->assertEquals(17, \count($result)); self::assertEquals(17, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
} }
public function testGetEntity() public function testGetEntity(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$em = $this->getEntityManager(); $em = $this->getEntityManager();
@@ -323,9 +360,11 @@ class TimesheetControllerTest extends APIControllerBaseTest
$em->flush(); $em->flush();
$this->assertAccessIsGranted($client, '/api/timesheets/' . $timesheet->getId()); $this->assertAccessIsGranted($client, '/api/timesheets/' . $timesheet->getId());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
$this->assertIsArray($result); self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$expected = [ $expected = [
@@ -353,7 +392,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
} }
} }
public function testGetEntityAccessDenied() public function testGetEntityAccessDenied(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -363,23 +402,25 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiAccessDenied($client, '/api/timesheets/' . $timesheets[0]->getId(), 'Access denied.'); $this->assertApiAccessDenied($client, '/api/timesheets/' . $timesheets[0]->getId(), 'Access denied.');
} }
public function testGetEntityAccessAllowedForAdmin() public function testGetEntityAccessAllowedForAdmin(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/timesheets/' . $timesheets[0]->getId()); $this->assertAccessIsGranted($client, '/api/timesheets/' . $timesheets[0]->getId());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
$this->assertIsArray($result); self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
} }
public function testGetEntityNotFound() public function testGetEntityNotFound(): void
{ {
$this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/' . PHP_INT_MAX, 'GET', 'App\\Entity\\Timesheet object not found by the @ParamConverter annotation.'); $this->assertEntityNotFound(User::ROLE_USER, '/api/timesheets/' . PHP_INT_MAX, 'GET', 'App\\Entity\\Timesheet object not found by the @ParamConverter annotation.');
} }
public function testPostAction() public function testPostAction(): void
{ {
$dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE)); $dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE));
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -396,16 +437,19 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets', 'POST', [], json_encode($data)); $this->request($client, '/api/timesheets', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
$this->assertTrue($result['duration'] == 28800 || $result['duration'] == 28860); // 1 minute rounding might be applied $this->assertTrue($result['duration'] == 28800 || $result['duration'] == 28860); // 1 minute rounding might be applied
$this->assertEquals(2016, $result['rate']); self::assertEquals(2016, $result['rate']);
$this->assertFalse($result['billable']); $this->assertFalse($result['billable']);
} }
public function testPostActionWithFullExpandedResponse() public function testPostActionWithFullExpandedResponse(): void
{ {
$dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE)); $dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE));
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -422,16 +466,19 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets?full=true', 'POST', [], json_encode($data)); $this->request($client, '/api/timesheets?full=true', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetExpanded', $result); self::assertApiResponseTypeStructure('TimesheetExpanded', $result);
$this->assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
$this->assertTrue($result['duration'] == 28800 || $result['duration'] == 28860); // 1 minute rounding might be applied $this->assertTrue($result['duration'] == 28800 || $result['duration'] == 28860); // 1 minute rounding might be applied
$this->assertEquals(2016, $result['rate']); self::assertEquals(2016, $result['rate']);
$this->assertTrue($result['billable']); $this->assertTrue($result['billable']);
} }
public function testPostActionForDifferentUser() public function testPostActionForDifferentUser(): void
{ {
$dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE)); $dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE));
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -451,18 +498,21 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets', 'POST', [], json_encode($data)); $this->request($client, '/api/timesheets', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
$this->assertEquals($user->getId(), $result['user']); self::assertEquals($user->getId(), $result['user']);
$this->assertNotEquals($admin->getId(), $result['user']); $this->assertNotEquals($admin->getId(), $result['user']);
$this->assertTrue($result['billable']); $this->assertTrue($result['billable']);
} }
// check for project, as this is a required field. It will not be included in the select, as it is // check for project, as this is a required field. It will not be included in the select, as it is
// already filtered within the repository due to the hidden customer // already filtered within the repository due to the hidden customer
public function testPostActionWithInvisibleProject() public function testPostActionWithInvisibleProject(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -496,7 +546,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
// check for activity, as this is a required field. It will not be included in the select, as it is // check for activity, as this is a required field. It will not be included in the select, as it is
// already filtered within the repository due to the hidden flag // already filtered within the repository due to the hidden flag
public function testPostActionWithInvisibleActivity() public function testPostActionWithInvisibleActivity(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -528,7 +578,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiCallValidationError($client->getResponse(), ['activity']); $this->assertApiCallValidationError($client->getResponse(), ['activity']);
} }
public function testPostActionWithNonBillableCustomer() public function testPostActionWithNonBillableCustomer(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
@@ -557,13 +607,16 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets', 'POST', [], json_encode($data)); $this->request($client, '/api/timesheets', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertFalse($result['billable']); $this->assertFalse($result['billable']);
} }
public function testPostActionWithNonBillableCustomerExplicit() public function testPostActionWithNonBillableCustomerExplicit(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
@@ -593,14 +646,17 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets', 'POST', [], json_encode($data)); $this->request($client, '/api/timesheets', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
// explicit overwritten values win! // explicit overwritten values win!
$this->assertTrue($result['billable']); $this->assertTrue($result['billable']);
} }
public function testPatchAction() public function testPatchAction(): void
{ {
$dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE)); $dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE));
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
@@ -616,16 +672,19 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets/' . $timesheets[0]->getId(), 'PATCH', [], json_encode($data)); $this->request($client, '/api/timesheets/' . $timesheets[0]->getId(), 'PATCH', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
$this->assertEquals(25200, $result['duration']); self::assertEquals(25200, $result['duration']);
$this->assertEquals('foo', $result['description']); self::assertEquals('foo', $result['description']);
$this->assertFalse($result['billable']); $this->assertFalse($result['billable']);
} }
public function testPatchActionWithInvalidUser() public function testPatchActionWithInvalidUser(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -654,12 +713,12 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiResponseAccessDenied($response); $this->assertApiResponseAccessDenied($response);
} }
public function testPatchActionWithUnknownTimesheet() public function testPatchActionWithUnknownTimesheet(): void
{ {
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/255', []); $this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/255', []);
} }
public function testInvalidPatchAction() public function testInvalidPatchAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -674,48 +733,51 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets/' . $timesheets[0]->getId(), 'PATCH', [], json_encode($data)); $this->request($client, '/api/timesheets/' . $timesheets[0]->getId(), 'PATCH', [], json_encode($data));
$response = $client->getResponse(); $response = $client->getResponse();
$this->assertEquals(400, $response->getStatusCode()); self::assertEquals(400, $response->getStatusCode());
$this->assertApiCallValidationError($response, ['activity'], false, ['End date must not be earlier then start date.']); $this->assertApiCallValidationError($response, ['activity'], false, ['End date must not be earlier then start date.']);
} }
// TODO: TEST PATCH FOR EXPORTED TIMESHEET FOR USER WITHOUT PERMISSION IS REJECTED // TODO: TEST PATCH FOR EXPORTED TIMESHEET FOR USER WITHOUT PERMISSION IS REJECTED
public function testDeleteAction() public function testDeleteAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
$this->assertAccessIsGranted($client, '/api/timesheets/' . $timesheets[0]->getId()); $this->assertAccessIsGranted($client, '/api/timesheets/' . $timesheets[0]->getId());
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
$id = $result['id']; $id = $result['id'];
$this->request($client, '/api/timesheets/' . $id, 'DELETE'); $this->request($client, '/api/timesheets/' . $id, 'DELETE');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode()); self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
$this->assertEmpty($client->getResponse()->getContent()); $this->assertEmpty($client->getResponse()->getContent());
} }
public function testDeleteActionWithUnknownTimesheet() public function testDeleteActionWithUnknownTimesheet(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->assertNotFoundForDelete($client, '/api/timesheets/255'); $this->assertNotFoundForDelete($client, '/api/timesheets/255');
} }
public function testDeleteActionForDifferentUser() public function testDeleteActionForDifferentUser(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
$this->request($client, '/api/timesheets/' . $timesheets[0]->getId(), 'DELETE'); $this->request($client, '/api/timesheets/' . $timesheets[0]->getId(), 'DELETE');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode()); self::assertEquals(Response::HTTP_NO_CONTENT, $client->getResponse()->getStatusCode());
$this->assertEmpty($client->getResponse()->getContent()); $this->assertEmpty($client->getResponse()->getContent());
} }
public function testDeleteActionWithoutAuthorization() public function testDeleteActionWithoutAuthorization(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -727,7 +789,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiResponseAccessDenied($response); $this->assertApiResponseAccessDenied($response);
} }
public function testDeleteActionForExportedRecordIsNotAllowed() public function testDeleteActionForExportedRecordIsNotAllowed(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -746,7 +808,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiResponseAccessDenied($response); $this->assertApiResponseAccessDenied($response);
} }
public function testDeleteActionForExportedRecordIsAllowedForAdmin() public function testDeleteActionForExportedRecordIsAllowedForAdmin(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -763,7 +825,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
} }
public function testGetRecentAction() public function testGetRecentAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -785,15 +847,18 @@ class TimesheetControllerTest extends APIControllerBaseTest
]; ];
$this->assertAccessIsGranted($client, '/api/timesheets/recent', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets/recent', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
$this->assertNotEmpty($result); self::assertIsString($content);
$this->assertEquals(1, \count($result)); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(1, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollectionFull', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollectionFull', $result[0]);
} }
public function testActiveAction() public function testActiveAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -813,14 +878,17 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets/active'); $this->request($client, '/api/timesheets/active');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$results = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertEquals(3, \count($results)); self::assertIsString($content);
foreach ($results as $timesheet) { $result = json_decode($content, true);
self::assertEquals(3, \count($result));
foreach ($result as $timesheet) {
self::assertApiResponseTypeStructure('TimesheetCollectionFull', $timesheet); self::assertApiResponseTypeStructure('TimesheetCollectionFull', $timesheet);
} }
} }
public function testStopAction() public function testStopAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -842,9 +910,12 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH'); $this->request($client, '/api/timesheets/' . $id . '/stop', 'PATCH');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$this->assertNotEmpty($result); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$em = $this->getEntityManager(); $em = $this->getEntityManager();
@@ -853,7 +924,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertInstanceOf(\DateTime::class, $timesheet->getEnd()); $this->assertInstanceOf(\DateTime::class, $timesheet->getEnd());
} }
public function testStopActionTriggersValidationOnLongRunning() public function testStopActionTriggersValidationOnLongRunning(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->setSystemConfiguration('timesheet.rules.long_running_duration', 750); $this->setSystemConfiguration('timesheet.rules.long_running_duration', 750);
@@ -877,12 +948,12 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiCallValidationError($client->getResponse(), ['duration' => 'Maximum 12:30 hours allowed.']); $this->assertApiCallValidationError($client->getResponse(), ['duration' => 'Maximum 12:30 hours allowed.']);
} }
public function testStopThrowsNotFound() public function testStopThrowsNotFound(): void
{ {
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/11/stop', []); $this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/11/stop', []);
} }
public function testStopNotAllowedForUser() public function testStopNotAllowedForUser(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -905,7 +976,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiResponseAccessDenied($client->getResponse(), 'Access denied.'); $this->assertApiResponseAccessDenied($client->getResponse(), 'Access denied.');
} }
public function testGetCollectionWithTags() public function testGetCollectionWithTags(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$this->importFixtureForUser(User::ROLE_USER); $this->importFixtureForUser(User::ROLE_USER);
@@ -924,33 +995,42 @@ class TimesheetControllerTest extends APIControllerBaseTest
$query = ['tags' => ['Test']]; $query = ['tags' => ['Test']];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
$this->assertNotEmpty($result); self::assertIsString($content);
$this->assertEquals(10, \count($result)); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(10, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
$query = ['tags' => ['Test', 'Admin']]; $query = ['tags' => ['Test', 'Admin']];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
$this->assertNotEmpty($result); self::assertIsString($content);
$this->assertEquals(10, \count($result)); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(10, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
$query = ['tags' => ['Nothing-2-see', 'here']]; $query = ['tags' => ['Nothing-2-see', 'here']];
$this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query); $this->assertAccessIsGranted($client, '/api/timesheets', 'GET', $query);
$result = json_decode($client->getResponse()->getContent(), true);
$this->assertIsArray($result); $content = $client->getResponse()->getContent();
$this->assertNotEmpty($result); self::assertIsString($content);
$this->assertEquals(20, \count($result)); $result = json_decode($content, true);
self::assertIsArray($result);
self::assertNotEmpty($result);
self::assertEquals(20, \count($result));
self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]); self::assertApiResponseTypeStructure('TimesheetCollection', $result[0]);
} }
public function testRestartAction() public function testRestartAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -965,7 +1045,10 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH'); $this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertEmpty($result['description']); $this->assertEmpty($result['description']);
$this->assertEmpty($result['tags']); $this->assertEmpty($result['tags']);
@@ -975,13 +1058,13 @@ class TimesheetControllerTest extends APIControllerBaseTest
$timesheet = $em->getRepository(Timesheet::class)->find($result['id']); $timesheet = $em->getRepository(Timesheet::class)->find($result['id']);
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin()); $this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
$this->assertNull($timesheet->getEnd()); $this->assertNull($timesheet->getEnd());
$this->assertEquals(1, $timesheet->getActivity()->getId()); self::assertEquals(1, $timesheet->getActivity()->getId());
$this->assertEquals(1, $timesheet->getProject()->getId()); self::assertEquals(1, $timesheet->getProject()->getId());
$this->assertEmpty($timesheet->getDescription()); $this->assertEmpty($timesheet->getDescription());
$this->assertEmpty($timesheet->getTags()); $this->assertEmpty($timesheet->getTags());
} }
public function testRestartActionWithBegin() public function testRestartActionWithBegin(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -997,7 +1080,10 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH', ['begin' => $begin->format(BaseApiController::DATE_FORMAT_PHP)]); $this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH', ['begin' => $begin->format(BaseApiController::DATE_FORMAT_PHP)]);
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertEmpty($result['description']); $this->assertEmpty($result['description']);
$this->assertEmpty($result['tags']); $this->assertEmpty($result['tags']);
@@ -1006,15 +1092,15 @@ class TimesheetControllerTest extends APIControllerBaseTest
/** @var Timesheet $timesheet */ /** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find($result['id']); $timesheet = $em->getRepository(Timesheet::class)->find($result['id']);
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin()); $this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
$this->assertEquals($begin->format(BaseApiController::DATE_FORMAT_PHP), $timesheet->getBegin()->format(BaseApiController::DATE_FORMAT_PHP)); self::assertEquals($begin->format(BaseApiController::DATE_FORMAT_PHP), $timesheet->getBegin()->format(BaseApiController::DATE_FORMAT_PHP));
$this->assertNull($timesheet->getEnd()); $this->assertNull($timesheet->getEnd());
$this->assertEquals(1, $timesheet->getActivity()->getId()); self::assertEquals(1, $timesheet->getActivity()->getId());
$this->assertEquals(1, $timesheet->getProject()->getId()); self::assertEquals(1, $timesheet->getProject()->getId());
$this->assertEmpty($timesheet->getDescription()); $this->assertEmpty($timesheet->getDescription());
$this->assertEmpty($timesheet->getTags()); $this->assertEmpty($timesheet->getTags());
} }
public function testRestartActionWithCopyData() public function testRestartActionWithCopyData(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -1034,29 +1120,32 @@ class TimesheetControllerTest extends APIControllerBaseTest
$em->flush(); $em->flush();
$timesheet = $em->getRepository(Timesheet::class)->find($id); $timesheet = $em->getRepository(Timesheet::class)->find($id);
$this->assertEquals('foo', $timesheet->getDescription()); self::assertEquals('foo', $timesheet->getDescription());
$this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH', ['copy' => 'all']); $this->request($client, '/api/timesheets/' . $id . '/restart', 'PATCH', ['copy' => 'all']);
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertEquals('foo', $result['description']); self::assertEquals('foo', $result['description']);
$this->assertEquals([['name' => 'sdfsdf', 'value' => 'nnnnn'], ['name' => '1234567890', 'value' => '1234567890']], $result['metaFields']); self::assertEquals([['name' => 'sdfsdf', 'value' => 'nnnnn'], ['name' => '1234567890', 'value' => '1234567890']], $result['metaFields']);
$this->assertEquals(['another', 'testing', 'bar'], $result['tags']); self::assertEquals(['another', 'testing', 'bar'], $result['tags']);
$em = $this->getEntityManager(); $em = $this->getEntityManager();
/** @var Timesheet $timesheet */ /** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find($result['id']); $timesheet = $em->getRepository(Timesheet::class)->find($result['id']);
$this->assertInstanceOf(\DateTime::class, $timesheet->getBegin()); $this->assertInstanceOf(\DateTime::class, $timesheet->getBegin());
$this->assertNull($timesheet->getEnd()); $this->assertNull($timesheet->getEnd());
$this->assertEquals(1, $timesheet->getActivity()->getId()); self::assertEquals(1, $timesheet->getActivity()->getId());
$this->assertEquals(1, $timesheet->getProject()->getId()); self::assertEquals(1, $timesheet->getProject()->getId());
$this->assertEquals('foo', $timesheet->getDescription()); self::assertEquals('foo', $timesheet->getDescription());
$this->assertEquals(['another', 'testing', 'bar'], $timesheet->getTagsAsArray()); self::assertEquals(['another', 'testing', 'bar'], $timesheet->getTagsAsArray());
} }
public function testRestartNotAllowedForUser() public function testRestartNotAllowedForUser(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
@@ -1078,12 +1167,12 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiResponseAccessDenied($client->getResponse(), 'Access denied.'); $this->assertApiResponseAccessDenied($client->getResponse(), 'Access denied.');
} }
public function testRestartThrowsNotFound() public function testRestartThrowsNotFound(): void
{ {
$this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/42/restart', []); $this->assertEntityNotFoundForPatch(User::ROLE_USER, '/api/timesheets/42/restart', []);
} }
public function testDuplicateAction() public function testDuplicateAction(): void
{ {
$dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE)); $dateTime = new DateTimeFactory(new \DateTimeZone(self::TEST_TIMEZONE));
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
@@ -1099,30 +1188,36 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets', 'POST', [], json_encode($data)); $this->request($client, '/api/timesheets', 'POST', [], json_encode($data));
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
$this->assertTrue($result['duration'] == 28800 || $result['duration'] == 28860); // 1 minute rounding might be applied $this->assertTrue($result['duration'] == 28800 || $result['duration'] == 28860); // 1 minute rounding might be applied
$this->assertEquals(2016, $result['rate']); self::assertEquals(2016, $result['rate']);
$this->request($client, '/api/timesheets/' . $result['id'] . '/duplicate', 'PATCH'); $this->request($client, '/api/timesheets/' . $result['id'] . '/duplicate', 'PATCH');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
$this->assertIsArray($result); self::assertIsString($content);
$result = json_decode($content, true);
self::assertIsArray($result);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertNotEmpty($result['id']); self::assertNotEmpty($result['id']);
$this->assertTrue($result['duration'] == 28800 || $result['duration'] == 28860); // 1 minute rounding might be applied $this->assertTrue($result['duration'] == 28800 || $result['duration'] == 28860); // 1 minute rounding might be applied
$this->assertEquals(2016, $result['rate']); self::assertEquals(2016, $result['rate']);
} }
public function testDuplicateThrowsNotFound() public function testDuplicateThrowsNotFound(): void
{ {
$this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/11/duplicate', []); $this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/11/duplicate', []);
} }
public function testExportAction() public function testExportAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -1135,7 +1230,11 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->request($client, '/api/timesheets/' . $id . '/export', 'PATCH'); $this->request($client, '/api/timesheets/' . $id . '/export', 'PATCH');
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true);
$content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$em->clear(); $em->clear();
@@ -1151,7 +1250,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertFalse($timesheet->isExported()); $this->assertFalse($timesheet->isExported());
} }
public function testExportNotAllowedForUser() public function testExportNotAllowedForUser(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -1161,17 +1260,17 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertApiResponseAccessDenied($client->getResponse(), 'Access denied.'); $this->assertApiResponseAccessDenied($client->getResponse(), 'Access denied.');
} }
public function testExportThrowsNotFound() public function testExportThrowsNotFound(): void
{ {
$this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/' . PHP_INT_MAX . '/export', []); $this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/' . PHP_INT_MAX . '/export', []);
} }
public function testMetaActionThrowsNotFound() public function testMetaActionThrowsNotFound(): void
{ {
$this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/' . PHP_INT_MAX . '/meta', []); $this->assertEntityNotFoundForPatch(User::ROLE_ADMIN, '/api/timesheets/' . PHP_INT_MAX . '/meta', []);
} }
public function testMetaActionThrowsExceptionOnMissingName() public function testMetaActionThrowsExceptionOnMissingName(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -1183,7 +1282,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
]); ]);
} }
public function testMetaActionThrowsExceptionOnMissingValue() public function testMetaActionThrowsExceptionOnMissingValue(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -1195,7 +1294,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
]); ]);
} }
public function testMetaActionThrowsExceptionOnMissingMetafield() public function testMetaActionThrowsExceptionOnMissingMetafield(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -1207,7 +1306,7 @@ class TimesheetControllerTest extends APIControllerBaseTest
]); ]);
} }
public function testMetaAction() public function testMetaAction(): void
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER); $client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
$timesheets = $this->importFixtureForUser(User::ROLE_USER); $timesheets = $this->importFixtureForUser(User::ROLE_USER);
@@ -1222,13 +1321,16 @@ class TimesheetControllerTest extends APIControllerBaseTest
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$result = json_decode($client->getResponse()->getContent(), true); $content = $client->getResponse()->getContent();
self::assertIsString($content);
$result = json_decode($content, true);
self::assertApiResponseTypeStructure('TimesheetEntity', $result); self::assertApiResponseTypeStructure('TimesheetEntity', $result);
$this->assertEquals(['name' => 'metatestmock', 'value' => 'another,testing,bar'], $result['metaFields'][0]); self::assertEquals(['name' => 'metatestmock', 'value' => 'another,testing,bar'], $result['metaFields'][0]);
$em = $this->getEntityManager(); $em = $this->getEntityManager();
/** @var Timesheet $timesheet */ /** @var Timesheet $timesheet */
$timesheet = $em->getRepository(Timesheet::class)->find($id); $timesheet = $em->getRepository(Timesheet::class)->find($id);
$this->assertEquals('another,testing,bar', $timesheet->getMetaField('metatestmock')->getValue()); self::assertEquals('another,testing,bar', $timesheet->getMetaField('metatestmock')->getValue());
} }
} }

View File

@@ -179,9 +179,20 @@ class TimesheetTest extends TestCase
public function testClone(): void public function testClone(): void
{ {
$sut = new Timesheet(); $sut = new Timesheet();
self::assertNull($sut->getModifiedAt());
$sut->setExported(true); $sut->setExported(true);
$sut->setDescription('Invalid timesheet category "foo" given, expected one of: work, holiday, sickness, parental, overtime'); $sut->setDescription('Invalid timesheet category "foo" given, expected one of: work, holiday, sickness, parental, overtime');
$modifiedDate = new \DateTime();
$reflection = new \ReflectionClass($sut);
$property = $reflection->getProperty('modifiedAt');
$property->setAccessible(true);
$property->setValue($sut, $modifiedDate);
$property->setAccessible(false);
self::assertEquals($modifiedDate, $sut->getModifiedAt());
$meta = new TimesheetMeta(); $meta = new TimesheetMeta();
$meta->setName('blabla'); $meta->setName('blabla');
$meta->setValue('1234567890'); $meta->setValue('1234567890');
@@ -198,6 +209,9 @@ class TimesheetTest extends TestCase
$clone = clone $sut; $clone = clone $sut;
self::assertNotNull($sut->getModifiedAt());
self::assertNull($clone->getModifiedAt());
foreach ($sut->getMetaFields() as $metaField) { foreach ($sut->getMetaFields() as $metaField) {
$cloneMeta = $clone->getMetaField($metaField->getName()); $cloneMeta = $clone->getMetaField($metaField->getName());
self::assertEquals($cloneMeta->getValue(), $metaField->getValue()); self::assertEquals($cloneMeta->getValue(), $metaField->getValue());

View File

@@ -65,4 +65,9 @@ class DebugFormatter implements InvoiceFormatter
{ {
// does nothing // does nothing
} }
public function getFormattedAmount(float $amount): string
{
return (string) $amount;
}
} }

View File

@@ -67,6 +67,7 @@ class InvoiceItemDefaultHydratorTest extends TestCase
'entry.user_id', 'entry.user_id',
'entry.user_name', 'entry.user_name',
'entry.user_alias', 'entry.user_alias',
'entry.user_display',
'entry.user_title', 'entry.user_title',
'entry.activity', 'entry.activity',
'entry.activity_id', 'entry.activity_id',

View File

@@ -44,6 +44,7 @@ class InvoiceModelCustomerHydratorTest extends TestCase
'customer.contact', 'customer.contact',
'customer.company', 'customer.company',
'customer.vat', 'customer.vat',
'customer.vat_id',
'customer.country', 'customer.country',
'customer.number', 'customer.number',
'customer.homepage', 'customer.homepage',

View File

@@ -41,6 +41,7 @@ class InvoiceModelDefaultHydratorTest extends TestCase
'invoice.vat', 'invoice.vat',
'invoice.language', 'invoice.language',
'invoice.tax', 'invoice.tax',
'invoice.tax_hide',
'invoice.tax_nc', 'invoice.tax_nc',
'invoice.tax_plain', 'invoice.tax_plain',
'invoice.total_time', 'invoice.total_time',
@@ -76,6 +77,7 @@ class InvoiceModelDefaultHydratorTest extends TestCase
'query.end_month', 'query.end_month',
'query.end_month_number', 'query.end_month_number',
'query.end_year', 'query.end_year',
'user.see_others',
]; ];
$givenKeys = array_keys($model); $givenKeys = array_keys($model);

View File

@@ -33,6 +33,7 @@ class InvoiceModelUserHydratorTest extends TestCase
protected function assertModelStructure(array $model) protected function assertModelStructure(array $model)
{ {
$keys = [ $keys = [
'user.display',
'user.alias', 'user.alias',
'user.email', 'user.email',
'user.name', 'user.name',

View File

@@ -84,6 +84,7 @@ class DebugRendererTest extends TestCase
'invoice.currency', 'invoice.currency',
'invoice.currency_symbol', 'invoice.currency_symbol',
'invoice.vat', 'invoice.vat',
'invoice.tax_hide',
'invoice.tax', 'invoice.tax',
'invoice.language', 'invoice.language',
'invoice.tax_nc', 'invoice.tax_nc',
@@ -127,6 +128,7 @@ class DebugRendererTest extends TestCase
'customer.contact', 'customer.contact',
'customer.company', 'customer.company',
'customer.vat', 'customer.vat',
'customer.vat_id',
'customer.country', 'customer.country',
'customer.number', 'customer.number',
'customer.homepage', 'customer.homepage',
@@ -150,14 +152,23 @@ class DebugRendererTest extends TestCase
'activity.time_budget_open', 'activity.time_budget_open',
'activity.time_budget_open_plain', 'activity.time_budget_open_plain',
'user.alias', 'user.alias',
'user.display',
'user.email', 'user.email',
'user.name', 'user.name',
'user.title', 'user.title',
'user.see_others',
'user.meta.hello', 'user.meta.hello',
'user.meta.kitty', 'user.meta.kitty',
'testFromModelHydrator', 'testFromModelHydrator',
]; ];
if ($activityCounter === 1) {
$keys = array_merge($keys, [
'query.activity.comment',
'query.activity.name',
]);
}
if ($activityCounter > 1) { if ($activityCounter > 1) {
$keys = array_merge($keys, [ $keys = array_merge($keys, [
'activity.1.budget_open', 'activity.1.budget_open',
@@ -171,6 +182,14 @@ class DebugRendererTest extends TestCase
]); ]);
} }
if ($projectCounter === 1) {
$keys = array_merge($keys, [
'query.project.name',
'query.project.comment',
'query.project.order_number',
]);
}
if ($projectCounter > 0) { if ($projectCounter > 0) {
$keys = array_merge($keys, [ $keys = array_merge($keys, [
'project.id', 'project.id',
@@ -254,6 +273,7 @@ class DebugRendererTest extends TestCase
'entry.weekyear', 'entry.weekyear',
'entry.user_id', 'entry.user_id',
'entry.user_name', 'entry.user_name',
'entry.user_display',
'entry.user_alias', 'entry.user_alias',
'entry.user_title', 'entry.user_title',
'entry.activity', 'entry.activity',

View File

@@ -61,20 +61,27 @@ class TimesheetQueryTest extends BaseQueryTest
protected function assertUser(TimesheetQuery $sut): void protected function assertUser(TimesheetQuery $sut): void
{ {
self::assertNull($sut->getUser()); self::assertNull($sut->getUser());
self::assertFalse($sut->hasUsers());
$expected = new User(); $expected = new User();
$expected->setUserIdentifier('foo-bar'); $expected->setUserIdentifier('foo-bar');
$sut->setUser($expected); $sut->setUser($expected);
self::assertEquals($expected, $sut->getUser()); self::assertEquals($expected, $sut->getUser());
self::assertTrue($sut->hasUsers());
$sut->setUser(null);
self::assertFalse($sut->hasUsers());
} }
protected function assertUsers(TimesheetQuery $sut): void protected function assertUsers(TimesheetQuery $sut): void
{ {
self::assertEmpty($sut->getUsers()); self::assertEmpty($sut->getUsers());
self::assertFalse($sut->hasUsers());
$user = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock(); $user = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock();
$user->method('getId')->willReturn(1); $user->method('getId')->willReturn(1);
$sut->addUser($user); $sut->addUser($user);
self::assertTrue($sut->hasUsers());
$user = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock(); $user = $this->getMockBuilder(User::class)->disableOriginalConstructor()->getMock();
$user->method('getId')->willReturn(1); $user->method('getId')->willReturn(1);

View File

@@ -28,30 +28,6 @@ class TagRepositoryTest extends AbstractRepositoryTest
$this->importFixture($data); $this->importFixture($data);
} }
public function testFindIds()
{
$em = $this->getEntityManager();
/** @var TagRepository $repository */
$repository = $em->getRepository(Tag::class);
$result = $repository->findIdsByTagNameList('2018,Test');
$this->assertIsArray($result);
$this->assertNotEmpty($result);
$this->assertEquals(7, \count($result));
}
public function testFindNoIds()
{
$em = $this->getEntityManager();
/** @var TagRepository $repository */
$repository = $em->getRepository(Tag::class);
$result = $repository->findIdsByTagNameList('Simply');
$this->assertIsArray($result);
$this->assertEmpty($result);
$this->assertEquals(0, \count($result));
}
public function testFindAllTagNames() public function testFindAllTagNames()
{ {
$em = $this->getEntityManager(); $em = $this->getEntityManager();

View File

@@ -1277,276 +1277,6 @@ parameters:
count: 1 count: 1
path: API/TimesheetControllerTest.php path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testActiveAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testDeleteAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testDeleteActionForDifferentUser\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testDeleteActionForExportedRecordIsAllowedForAdmin\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testDeleteActionForExportedRecordIsNotAllowed\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testDeleteActionWithUnknownTimesheet\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testDeleteActionWithoutAuthorization\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testDuplicateAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testDuplicateThrowsNotFound\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testExportAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testExportNotAllowedForUser\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testExportThrowsNotFound\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testExportedFilter\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollection\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollectionForAllUser\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollectionForEmptyResult\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollectionForOtherUser\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollectionFull\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollectionWithQuery\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollectionWithQueryFailsWith404OnOutOfRangedPage\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollectionWithSingleParamsQuery\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetCollectionWithTags\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetEntity\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetEntityAccessAllowedForAdmin\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetEntityAccessDenied\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetEntityNotFound\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testGetRecentAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testInvalidPatchAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testIsSecure\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testMetaAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testMetaActionThrowsExceptionOnMissingMetafield\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testMetaActionThrowsExceptionOnMissingName\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testMetaActionThrowsExceptionOnMissingValue\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testMetaActionThrowsNotFound\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPatchAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPatchActionWithInvalidUser\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPatchActionWithUnknownTimesheet\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPostAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPostActionForDifferentUser\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPostActionWithFullExpandedResponse\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPostActionWithInvisibleActivity\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPostActionWithInvisibleProject\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPostActionWithNonBillableCustomer\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testPostActionWithNonBillableCustomerExplicit\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testRestartAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testRestartActionWithBegin\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testRestartActionWithCopyData\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testRestartNotAllowedForUser\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testRestartThrowsNotFound\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testStopAction\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testStopActionTriggersValidationOnLongRunning\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testStopNotAllowedForUser\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Method App\\\\Tests\\\\API\\\\TimesheetControllerTest\\:\\:testStopThrowsNotFound\\(\\) has no return type specified\\.$#"
count: 1
path: API/TimesheetControllerTest.php
-
message: "#^Parameter \\#1 \\$json of function json_decode expects string, string\\|false given\\.$#"
count: 32
path: API/TimesheetControllerTest.php
- -
message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#" message: "#^Parameter \\#1 \\$value of function count expects array\\|Countable, mixed given\\.$#"
count: 1 count: 1
@@ -8692,16 +8422,6 @@ parameters:
count: 1 count: 1
path: Repository/TagRepositoryTest.php path: Repository/TagRepositoryTest.php
-
message: "#^Method App\\\\Tests\\\\Repository\\\\TagRepositoryTest\\:\\:testFindIds\\(\\) has no return type specified\\.$#"
count: 1
path: Repository/TagRepositoryTest.php
-
message: "#^Method App\\\\Tests\\\\Repository\\\\TagRepositoryTest\\:\\:testFindNoIds\\(\\) has no return type specified\\.$#"
count: 1
path: Repository/TagRepositoryTest.php
- -
message: "#^Method App\\\\Tests\\\\Repository\\\\TagRepositoryTest\\:\\:testFindNoTagNames\\(\\) has no return type specified\\.$#" message: "#^Method App\\\\Tests\\\\Repository\\\\TagRepositoryTest\\:\\:testFindNoTagNames\\(\\) has no return type specified\\.$#"
count: 1 count: 1