improved invoices with new renderer (#306)

* added docx renderer and demo template
* added csv renderer and demo template
* added xlsx renderer and demo template
* added ods renderer and demo template
* added user calculator
* added invoice documentation
This commit is contained in:
Kevin Papst
2018-09-21 01:27:56 +02:00
committed by GitHub
parent b035fc9ebd
commit 53f82a808b
121 changed files with 4094 additions and 770 deletions

7
.gitignore vendored
View File

@@ -7,11 +7,16 @@
!bin/console !bin/console
/config/packages/local.yaml /config/packages/local.yaml
/var/data/kimai.sqlite templates/invoice/renderer/.~lock*
/var/data/*.sqlite
!/var/data/kimai_test.sqlite
/var/coverage/ /var/coverage/
/var/cache/* /var/cache/*
!var/cache/.gitkeep !var/cache/.gitkeep
/var/invoices/*
/var/log/* /var/log/*
!var/log/.gitkeep !var/log/.gitkeep

View File

@@ -12,11 +12,13 @@ build:
override: override:
- -
command: 'vendor/bin/phpunit --coverage-clover=clover.xml tests/' command: 'vendor/bin/phpunit --coverage-clover=clover.xml tests/'
stop_on_failure: true
coverage: coverage:
file: 'clover.xml' file: 'clover.xml'
format: 'clover' format: 'clover'
- -
command: 'bin/console kimai:phpcs --checkstyle=checkstyle' command: 'bin/console kimai:phpcs --checkstyle=checkstyle'
stop_on_failure: true
analysis: analysis:
file: 'checkstyle' file: 'checkstyle'
format: 'php-cs-checkstyle' format: 'php-cs-checkstyle'
@@ -33,3 +35,8 @@ filter:
dependency_paths: dependency_paths:
- 'node_modules/' - 'node_modules/'
- 'vendor/' - 'vendor/'
build_failure_conditions:
- 'project.metric("scrutinizer.quality", < 9.30)'
- 'project.metric("scrutinizer.test_coverage", < 0.70)'
- 'project.metric_change("scrutinizer.test_coverage", < 0.01)'

View File

@@ -55,10 +55,11 @@ git clone https://github.com/kevinpapst/kimai2.git
cd kimai2/ cd kimai2/
``` ```
Make sure the [file permissions are correct](https://symfony.com/doc/current/setup/file_permissions.html): Make sure the [file permissions are correct](https://symfony.com/doc/current/setup/file_permissions.html) and create your `.env` file:
```bash ```bash
chown -R www-data var/ chown -R www-data var/
chmod -R 777 var/ chmod -R 777 var/
cp .env.dist .env
``` ```
It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite. It's up to you which database server you want to use, Kimai v2 supports MySQL/MariaDB and SQLite.
@@ -66,7 +67,7 @@ Configure the database connection string in your the `.env` file:
``` ```
# adjust all settings in .env to your needs # adjust all settings in .env to your needs
APP_ENV=prod APP_ENV=prod
DATABASE_URL=mysql://db_user:db_password@127.0.0.1:3306/db_name DATABASE_URL=mysql://user:password@127.0.0.1:3306/database
``` ```
Now install all dependencies for Kimai 2: Now install all dependencies for Kimai 2:

View File

@@ -19,6 +19,15 @@ bin/console doctrine:migrations:migrate
There might be version specific tasks that need to be executed before or after these steps, please see below There might be version specific tasks that need to be executed before or after these steps, please see below
if your updated version is mentioned below. if your updated version is mentioned below.
## [0.5](https://github.com/kevinpapst/kimai2/releases/tag/0.5) (unreleased)
Some configuration nodes were removed, if you have one of them in your `local.yaml` you need to delete them:
- `kimai.invoice.calculator`
- `kimai.invoice.renderer`
- `kimai.invoice.number_generator`
The new config `kimai.invoice.documents` was introduced, holding a list of directories ([read more](https://github.com/kevinpapst/kimai2/blob/master/var/docs/invoices.md)).
## [0.4](https://github.com/kevinpapst/kimai2/releases/tag/0.4) (2018-09-01) ## [0.4](https://github.com/kevinpapst/kimai2/releases/tag/0.4) (2018-09-01)
In the time between 0.3 and 0.4 there was a release of composer that introduced a BC break, In the time between 0.3 and 0.4 there was a release of composer that introduced a BC break,

View File

@@ -31,8 +31,11 @@
td { td {
&.timesheet-description { &.timesheet-description {
& >ul { ul {
padding-left: 10px; padding-left: 15px;
}
p {
margin: 0;
} }
} }
} }

View File

@@ -5,6 +5,11 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
body.invoice_print
{
margin: 20px;
}
.invoice { .invoice {
margin: 0; margin: 0;
} }

View File

@@ -16,6 +16,8 @@
"kevinpapst/adminlte-bundle": "^2.1", "kevinpapst/adminlte-bundle": "^2.1",
"nelmio/api-doc-bundle": "^3.2", "nelmio/api-doc-bundle": "^3.2",
"ocramius/proxy-manager": "2.1.1", "ocramius/proxy-manager": "2.1.1",
"phpoffice/phpspreadsheet": "^1.4",
"phpoffice/phpword": "^0.15.0",
"sensio/framework-extra-bundle": "^5.2", "sensio/framework-extra-bundle": "^5.2",
"symfony/asset": "^4.0", "symfony/asset": "^4.0",
"symfony/console": "^4.0", "symfony/console": "^4.0",

430
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "26a66efe4ec32d6644deeaff090a303b", "content-hash": "a26c523a27230af927eae4b468daadda",
"packages": [ "packages": [
{ {
"name": "beberlei/DoctrineExtensions", "name": "beberlei/DoctrineExtensions",
@@ -1899,6 +1899,101 @@
"description": "Admin Theme based on the AdminLTE 2.4.8 for easy integration with Symfony 4", "description": "Admin Theme based on the AdminLTE 2.4.8 for easy integration with Symfony 4",
"time": "2018-09-02T17:21:38+00:00" "time": "2018-09-02T17:21:38+00:00"
}, },
{
"name": "markbaker/complex",
"version": "1.4.6",
"source": {
"type": "git",
"url": "https://github.com/MarkBaker/PHPComplex.git",
"reference": "a78d82ae4e682c3809fc3023d1b0ce654f6ab12b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/MarkBaker/PHPComplex/zipball/a78d82ae4e682c3809fc3023d1b0ce654f6ab12b",
"reference": "a78d82ae4e682c3809fc3023d1b0ce654f6ab12b",
"shasum": ""
},
"require": {
"php": "^5.6.0|^7.0.0"
},
"require-dev": {
"dealerdirect/phpcodesniffer-composer-installer": "^0.4.3",
"phpcompatibility/php-compatibility": "^8.0",
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "2.*",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^4.8.35|^5.4.0",
"sebastian/phpcpd": "2.*",
"squizlabs/php_codesniffer": "^3.3.0"
},
"type": "library",
"autoload": {
"psr-4": {
"Complex\\": "classes/src/"
},
"files": [
"classes/src/functions/abs.php",
"classes/src/functions/acos.php",
"classes/src/functions/acosh.php",
"classes/src/functions/acot.php",
"classes/src/functions/acoth.php",
"classes/src/functions/acsc.php",
"classes/src/functions/acsch.php",
"classes/src/functions/argument.php",
"classes/src/functions/asec.php",
"classes/src/functions/asech.php",
"classes/src/functions/asin.php",
"classes/src/functions/asinh.php",
"classes/src/functions/atan.php",
"classes/src/functions/atanh.php",
"classes/src/functions/conjugate.php",
"classes/src/functions/cos.php",
"classes/src/functions/cosh.php",
"classes/src/functions/cot.php",
"classes/src/functions/coth.php",
"classes/src/functions/csc.php",
"classes/src/functions/csch.php",
"classes/src/functions/exp.php",
"classes/src/functions/inverse.php",
"classes/src/functions/ln.php",
"classes/src/functions/log2.php",
"classes/src/functions/log10.php",
"classes/src/functions/negative.php",
"classes/src/functions/pow.php",
"classes/src/functions/rho.php",
"classes/src/functions/sec.php",
"classes/src/functions/sech.php",
"classes/src/functions/sin.php",
"classes/src/functions/sinh.php",
"classes/src/functions/sqrt.php",
"classes/src/functions/tan.php",
"classes/src/functions/tanh.php",
"classes/src/functions/theta.php",
"classes/src/operations/add.php",
"classes/src/operations/subtract.php",
"classes/src/operations/multiply.php",
"classes/src/operations/divideby.php",
"classes/src/operations/divideinto.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Mark Baker",
"email": "mark@lange.demon.co.uk"
}
],
"description": "PHP Class for working with complex numbers",
"homepage": "https://github.com/MarkBaker/PHPComplex",
"keywords": [
"complex",
"mathematics"
],
"time": "2018-07-31T08:38:40+00:00"
},
{ {
"name": "monolog/monolog", "name": "monolog/monolog",
"version": "1.23.0", "version": "1.23.0",
@@ -2295,6 +2390,43 @@
], ],
"time": "2018-07-04T16:31:37+00:00" "time": "2018-07-04T16:31:37+00:00"
}, },
{
"name": "pclzip/pclzip",
"version": "2.8.2",
"source": {
"type": "git",
"url": "https://github.com/ivanlanin/pclzip.git",
"reference": "19dd1de9d3f5fc4d7d70175b4c344dee329f45fd"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/ivanlanin/pclzip/zipball/19dd1de9d3f5fc4d7d70175b4c344dee329f45fd",
"reference": "19dd1de9d3f5fc4d7d70175b4c344dee329f45fd",
"shasum": ""
},
"type": "library",
"autoload": {
"classmap": [
"pclzip.lib.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1"
],
"authors": [
{
"name": "Vincent Blavet"
}
],
"description": "A PHP library that offers compression and extraction functions for Zip formatted archives",
"homepage": "http://www.phpconcept.net/pclzip",
"keywords": [
"php",
"zip"
],
"time": "2014-06-05T11:42:24+00:00"
},
{ {
"name": "phpcollection/phpcollection", "name": "phpcollection/phpcollection",
"version": "0.5.0", "version": "0.5.0",
@@ -2495,6 +2627,257 @@
], ],
"time": "2017-07-14T14:27:02+00:00" "time": "2017-07-14T14:27:02+00:00"
}, },
{
"name": "phpoffice/common",
"version": "0.2.9",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/Common.git",
"reference": "edb5d32b1e3400a35a5c91e2539ed6f6ce925e4d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/Common/zipball/edb5d32b1e3400a35a5c91e2539ed6f6ce925e4d",
"reference": "edb5d32b1e3400a35a5c91e2539ed6f6ce925e4d",
"shasum": ""
},
"require": {
"pclzip/pclzip": "^2.8",
"php": ">=5.3.0"
},
"require-dev": {
"phpdocumentor/phpdocumentor": "2.*",
"phploc/phploc": "2.*",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^4.8.36 || ^7.0",
"sebastian/phpcpd": "2.*",
"squizlabs/php_codesniffer": "2.*"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\Common\\": "src/Common/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL"
],
"authors": [
{
"name": "Mark Baker"
},
{
"name": "Franck Lefevre",
"homepage": "http://rootslabs.net"
}
],
"description": "PHPOffice Common",
"homepage": "http://phpoffice.github.io",
"keywords": [
"common",
"component",
"office",
"php"
],
"time": "2018-07-13T14:12:34+00:00"
},
{
"name": "phpoffice/phpspreadsheet",
"version": "1.4.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
"reference": "125f462a718956f37d81305ca0df4f17cef0f3b9"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/125f462a718956f37d81305ca0df4f17cef0f3b9",
"reference": "125f462a718956f37d81305ca0df4f17cef0f3b9",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-dom": "*",
"ext-gd": "*",
"ext-iconv": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-simplexml": "*",
"ext-xml": "*",
"ext-xmlreader": "*",
"ext-xmlwriter": "*",
"ext-zip": "*",
"ext-zlib": "*",
"markbaker/complex": "^1.4.1",
"php": "^5.6|^7.0",
"psr/simple-cache": "^1.0"
},
"require-dev": {
"dompdf/dompdf": "^0.8.0",
"friendsofphp/php-cs-fixer": "@stable",
"jpgraph/jpgraph": "^4.0",
"mpdf/mpdf": "^7.0.0",
"phpunit/phpunit": "^5.7",
"squizlabs/php_codesniffer": "^3.3",
"tecnickcom/tcpdf": "^6.2"
},
"suggest": {
"dompdf/dompdf": "Option for rendering PDF with PDF Writer",
"jpgraph/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
"mpdf/mpdf": "Option for rendering PDF with PDF Writer",
"tecnick.com/tcpdf": "Option for rendering PDF with PDF Writer"
},
"type": "library",
"autoload": {
"psr-4": {
"PhpOffice\\PhpSpreadsheet\\": "src/PhpSpreadsheet"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-2.1-or-later"
],
"authors": [
{
"name": "Maarten Balliauw",
"homepage": "http://blog.maartenballiauw.be"
},
{
"name": "Erik Tilt"
},
{
"name": "Franck Lefevre",
"homepage": "http://rootslabs.net"
},
{
"name": "Mark Baker",
"homepage": "http://markbakeruk.net"
}
],
"description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
"homepage": "https://github.com/PHPOffice/PhpSpreadsheet",
"keywords": [
"OpenXML",
"excel",
"gnumeric",
"ods",
"php",
"spreadsheet",
"xls",
"xlsx"
],
"time": "2018-08-06T02:58:06+00:00"
},
{
"name": "phpoffice/phpword",
"version": "0.15.0",
"source": {
"type": "git",
"url": "https://github.com/PHPOffice/PHPWord.git",
"reference": "dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/PHPOffice/PHPWord/zipball/dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f",
"reference": "dfa2f36cad2b632b7ab1c56473e4f5db9a7caf7f",
"shasum": ""
},
"require": {
"ext-xml": "*",
"php": "^5.3.3 || ^7.0",
"phpoffice/common": "^0.2.9",
"zendframework/zend-escaper": "^2.2"
},
"require-dev": {
"dompdf/dompdf": "0.8.*",
"ext-gd": "*",
"ext-zip": "*",
"friendsofphp/php-cs-fixer": "^2.2",
"mpdf/mpdf": "5.7.4 || 6.* || 7.*",
"php-coveralls/php-coveralls": "1.1.0 || ^2.0",
"phploc/phploc": "2.* || 3.* || 4.*",
"phpmd/phpmd": "2.*",
"phpunit/phpunit": "^4.8.36 || ^7.0",
"squizlabs/php_codesniffer": "^2.9",
"tecnickcom/tcpdf": "6.*"
},
"suggest": {
"dompdf/dompdf": "Allows writing PDF",
"ext-gd2": "Allows adding images",
"ext-xmlwriter": "Allows writing OOXML and ODF",
"ext-xsl": "Allows applying XSL style sheet to headers, to main document part, and to footers of an OOXML template",
"ext-zip": "Allows writing OOXML and ODF"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-develop": "0.16-dev"
}
},
"autoload": {
"psr-4": {
"PhpOffice\\PhpWord\\": "src/PhpWord"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0"
],
"authors": [
{
"name": "Mark Baker"
},
{
"name": "Gabriel Bull",
"email": "me@gabrielbull.com",
"homepage": "http://gabrielbull.com/"
},
{
"name": "Ivan Lanin",
"homepage": "http://ivan.lanin.org"
},
{
"name": "Roman Syroeshko",
"homepage": "http://ru.linkedin.com/pub/roman-syroeshko/34/a53/994/"
},
{
"name": "Franck Lefevre",
"homepage": "https://rootslabs.net/blog/"
},
{
"name": "Antoine de Troostembergh"
}
],
"description": "PHPWord - A pure PHP library for reading and writing word processing documents (OOXML, ODF, RTF, HTML, PDF)",
"homepage": "http://phpoffice.github.io",
"keywords": [
"ISO IEC 29500",
"OOXML",
"Office Open XML",
"OpenDocument",
"OpenXML",
"PhpOffice",
"PhpWord",
"Rich Text Format",
"WordprocessingML",
"doc",
"docx",
"html",
"odf",
"odt",
"office",
"pdf",
"php",
"reader",
"rtf",
"template",
"template processor",
"word",
"writer"
],
"time": "2018-07-14T16:59:43+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.5.0", "version": "1.5.0",
@@ -6216,6 +6599,51 @@
], ],
"time": "2018-08-13T20:36:59+00:00" "time": "2018-08-13T20:36:59+00:00"
}, },
{
"name": "zendframework/zend-escaper",
"version": "2.6.0",
"source": {
"type": "git",
"url": "https://github.com/zendframework/zend-escaper.git",
"reference": "31d8aafae982f9568287cb4dce987e6aff8fd074"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/zendframework/zend-escaper/zipball/31d8aafae982f9568287cb4dce987e6aff8fd074",
"reference": "31d8aafae982f9568287cb4dce987e6aff8fd074",
"shasum": ""
},
"require": {
"php": "^5.6 || ^7.0"
},
"require-dev": {
"phpunit/phpunit": "^5.7.27 || ^6.5.8 || ^7.1.2",
"zendframework/zend-coding-standard": "~1.0.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "2.6.x-dev",
"dev-develop": "2.7.x-dev"
}
},
"autoload": {
"psr-4": {
"Zend\\Escaper\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"description": "Securely and safely escape HTML, HTML attributes, JavaScript, CSS, and URLs",
"keywords": [
"ZendFramework",
"escaper",
"zf"
],
"time": "2018-04-25T15:48:53+00:00"
},
{ {
"name": "zendframework/zend-eventmanager", "name": "zendframework/zend-eventmanager",
"version": "3.2.1", "version": "3.2.1",

View File

@@ -33,17 +33,12 @@ kimai:
# days: ['saturday','sunday'] # days: ['saturday','sunday']
# factor: 1.5 # factor: 1.5
# All configs related to the invoice administration # Invoice management
invoice: #invoice:
renderer: # # all files in these directories will be used as invoice documents (if supported by a renderer)
default: 'App\Controller\InvoicePrintController::printInvoice' # documents:
timesheet: 'App\Controller\InvoicePrintController::printTimesheet' # - 'var/invoices/'
freelancer: 'App\Controller\InvoicePrintController::printFreelancer' # - 'templates/invoice/renderer/'
calculator:
default: 'App\Invoice\DefaultCalculator'
short: 'App\Invoice\ShortInvoiceCalculator'
number_generator:
default: 'App\Invoice\DateNumberGenerator'
# Language specific settings, like the date formats # Language specific settings, like the date formats
languages: languages:

View File

@@ -32,10 +32,6 @@ services:
# APPLICATION CORE # APPLICATION CORE
# ================================================================================ # ================================================================================
App\Invoice\ServiceInvoice:
class: App\Invoice\ServiceInvoice
arguments: ['%kimai.invoice%']
App\EventSubscriber\RedirectToLocaleSubscriber: App\EventSubscriber\RedirectToLocaleSubscriber:
class: App\EventSubscriber\RedirectToLocaleSubscriber class: App\EventSubscriber\RedirectToLocaleSubscriber
arguments: ['@router', '%app_locales%', '%locale%'] arguments: ['@router', '%app_locales%', '%locale%']
@@ -150,3 +146,8 @@ services:
class: Doctrine\ORM\EntityRepository class: Doctrine\ORM\EntityRepository
factory: ['@doctrine.orm.entity_manager', getRepository] factory: ['@doctrine.orm.entity_manager', getRepository]
arguments: ['App\Entity\InvoiceTemplate'] arguments: ['App\Entity\InvoiceTemplate']
App\Repository\InvoiceDocumentRepository:
class: App\Repository\InvoiceDocumentRepository
arguments: ['%kimai.invoice.documents%']

File diff suppressed because one or more lines are too long

View File

@@ -1,6 +1,6 @@
{ {
"build/app.js": "/build/app.js?ba9725f5267413a18701", "build/app.js": "/build/app.js?ba9725f5267413a18701",
"build/app.css": "/build/app.css?68655639f068cc8b6eadb4170698a2c5", "build/app.css": "/build/app.css?b30340309e980dc272f3ca6d1ff64aa2",
"build/fonts/fa-regular-400.woff": "/build/fonts/fa-regular-400.woff?d9e29124", "build/fonts/fa-regular-400.woff": "/build/fonts/fa-regular-400.woff?d9e29124",
"build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd", "build/images/blue@2x.png": "/build/images/blue@2x.png?2694acfd",
"build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29", "build/fonts/fa-solid-900.woff2": "/build/fonts/fa-solid-900.woff2?e8a92a29",

View File

@@ -312,18 +312,28 @@ class KimaiImporterCommand extends Command
* This is checked against the Kimai version and database revision. * This is checked against the Kimai version and database revision.
* *
* @param SymfonyStyle $io * @param SymfonyStyle $io
* @param $requiredVersion * @param string $requiredVersion
* @param $requiredRevision * @param string $requiredRevision
* @return bool * @return bool
* @throws \Doctrine\DBAL\DBALException * @throws \Doctrine\DBAL\DBALException
*/ */
protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision) protected function checkDatabaseVersion(SymfonyStyle $io, $requiredVersion, $requiredRevision)
{ {
$versionQuery = 'SELECT `value` from ' . $this->dbPrefix . 'configuration WHERE `option` = "version"'; $version = $this->connection->createQueryBuilder()
$revisionQuery = 'SELECT `value` from ' . $this->dbPrefix . 'configuration WHERE `option` = "revision"'; ->select('value')
->from($this->connection->quoteIdentifier($this->dbPrefix . 'configuration'))
->where('option = :option')
->setParameter(':option', 'version')
->execute()
->fetchColumn();
$version = $this->getImportConnection()->query($versionQuery)->fetchColumn(); $revision = $this->connection->createQueryBuilder()
$revision = $this->getImportConnection()->query($revisionQuery)->fetchColumn(); ->select('value')
->from($this->connection->quoteIdentifier($this->dbPrefix . 'configuration'))
->where('option = :option')
->setParameter(':option', 'revision')
->execute()
->fetchColumn();
if (1 == version_compare($requiredVersion, $version)) { if (1 == version_compare($requiredVersion, $version)) {
$io->error( $io->error(
@@ -378,21 +388,16 @@ class KimaiImporterCommand extends Command
} }
/** /**
* @param $table * @param string $table
* @return array * @return array
* @throws \Doctrine\DBAL\DBALException
*/ */
protected function fetchAllFromImport($table) protected function fetchAllFromImport($table)
{ {
return $this->getImportConnection()->query('SELECT * from ' . $this->dbPrefix . $table)->fetchAll(); return $this->connection->createQueryBuilder()
} ->select('*')
->from($this->connection->quoteIdentifier($this->dbPrefix . $table))
/** ->execute()
* @return \Doctrine\DBAL\Connection ->fetchAll();
*/
protected function getImportConnection()
{
return $this->connection;
} }
/** /**
@@ -792,7 +797,6 @@ class KimaiImporterCommand extends Command
} }
} }
if (!$this->validateImport($io, $activity)) { if (!$this->validateImport($io, $activity)) {
throw new \Exception('Failed to validate activity: ' . $activity->getName()); throw new \Exception('Failed to validate activity: ' . $activity->getName());
} }

View File

@@ -76,7 +76,7 @@ class InvoiceController extends AbstractController
} }
/** /**
* @Route(path="/", name="invoice", methods={"GET", "POST"}) * @Route(path="/", name="invoice", methods={"GET"})
* *
* @param Request $request * @param Request $request
* @return \Symfony\Component\HttpFoundation\Response * @return \Symfony\Component\HttpFoundation\Response
@@ -102,14 +102,53 @@ class InvoiceController extends AbstractController
$model = $this->prepareModel($query, $entries); $model = $this->prepareModel($query, $entries);
$action = null; return $this->render('invoice/index.html.twig', [
if ($query->getTemplate() !== null) { 'model' => $model,
$action = $this->service->getRendererActionByName($query->getTemplate()->getRenderer()); 'form' => $form->createView(),
]);
} }
/**
* @Route(path="/print", name="invoice_print", methods={"GET"})
*
* @param Request $request
* @return \Symfony\Component\HttpFoundation\Response
* @throws \Exception
*/
public function printAction(Request $request)
{
if (!$this->invoiceRepository->hasTemplate()) {
return $this->redirectToRoute('admin_invoice_template_create');
}
$query = $this->getDefaultQuery();
$form = $this->getToolbarForm($query);
$form->handleRequest($request);
if (!$form->isSubmitted() || !$form->isValid()) {
return $this->redirectToRoute('invoice');
}
/** @var InvoiceQuery $query */
$query = $form->getData();
$entries = $this->getEntries($query);
$model = $this->prepareModel($query, $entries);
$document = $this->service->getDocumentByName($model->getTemplate()->getRenderer());
if (null === $document) {
throw new \Exception('Unknown invoice document: ' . $model->getTemplate()->getRenderer());
}
foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
return $renderer->render($document, $model);
}
}
$this->flashError('Cannot render invoice: ' . $model->getTemplate()->getRenderer() . ' (' . $document->getName() . ')');
return $this->render('invoice/index.html.twig', [ return $this->render('invoice/index.html.twig', [
'model' => $model, 'model' => $model,
'action' => $action,
'form' => $form->createView(), 'form' => $form->createView(),
]); ]);
} }
@@ -142,11 +181,12 @@ class InvoiceController extends AbstractController
protected function prepareModel(InvoiceQuery $query, array $entries) protected function prepareModel(InvoiceQuery $query, array $entries)
{ {
$model = new InvoiceModel(); $model = new InvoiceModel();
$model->setQuery($query); $model
$model->setEntries($entries); ->setQuery($query)
$model->setCustomer($query->getCustomer()); ->setEntries($entries)
->setCustomer($query->getCustomer())
;
$action = null;
if ($query->getTemplate() !== null) { if ($query->getTemplate() !== null) {
$generator = $this->service->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator()); $generator = $this->service->getNumberGeneratorByName($query->getTemplate()->getNumberGenerator());
if (null === $generator) { if (null === $generator) {
@@ -278,6 +318,9 @@ class InvoiceController extends AbstractController
return $this->createForm(InvoiceToolbarForm::class, $query, [ return $this->createForm(InvoiceToolbarForm::class, $query, [
'action' => $this->generateUrl('invoice', []), 'action' => $this->generateUrl('invoice', []),
'method' => 'GET', 'method' => 'GET',
'attr' => [
'id' => 'invoice-print-form'
]
]); ]);
} }

View File

@@ -1,54 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Controller;
use App\Model\InvoiceModel;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
/**
* Controller used to print invoices.
*
* @Security("is_granted('ROLE_TEAMLEAD')")
*/
class InvoicePrintController extends AbstractController
{
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function printInvoice(InvoiceModel $model)
{
return $this->render('invoice/renderer/invoice.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function printTimesheet(InvoiceModel $model)
{
return $this->render('invoice/renderer/timesheet.html.twig', [
'model' => $model,
]);
}
/**
* @param InvoiceModel $model
* @return \Symfony\Component\HttpFoundation\Response
*/
public function printFreelancer(InvoiceModel $model)
{
return $this->render('invoice/renderer/freelancer.html.twig', [
'model' => $model,
]);
}
}

View File

@@ -13,6 +13,7 @@ use App\Entity\InvoiceTemplate;
use Doctrine\Bundle\FixturesBundle\Fixture; use Doctrine\Bundle\FixturesBundle\Fixture;
use Doctrine\Common\Persistence\ObjectManager; use Doctrine\Common\Persistence\ObjectManager;
use Faker\Factory; use Faker\Factory;
use Faker\Generator;
/** /**
* Defines the sample data to load in the database when running the unit and * Defines the sample data to load in the database when running the unit and
@@ -28,64 +29,47 @@ class InvoiceFixtures extends Fixture
*/ */
public function load(ObjectManager $manager) public function load(ObjectManager $manager)
{ {
$this->loadInvoiceTemplate($manager); foreach ($this->getInvoiceConfigs() as $invoiceConfig) {
$this->loadFreelancerTemplate($manager);
$this->loadTimesheetTemplate($manager);
}
/**
* @param ObjectManager $manager
*/
private function loadInvoiceTemplate(ObjectManager $manager)
{
$faker = Factory::create();
$template = new InvoiceTemplate(); $template = new InvoiceTemplate();
// name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
$template $template
->setName('Invoice') ->setName($invoiceConfig[0])
->setTitle('Your company name') ->setTitle($invoiceConfig[1])
->setCompany($faker->company) ->setRenderer($invoiceConfig[2])
->setVat(19) ->setCalculator($invoiceConfig[3])
->setDueDays(30) ->setNumberGenerator($invoiceConfig[4])
->setRenderer('default') ->setCompany($invoiceConfig[5])
->setCalculator('default') ->setVat($invoiceConfig[6])
->setNumberGenerator('default') ->setDueDays($invoiceConfig[7])
->setPaymentTerms( ->setAddress($invoiceConfig[8])
'I would like to thank you for your confidence and will gladly be there for you in the future.' . ->setPaymentTerms($invoiceConfig[9])
PHP_EOL .
'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
'as reference.'
)
->setAddress(
$faker->streetAddress . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode . PHP_EOL .
'Phone: ' . $faker->phoneNumber . PHP_EOL .
'Email: ' . $faker->safeEmail
)
; ;
$manager->persist($template); $manager->persist($template);
$manager->flush(); $manager->flush();
} }
}
/** private function getInvoiceConfigs()
* @param ObjectManager $manager
*/
private function loadFreelancerTemplate(ObjectManager $manager)
{ {
$faker = Factory::create(); $faker = Factory::create();
$template = new InvoiceTemplate(); $paymentTerms =
$template 'I would like to thank you for your confidence and will gladly be there for you in the future.' .
->setName('Freelancer') PHP_EOL .
->setTitle('Rechnung') 'Please transfer the total amount within 14 days to the given account and use the invoice number ' .
->setCompany($faker->company) 'as reference.'
->setVat(19) ;
->setDueDays(14)
->setRenderer('freelancer') $address =
->setCalculator('short') $faker->streetAddress . PHP_EOL .
->setNumberGenerator('default') $faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode . PHP_EOL .
->setPaymentTerms( 'Phone: ' . $faker->phoneNumber . PHP_EOL .
'Email: ' . $faker->safeEmail
;
$paymentTerms_de =
'Bitte überweisen Sie den Gesamtbetrag innerhalb von 14 Tagen nach Erhalt der Rechnung auf das unten genannte Konto. Verwenden Sie bitte als Betreff Ihrer Überweisung die Rechnungsnummer.' . 'Bitte überweisen Sie den Gesamtbetrag innerhalb von 14 Tagen nach Erhalt der Rechnung auf das unten genannte Konto. Verwenden Sie bitte als Betreff Ihrer Überweisung die Rechnungsnummer.' .
PHP_EOL . PHP_EOL .
PHP_EOL . PHP_EOL .
@@ -95,40 +79,42 @@ class InvoiceFixtures extends Fixture
'Mit freundlichen Grüßen,' . 'Mit freundlichen Grüßen,' .
PHP_EOL . PHP_EOL .
'Max Müller' 'Max Müller'
)
->setAddress(
$faker->name . ' - ' . $faker->streetAddress . '-' . $faker->postcode . ' ' . $faker->city
)
; ;
$manager->persist($template); // name, title, renderer, calculator, numberGenerator, company, vat, dueDays, address, paymentTerms
$manager->flush(); return [
['Invoice (HTML)', 'Company name', 'default', 'default', 'default', $faker->company, 19, 30, $address, $paymentTerms],
['Freelancer (HTML, short)', 'Invoice', 'freelancer', 'short', 'default', $faker->company, 19, 14, $this->generateAddress($faker), $paymentTerms_de],
['Timesheet (HTML)', 'Timesheet', 'timesheet', 'default', 'default', $faker->company, 19, 7, $this->generateAddress($faker), ''],
['Company invoice (DOCX)', 'Invoice', 'company', 'default', 'default', 'Kimai Inc.', 19, 14, $this->generateAddress($faker, true), $this->generatePaymentTerms($faker)],
['Export (CSV, user-group)', 'User-grouped', 'export', 'user', 'default', $faker->company, 7, 28, '', ''],
['Export (ODS)', 'Spreadsheet', 'open-spreadsheet', 'default', 'default', $faker->company, 19, 14, '', ''],
['Export (XLSX, user-group)', 'Spreadsheet', 'spreadsheet', 'user', 'default', $faker->company, 13, 10, '', ''],
];
} }
/** protected function generatePaymentTerms(Generator $faker)
* @param ObjectManager $manager
*/
private function loadTimesheetTemplate(ObjectManager $manager)
{ {
$faker = Factory::create(); return
'Acme Bank' . PHP_EOL .
$template = new InvoiceTemplate(); 'Account: ' . $faker->bankAccountNumber . PHP_EOL .
$template 'IBAN: ' . $faker->iban('DE')
->setName('Timesheet')
->setTitle('Stundenzettel')
->setCompany($faker->company)
->setVat(19)
->setDueDays(14)
->setRenderer('timesheet')
->setCalculator('default')
->setNumberGenerator('default')
->setPaymentTerms('')
->setAddress(
$faker->name . ' - ' . $faker->streetAddress . '-' . $faker->postcode . ' ' . $faker->city
)
; ;
}
$manager->persist($template); protected function generateAddress(Generator $faker, $lineBreaks = false)
$manager->flush(); {
if (!$lineBreaks) {
return
$faker->name . ' - ' .
$faker->streetAddress . '-' .
$faker->postcode . ' ' . $faker->city;
}
return
'Kimai Inc.' . PHP_EOL .
$faker->streetAddress . PHP_EOL .
$faker->city . ', ' . $faker->stateAbbr . ' ' . $faker->postcode
;
} }
} }

View File

@@ -10,7 +10,6 @@
namespace App\DependencyInjection; namespace App\DependencyInjection;
use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException;
use Symfony\Component\Config\Definition\Exception\InvalidDefinitionException;
use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface;
use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\HttpKernel\DependencyInjection\Extension;
@@ -39,10 +38,10 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.theme', $config['theme']); $container->setParameter('kimai.theme', $config['theme']);
$container->setParameter('kimai.dashboard', $config['dashboard']); $container->setParameter('kimai.dashboard', $config['dashboard']);
$container->setParameter('kimai.widgets', $config['widgets']); $container->setParameter('kimai.widgets', $config['widgets']);
$container->setParameter('kimai.invoice.documents', $config['invoice']['documents']);
$this->createUserParameter($config, $container); $this->createUserParameter($config, $container);
$this->createTimesheetParameter($config, $container); $this->createTimesheetParameter($config, $container);
$this->createInvoiceParameter($config, $container);
} }
/** /**
@@ -78,25 +77,6 @@ class AppExtension extends Extension implements PrependExtensionInterface
$container->setParameter('kimai.timesheet.markdown', $config['timesheet']['markdown_content']); $container->setParameter('kimai.timesheet.markdown', $config['timesheet']['markdown_content']);
} }
/**
* @param array $config
* @param ContainerBuilder $container
*/
private function createInvoiceParameter(array $config, ContainerBuilder $container)
{
$keys = ['renderer', 'calculator', 'number_generator'];
foreach ($keys as $key) {
if (!isset($config['invoice'][$key]) || 0 === count($config['invoice'][$key])) {
throw new InvalidDefinitionException('Missing invoice configuration: kimai.invoice.' . $key);
}
$container->setParameter('kimai.invoice.' . $key, $config['invoice'][$key]);
}
$container->setParameter('kimai.invoice', $config['invoice']);
}
/** /**
* @param ContainerBuilder $container * @param ContainerBuilder $container
*/ */

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\DependencyInjection\Compiler;
use App\Invoice\ServiceInvoice;
use App\Kernel;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;
/**
* Dynamically adds all dependencies to the InvoiceService.
*/
class InvoiceServiceCompilerPass implements CompilerPassInterface
{
/**
* @param ContainerBuilder $container
* @throws \Exception
*/
public function process(ContainerBuilder $container)
{
// always first check if the primary service is defined
if (!$container->has(ServiceInvoice::class)) {
return;
}
$definition = $container->findDefinition(ServiceInvoice::class);
$taggedRenderer = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_RENDERER);
foreach ($taggedRenderer as $id => $tags) {
$definition->addMethodCall('addRenderer', [new Reference($id)]);
}
$taggedGenerator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_NUMBER_GENERATOR);
foreach ($taggedGenerator as $id => $tags) {
$definition->addMethodCall('addNumberGenerator', [new Reference($id)]);
}
$taggedCalculator = $container->findTaggedServiceIds(Kernel::TAG_INVOICE_CALCULATOR);
foreach ($taggedCalculator as $id => $tags) {
$definition->addMethodCall('addCalculator', [new Reference($id)]);
}
}
}

View File

@@ -29,5 +29,17 @@ class TwigContextCompilerPass implements CompilerPassInterface
$twig->addMethodCall('addGlobal', ['kimai_context', $theme]); $twig->addMethodCall('addGlobal', ['kimai_context', $theme]);
$twig->addMethodCall('addGlobal', ['duration_only', $durationOnly]); $twig->addMethodCall('addGlobal', ['duration_only', $durationOnly]);
if ($container->hasDefinition('twig.loader.native_filesystem')) {
$definition = $container->getDefinition('twig.loader.native_filesystem');
$path = dirname(dirname(dirname(__DIR__))) . DIRECTORY_SEPARATOR;
foreach ($container->getParameter('kimai.invoice.documents') as $invoicePath) {
if (!is_dir($path . $invoicePath)) {
continue;
}
$definition->addMethodCall('addPath', [$path . $invoicePath, 'invoice']);
}
}
} }
} }

View File

@@ -99,6 +99,12 @@ class Configuration implements ConfigurationInterface
->floatNode('factor') ->floatNode('factor')
->isRequired() ->isRequired()
->defaultValue(1) ->defaultValue(1)
->validate()
->ifTrue(function ($value) {
return $value <= 0;
})
->thenInvalid('A rate factor smaller or equals 0 is not allowed')
->end()
->end() ->end()
->end() ->end()
->end() ->end()
@@ -116,32 +122,15 @@ class Configuration implements ConfigurationInterface
$node = $builder->root('invoice'); $node = $builder->root('invoice');
$node $node
->addDefaultsIfNotSet()
->children() ->children()
->arrayNode('renderer') ->arrayNode('documents')
->requiresAtLeastOneElement() ->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired() ->isRequired()
->prototype('scalar')->end() ->scalarPrototype()->end()
->defaultValue([ ->defaultValue([
'default' => 'App\Controller\InvoiceController::invoiceAction', 'var/invoices/',
]) 'templates/invoice/renderer/'
->end()
->arrayNode('calculator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Invoice\DefaultCalculator',
])
->end()
->arrayNode('number_generator')
->requiresAtLeastOneElement()
->useAttributeAsKey('key')
->isRequired()
->prototype('scalar')->end()
->defaultValue([
'default' => 'App\Invoice\DateNumberGenerator',
]) ])
->end() ->end()
->end() ->end()

View File

@@ -0,0 +1,52 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Entity;
class InvoiceDocument
{
/**
* @var \SplFileInfo
*/
private $file;
/**
* @param \SplFileInfo $file
*/
public function __construct(\SplFileInfo $file)
{
$this->file = $file;
}
/**
* @return string
*/
public function getId()
{
$file = $this->file->getFilename();
return substr($file, 0, strpos($file, '.'));
}
/**
* @return string
*/
public function getName()
{
return basename($this->getFilename());
}
/**
* @return string
*/
public function getFilename(): string
{
return $this->file->getRealPath();
}
}

View File

@@ -153,6 +153,8 @@ class Timesheet
public function setEnd($end) public function setEnd($end)
{ {
$this->end = $end; $this->end = $end;
// FIXME test and then remove it, this should not be neccessary
if (null === $end) { if (null === $end) {
$this->duration = 0; $this->duration = 0;
} }

View File

@@ -39,16 +39,17 @@ class InvoiceCalculatorType extends AbstractType
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver)
{ {
$renderer = []; $renderer = [];
foreach ($this->service->getCalculator() as $name => $class) { foreach ($this->service->getCalculator() as $calculator) {
$renderer[$name] = $name; $renderer[$calculator->getId()] = $calculator->getId();
} }
$resolver->setDefaults([ $resolver->setDefaults([
'label' => 'label.invoice_calculator', 'label' => 'label.invoice_calculator',
'choices' => $renderer, 'choices' => $renderer,
'choice_label' => function ($renderer) { 'choice_label' => function ($renderer) {
return 'invoice_calculator.' . $renderer; return $renderer;
} },
'translation_domain' => 'invoice-calculator',
]); ]);
} }

View File

@@ -39,16 +39,17 @@ class InvoiceNumberGeneratorType extends AbstractType
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver)
{ {
$renderer = []; $renderer = [];
foreach ($this->service->getNumberGenerator() as $name => $class) { foreach ($this->service->getNumberGenerator() as $generator) {
$renderer[$name] = $name; $renderer[$generator->getId()] = $generator->getId();
} }
$resolver->setDefaults([ $resolver->setDefaults([
'label' => 'label.invoice_number_generator', 'label' => 'label.invoice_number_generator',
'choices' => $renderer, 'choices' => $renderer,
'choice_label' => function ($renderer) { 'choice_label' => function ($renderer) {
return 'invoice_number_generator.' . $renderer; return $renderer;
} },
'translation_domain' => 'invoice-numbergenerator',
]); ]);
} }

View File

@@ -38,20 +38,46 @@ class InvoiceRendererType extends AbstractType
*/ */
public function configureOptions(OptionsResolver $resolver) public function configureOptions(OptionsResolver $resolver)
{ {
$renderer = []; $documents = [];
foreach ($this->service->getRenderer() as $name => $action) { foreach ($this->service->getDocuments() as $document) {
$renderer[$name] = $name; foreach ($this->service->getRenderer() as $renderer) {
if ($renderer->supports($document)) {
$documents[$document->getId()] = $document->getName();
}
}
} }
$resolver->setDefaults([ $resolver->setDefaults([
'label' => 'label.invoice_renderer', 'label' => 'label.invoice_renderer',
'choices' => $renderer, 'choices' => array_flip($documents),
'choice_label' => function ($renderer) { 'group_by' => [$this, 'getGroupBy'],
return 'invoice_renderer.' . $renderer; 'choice_label' => function ($choiceValue, $key, $value) {
} return $choiceValue;
},
'translation_domain' => 'invoice-renderer',
'docu_chapter' => 'invoices',
]); ]);
} }
/**
* @param string $value
* @param string $label
* @param string $index
* @return string
*/
public function getGroupBy($value, $label, $index)
{
$renderer = $label;
return ucfirst(
substr(
$renderer,
1 + strrpos($renderer, '.'),
strrpos($renderer, '.')
)
);
}
/** /**
* {@inheritdoc} * {@inheritdoc}
*/ */

View File

@@ -7,18 +7,12 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace App\Invoice; namespace App\Invoice\Calculator;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Model\InvoiceModel; use App\Model\InvoiceModel;
/** abstract class AbstractCalculator
* Class DefaultCalculator works on all given entries using:
* - the customers currency
* - the invoice template vat rate
* - the entries rate
*/
class DefaultCalculator implements CalculatorInterface
{ {
/** /**
* @var string * @var string
@@ -33,10 +27,12 @@ class DefaultCalculator implements CalculatorInterface
/** /**
* @return Timesheet[] * @return Timesheet[]
*/ */
public function getEntries() abstract public function getEntries();
{
return $this->model->getEntries(); /**
} * @return string
*/
abstract public function getId(): string;
/** /**
* @param InvoiceModel $model * @param InvoiceModel $model

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Calculator;
use App\Entity\Timesheet;
abstract class AbstractMergedCalculator extends AbstractCalculator
{
/**
* @param Timesheet $timesheet
* @param Timesheet $entry
*/
protected function mergeTimesheets(Timesheet $timesheet, Timesheet $entry)
{
$timesheet->setUser($entry->getUser());
$timesheet->setFixedRate($entry->getFixedRate()); // FIXME invoice
$timesheet->setHourlyRate($entry->getHourlyRate()); // FIXME invoice
$timesheet->setRate($timesheet->getRate() + $entry->getRate());
$timesheet->setDuration($timesheet->getDuration() + $entry->getDuration());
if (null === $timesheet->getBegin() || $timesheet->getBegin()->getTimestamp() > $entry->getBegin()->getTimestamp()) {
$timesheet->setBegin($entry->getBegin());
}
if (null === $timesheet->getEnd() || $timesheet->getEnd()->getTimestamp() < $entry->getEnd()->getTimestamp()) {
$timesheet->setEnd($entry->getEnd());
}
if (null !== $this->model->getQuery()->getActivity()) {
$timesheet->setActivity($this->model->getQuery()->getActivity());
$timesheet->setDescription($this->model->getQuery()->getActivity()->getName());
} elseif (null !== $this->model->getQuery()->getProject()) {
$timesheet->setDescription($this->model->getQuery()->getProject()->getName());
}
if (null === $timesheet->getActivity()) {
$timesheet->setActivity($entry->getActivity());
}
if (empty($timesheet->getDescription())) {
$timesheet->setDescription($entry->getActivity()->getName());
}
}
}

View File

@@ -0,0 +1,38 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Calculator;
use App\Entity\Timesheet;
use App\Invoice\CalculatorInterface;
/**
* Class DefaultCalculator works on all given entries using:
* - the customers currency
* - the invoice template vat rate
* - the entries rate
*/
class DefaultCalculator extends AbstractCalculator implements CalculatorInterface
{
/**
* @return Timesheet[]
*/
public function getEntries()
{
return $this->model->getEntries();
}
/**
* @return string
*/
public function getId(): string
{
return 'default';
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Calculator;
use App\Entity\Timesheet;
use App\Invoice\CalculatorInterface;
/**
* A calculator that sums up all timesheet records from the model and returns only one
* entry for a compact invoice version.
*/
class ShortInvoiceCalculator extends AbstractMergedCalculator implements CalculatorInterface
{
/**
* @return Timesheet[]
*/
public function getEntries()
{
$entries = $this->model->getEntries();
if (empty($entries)) {
return [];
}
$timesheet = new Timesheet();
foreach ($entries as $entry) {
$this->mergeTimesheets($timesheet, $entry);
}
return [$timesheet];
}
/**
* @return string
*/
public function getId(): string
{
return 'short';
}
}

View File

@@ -0,0 +1,51 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Calculator;
use App\Entity\Timesheet;
use App\Invoice\CalculatorInterface;
/**
* A calculator that sums up the timesheet records by user.
*/
class UserInvoiceCalculator extends AbstractMergedCalculator implements CalculatorInterface
{
/**
* @return Timesheet[]
*/
public function getEntries()
{
$entries = $this->model->getEntries();
if (empty($entries)) {
return [];
}
/** @var Timesheet[] $timesheets */
$timesheets = [];
foreach ($entries as $entry) {
if (!isset($timesheets[$entry->getUser()->getId()])) {
$timesheets[$entry->getUser()->getId()] = new Timesheet();
}
$timesheet = $timesheets[$entry->getUser()->getId()];
$this->mergeTimesheets($timesheet, $entry);
}
return array_values($timesheets);
}
/**
* @return string
*/
public function getId(): string
{
return 'user';
}
}

View File

@@ -72,4 +72,14 @@ interface CalculatorInterface
* @return int * @return int
*/ */
public function getTimeWorked(): int; public function getTimeWorked(): int;
/**
* Returns the unique ID of this calculator.
*
* Prefix it with your company name followed by a hyphen (e.g. "acme-"),
* if this is a third-party calculator.
*
* @return string
*/
public function getId(): string;
} }

View File

@@ -7,8 +7,9 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace App\Invoice; namespace App\Invoice\NumberGenerator;
use App\Invoice\NumberGeneratorInterface;
use App\Model\InvoiceModel; use App\Model\InvoiceModel;
/** /**
@@ -22,6 +23,14 @@ class DateNumberGenerator implements NumberGeneratorInterface
*/ */
protected $model; protected $model;
/**
* @return string
*/
public function getId(): string
{
return 'default';
}
/** /**
* @param InvoiceModel $model * @param InvoiceModel $model
*/ */
@@ -35,6 +44,6 @@ class DateNumberGenerator implements NumberGeneratorInterface
*/ */
public function getInvoiceNumber(): string public function getInvoiceNumber(): string
{ {
return date('ymd'); return date('ymd', $this->model->getInvoiceDate()->getTimestamp());
} }
} }

View File

@@ -25,4 +25,14 @@ interface NumberGeneratorInterface
* @return string * @return string
*/ */
public function getInvoiceNumber(): string; public function getInvoiceNumber(): string;
/**
* Returns the unique ID of this number generator.
*
* Prefix it with your company name followed by a hyphen (e.g. "acme-"),
* if this is a third-party generator.
*
* @return string
*/
public function getId(): string;
} }

View File

@@ -0,0 +1,82 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Renderer;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use Symfony\Component\Translation\TranslatorInterface;
abstract class AbstractRenderer
{
use RendererTrait;
/**
* @var DateExtensions
*/
protected $dateExtension;
/**
* @var Extensions
*/
protected $extension;
/**
* @var TranslatorInterface
*/
protected $translator;
/**
* @param TranslatorInterface $translator
* @param DateExtensions $dateExtension
* @param Extensions $extensions
*/
public function __construct(TranslatorInterface $translator, DateExtensions $dateExtension, Extensions $extensions)
{
$this->translator = $translator;
$this->dateExtension = $dateExtension;
$this->extension = $extensions;
}
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedDateTime(\DateTime $date)
{
return $this->dateExtension->dateShort($date);
}
/**
* @param $amount
* @return mixed
*/
protected function getFormattedMoney($amount)
{
return $this->extension->money($amount);
}
/**
* @param \DateTime $date
* @return mixed
*/
protected function getFormattedMonthName(\DateTime $date)
{
return $this->translator->trans($this->dateExtension->monthName($date));
}
/**
* @param $seconds
* @return mixed
*/
protected function getFormattedDuration($seconds)
{
return $this->extension->duration($seconds);
}
}

View File

@@ -0,0 +1,129 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Model\InvoiceModel;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
use Symfony\Component\HttpFoundation\Response;
abstract class AbstractSpreadsheetRenderer extends AbstractRenderer
{
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet);
/**
* Render the given InvoiceDocument with the data from the InvoiceModel.
*
* @param InvoiceDocument $document
* @param InvoiceModel $model
* @return Response
*/
public function render(InvoiceDocument $document, InvoiceModel $model): Response
{
$spreadsheet = IOFactory::load($document->getFilename());
$worksheet = $spreadsheet->getActiveSheet();
$entries = $model->getCalculator()->getEntries();
$replacer = $this->modelToReplacer($model);
$timesheetAmount = count($entries);
$this->addTemplateRows($worksheet, $timesheetAmount);
$worksheet->setTitle($model->getTemplate()->getTitle());
$entryRow = 0;
foreach ($worksheet->getRowIterator() as $row) {
$timesheet = $entries[$entryRow];
$sheetValues = false;
foreach ($row->getCellIterator() as $cell) {
$value = $cell->getValue();
if (stripos($value, '${entry.') !== false) {
if ($sheetValues === false) {
$sheetValues = $this->timesheetToArray($timesheet);
}
$searcher = str_replace('${', '', $value);
$searcher = str_replace('}', '', $searcher);
if (isset($sheetValues[$searcher])) {
$cell->setValue($sheetValues[$searcher]);
}
} elseif (stripos($value, '${') !== false) {
$searcher = str_replace('${', '', $value);
$searcher = str_replace('}', '', $searcher);
if (isset($replacer[$searcher])) {
$cell->setValue($replacer[$searcher]);
}
}
}
if ($sheetValues !== false && $entryRow < $timesheetAmount - 1) {
$entryRow++;
}
}
$filename = $this->saveSpreadsheet($spreadsheet);
return $this->getFileResponse($filename, basename($document->getFilename()));
}
/**
* @param Worksheet $worksheet
* @param int $timesheets
*/
protected function addTemplateRows(Worksheet $worksheet, int $timesheets)
{
$startRow = null;
$rowCounter = 0;
foreach ($worksheet->getRowIterator() as $row) {
$cellCounter = 0;
foreach ($row->getCellIterator() as $cell) {
$value = $cell->getValue();
if (stripos($value, '${entry.') !== false) {
$startRow = $row->getRowIndex();
$worksheet->insertNewRowBefore($row->getRowIndex(), $timesheets - 1);
break 2;
}
if ($cellCounter++ >= 10) {
break;
}
}
if ($rowCounter++ >= 100) {
break;
}
}
if ($startRow === null) {
throw new \Exception('Invalid invoice document, no template row found.');
}
// fill up all new rows with template values
$templateRow = $timesheets + $startRow;
$iterator = $worksheet->getRowIterator($templateRow - 1, $templateRow);
$templateColumns = [];
foreach ($iterator->current()->getCellIterator() as $cell) {
$templateColumns[$cell->getColumn()] = $cell->getValue();
}
$iterator = $worksheet->getRowIterator($startRow, $templateRow - 2);
foreach ($iterator as $row) {
foreach ($row->getCellIterator() as $cell) {
$cell->setValue($templateColumns[$cell->getColumn()]);
}
}
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Renderer;
use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class CsvRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string[]
*/
protected function getFileExtensions()
{
return ['.csv'];
}
/**
* @return string
*/
protected function getContentType()
{
return 'text/csv';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-csv');
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
$writer->save($filename);
return $filename;
}
}

View File

@@ -0,0 +1,88 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\RendererInterface;
use App\Model\InvoiceModel;
use PhpOffice\PhpWord\PhpWord;
use PhpOffice\PhpWord\TemplateProcessor;
use Symfony\Component\HttpFoundation\File\Stream;
use Symfony\Component\HttpFoundation\Response;
class DocxRenderer extends AbstractRenderer implements RendererInterface
{
/*
protected function setPhpWordOptions(PhpWord $phpWord)
{
if (!extension_loaded('zip')) {
\PhpOffice\PhpWord\Settings::setZipClass(\PhpOffice\PhpWord\Settings::PCLZIP);
}
// \PhpOffice\PhpWord\Settings::setPdfRendererPath(__DIR__ . '/../../vendor/tecnickcom/tcpdf/');
// \PhpOffice\PhpWord\Settings::setPdfRendererName(\PhpOffice\PhpWord\Settings::PDF_RENDERER_TCPDF);
// \PhpOffice\PhpWord\Settings::setOutputEscapingEnabled(true);
// $phpWord->getSettings()->setThemeFontLang(new Language(Language::EN_US));
$properties = $phpWord->getDocInfo();
$properties->setCreator('Kimai 2');
$properties->setDescription('Created with Kimai 2, the open-source time-tracking software! Get more information at www.kimai.org.');
$properties->setCreated(time());
$properties->setModified(time());
}
*/
/**
* @param InvoiceDocument $document
* @param InvoiceModel $model
* @return Response
*/
public function render(InvoiceDocument $document, InvoiceModel $model): Response
{
$filename = basename($document->getFilename());
$template = new TemplateProcessor($document->getFilename());
foreach ($this->modelToReplacer($model) as $key => $value) {
$template->setValue($key, $value);
}
$template->cloneRow('entry.description', count($model->getCalculator()->getEntries()));
$i = 1;
foreach ($model->getCalculator()->getEntries() as $entry) {
$values = $this->timesheetToArray($entry);
foreach ($values as $search => $replace) {
$template->setValue($search . '#' . $i, $replace);
}
$i++;
}
$cacheFile = $template->save();
clearstatcache(true, $cacheFile);
return $this->getFileResponse(new Stream($cacheFile), $filename);
}
/**
* @return string[]
*/
protected function getFileExtensions()
{
return ['.docx'];
}
/**
* @return string
*/
protected function getContentType()
{
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Renderer;
use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class OdsRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string[]
*/
protected function getFileExtensions()
{
return ['.ods'];
}
/**
* @return string
*/
protected function getContentType()
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-ods');
$writer = IOFactory::createWriter($spreadsheet, 'Ods');
$writer->save($filename);
return $filename;
}
}

View File

@@ -0,0 +1,180 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Entity\Timesheet;
use App\Entity\UserPreference;
use App\Model\InvoiceModel;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
trait RendererTrait
{
/**
* @return string[]
*/
abstract protected function getFileExtensions();
/**
* @return string
*/
abstract protected function getContentType();
/**
* @param InvoiceDocument $document
* @return bool
*/
public function supports(InvoiceDocument $document): bool
{
foreach ($this->getFileExtensions() as $extension) {
if (stripos($document->getFilename(), $extension) !== false) {
return true;
}
}
return false;
}
/**
* @param \DateTime $date
* @return mixed
*/
abstract protected function getFormattedDateTime(\DateTime $date);
/**
* @param $amount
* @return mixed
*/
abstract protected function getFormattedMoney($amount);
/**
* @param \DateTime $date
* @return mixed
*/
abstract protected function getFormattedMonthName(\DateTime $date);
/**
* @param $seconds
* @return mixed
*/
abstract protected function getFormattedDuration($seconds);
/**
* @param InvoiceModel $model
* @return array
*/
protected function modelToReplacer(InvoiceModel $model)
{
return [
'invoice.due_date' => $this->getFormattedDateTime($model->getDueDate()),
'invoice.date' => $this->getFormattedDateTime($model->getInvoiceDate()),
'invoice.number' => $model->getNumberGenerator()->getInvoiceNumber(),
'invoice.currency' => $model->getCalculator()->getCurrency(),
'invoice.vat' => $model->getCalculator()->getVat(),
'invoice.tax' => $this->getFormattedMoney($model->getCalculator()->getTax()),
'invoice.total_time' => $this->getFormattedDuration($model->getCalculator()->getTimeWorked()),
'invoice.total' => $this->getFormattedMoney($model->getCalculator()->getTotal()),
'invoice.subtotal' => $this->getFormattedMoney($model->getCalculator()->getSubtotal()),
'template.name' => $model->getTemplate()->getName(),
'template.company' => $model->getTemplate()->getCompany(),
'template.address' => $model->getTemplate()->getAddress(),
'template.title' => $model->getTemplate()->getTitle(),
'template.payment_terms' => $model->getTemplate()->getPaymentTerms(),
'template.due_days' => $model->getTemplate()->getDueDays(),
'query.begin' => $this->getFormattedDateTime($model->getQuery()->getBegin()),
'query.end' => $this->getFormattedDateTime($model->getQuery()->getEnd()),
'query.month' => $this->getFormattedMonthName($model->getQuery()->getBegin()),
'query.year' => $model->getQuery()->getBegin()->format('Y'),
'customer.address' => $model->getCustomer()->getAddress(),
'customer.name' => $model->getCustomer()->getName(),
'customer.contact' => $model->getCustomer()->getContact(),
'customer.company' => $model->getCustomer()->getCompany(),
'customer.number' => $model->getCustomer()->getNumber(),
'customer.country' => $model->getCustomer()->getCountry(),
'customer.homepage' => $model->getCustomer()->getHomepage(),
'customer.comment' => $model->getCustomer()->getComment(),
];
}
/**
* @param Timesheet $timesheet
* @return array
*/
protected function timesheetToArray(Timesheet $timesheet)
{
$rate = $timesheet->getRate();
$hourlyRate = $timesheet->getHourlyRate();
$amount = $this->getFormattedDuration($timesheet->getDuration());
$description = $timesheet->getDescription();
if (null !== $timesheet->getFixedRate()) {
$rate = $timesheet->getFixedRate();
$hourlyRate = $timesheet->getFixedRate();
$amount = 1;
}
if (empty($description)) {
$description = $timesheet->getActivity()->getName();
}
$user = $timesheet->getUser();
if (empty($hourlyRate)) {
$hourlyRate = $user->getPreferenceValue(UserPreference::HOURLY_RATE);
}
$activity = $timesheet->getActivity();
$project = $activity->getProject();
$customer = $project->getCustomer();
return [
'entry.description' => $description,
'entry.amount' => $amount,
'entry.rate' => $this->getFormattedMoney($hourlyRate),
'entry.total' => $this->getFormattedMoney($rate),
'entry.duration' => $timesheet->getDuration(),
'entry.begin' => $this->getFormattedDateTime($timesheet->getBegin()),
'entry.begin_timestamp' => $timesheet->getBegin()->getTimestamp(),
'entry.end' => $this->getFormattedDateTime($timesheet->getEnd()),
'entry.end_timestamp' => $timesheet->getEnd()->getTimestamp(),
'entry.date' => $this->getFormattedDateTime($timesheet->getBegin()),
'entry.user_id' => $user->getId(),
'entry.user_name' => $user->getUsername(),
'entry.user_alias' => $user->getAlias(),
'entry.activity' => $activity->getName(),
'entry.activity_id' => $activity->getId(),
'entry.project' => $project->getName(),
'entry.project_id' => $project->getId(),
'entry.customer' => $customer->getName(),
'entry.customer_id' => $customer->getId(),
];
}
/**
* @param mixed $file
* @param string $filename
* @return BinaryFileResponse
*/
protected function getFileResponse($file, $filename)
{
$response = new BinaryFileResponse($file);
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
$response->headers->set('Content-Type', $this->getContentType());
$response->headers->set('Content-Disposition', $disposition);
$response->deleteFileAfterSend(true);
return $response;
}
}

View File

@@ -0,0 +1,57 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Renderer;
use App\Entity\InvoiceDocument;
use App\Invoice\RendererInterface;
use App\Model\InvoiceModel;
use Symfony\Component\HttpFoundation\Response;
class TwigRenderer implements RendererInterface
{
/**
* @var \Twig_Environment
*/
protected $twig;
/**
* @param \Twig_Environment $twig
*/
public function __construct(\Twig_Environment $twig)
{
$this->twig = $twig;
}
/**
* @param InvoiceDocument $document
* @return bool
*/
public function supports(InvoiceDocument $document): bool
{
return stripos($document->getFilename(), '.twig') !== false;
}
/**
* @param InvoiceDocument $document
* @param InvoiceModel $model
* @return Response
*/
public function render(InvoiceDocument $document, InvoiceModel $model): Response
{
$content = $this->twig->render('@invoice/' . basename($document->getFilename()), [
'model' => $model
]);
$response = new Response();
$response->setContent($content);
return $response;
}
}

View File

@@ -0,0 +1,47 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice\Renderer;
use App\Invoice\RendererInterface;
use PhpOffice\PhpSpreadsheet\IOFactory;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
class XlsxRenderer extends AbstractSpreadsheetRenderer implements RendererInterface
{
/**
* @return string[]
*/
protected function getFileExtensions()
{
return ['.xlsx', '.xls'];
}
/**
* @return string
*/
protected function getContentType()
{
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
/**
* @param Spreadsheet $spreadsheet
* @return bool|string
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
*/
protected function saveSpreadsheet(Spreadsheet $spreadsheet)
{
$filename = tempnam(sys_get_temp_dir(), 'kimai-xslx');
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
$writer->save($filename);
return $filename;
}
}

View File

@@ -0,0 +1,34 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use App\Entity\InvoiceDocument;
use App\Model\InvoiceModel;
use Symfony\Component\HttpFoundation\Response;
interface RendererInterface
{
/**
* Checks whether the given InvoiceDocument can be rendered.
*
* @param InvoiceDocument $document
* @return bool
*/
public function supports(InvoiceDocument $document): bool;
/**
* Render the given InvoiceDocument with the data from the InvoiceModel.
*
* @param InvoiceDocument $document
* @param InvoiceModel $model
* @return Response
*/
public function render(InvoiceDocument $document, InvoiceModel $model): Response;
}

View File

@@ -9,34 +9,59 @@
namespace App\Invoice; namespace App\Invoice;
use App\Entity\InvoiceDocument;
use App\Repository\InvoiceDocumentRepository;
/** /**
* A service to manage the invoice configuration: * A service to manage invoice dependencies.
* - invoice number generator
* - invoice sum calculator
* - template renderer
*/ */
class ServiceInvoice class ServiceInvoice
{ {
/** /**
* @var array * @var CalculatorInterface[]
*/ */
protected $config = []; protected $calculator = [];
/** /**
* ServiceInvoice constructor. * @var RendererInterface[]
* @param array $invoiceConfig
*/ */
public function __construct(array $invoiceConfig) protected $renderer = [];
/**
* @var NumberGeneratorInterface[]
*/
protected $numberGenerator = [];
/**
* @var InvoiceDocumentRepository
*/
protected $documents;
/**
* @param InvoiceDocumentRepository $repository
*/
public function __construct(InvoiceDocumentRepository $repository)
{ {
$this->config = $invoiceConfig; $this->documents = $repository;
} }
/** /**
* @return array * @param NumberGeneratorInterface $generator
* @return $this
*/
public function addNumberGenerator(NumberGeneratorInterface $generator)
{
$this->numberGenerator[] = $generator;
return $this;
}
/**
* @return NumberGeneratorInterface[]
*/ */
public function getNumberGenerator() public function getNumberGenerator()
{ {
return $this->config['number_generator']; return $this->numberGenerator;
} }
/** /**
@@ -45,9 +70,9 @@ class ServiceInvoice
*/ */
public function getNumberGeneratorByName(string $name) public function getNumberGeneratorByName(string $name)
{ {
foreach ($this->getNumberGenerator() as $key => $class) { foreach ($this->getNumberGenerator() as $generator) {
if ($key === $name) { if ($generator->getId() === $name) {
return new $class(); return $generator;
} }
} }
@@ -55,11 +80,22 @@ class ServiceInvoice
} }
/** /**
* @return array * @param CalculatorInterface $calculator
* @return $this
*/
public function addCalculator(CalculatorInterface $calculator)
{
$this->calculator[] = $calculator;
return $this;
}
/**
* @return CalculatorInterface[]
*/ */
public function getCalculator() public function getCalculator()
{ {
return $this->config['calculator']; return $this->calculator;
} }
/** /**
@@ -68,38 +104,52 @@ class ServiceInvoice
*/ */
public function getCalculatorByName(string $name) public function getCalculatorByName(string $name)
{ {
foreach ($this->getCalculator() as $key => $class) { foreach ($this->getCalculator() as $calculator) {
if ($key === $name) { if ($calculator->getId() === $name) {
return new $class(); return $calculator;
} }
} }
return null; return null;
} }
/**
* @param string $name
* @return InvoiceDocument|null
*/
public function getDocumentByName(string $name)
{
return $this->documents->findByName($name);
}
/** /**
* Returns an array of invoice renderer, which will consist of a unique name and a controller action. * Returns an array of invoice renderer, which will consist of a unique name and a controller action.
* *
* @return array * @return InvoiceDocument[]
* @throws \Exception
*/ */
public function getRenderer() public function getDocuments()
{ {
return $this->config['renderer']; return $this->documents->findAll();
} }
/** /**
* @param $renderer * @param RendererInterface $renderer
* @return string|null * @return $this
*/ */
public function getRendererActionByName($renderer) public function addRenderer(RendererInterface $renderer)
{ {
foreach ($this->config['renderer'] as $name => $action) { $this->renderer[] = $renderer;
if ($name == $renderer) {
return $action; return $this;
}
} }
return null; /**
* Returns an array of invoice renderer.
*
* @return RendererInterface[]
*/
public function getRenderer()
{
return $this->renderer;
} }
} }

View File

@@ -1,44 +0,0 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Invoice;
use App\Entity\Timesheet;
/**
* A calculator that sums up all timesheet records from the model and returns only one
* entry for a compact invoice version.
*/
class ShortInvoiceCalculator extends DefaultCalculator
{
/**
* @return Timesheet[]
*/
public function getEntries()
{
$timesheet = new Timesheet();
foreach ($this->model->getEntries() as $entry) {
$timesheet->setRate($timesheet->getRate() + $entry->getRate());
$timesheet->setDuration($timesheet->getDuration() + $entry->getDuration());
$timesheet->setBegin($entry->getBegin());
if (null === $timesheet->getActivity()) {
$timesheet->setActivity($entry->getActivity());
}
}
if (null !== $this->model->getQuery()->getActivity()) {
$timesheet->setDescription($this->model->getQuery()->getActivity()->getName());
} elseif (null !== $this->model->getQuery()->getProject()) {
$timesheet->setDescription($this->model->getQuery()->getProject()->getName());
}
return [$timesheet];
}
}

View File

@@ -11,8 +11,12 @@ namespace App;
use App\DependencyInjection\AppExtension; use App\DependencyInjection\AppExtension;
use App\DependencyInjection\Compiler\DoctrineCompilerPass; use App\DependencyInjection\Compiler\DoctrineCompilerPass;
use App\DependencyInjection\Compiler\InvoiceServiceCompilerPass;
use App\DependencyInjection\Compiler\TwigContextCompilerPass; use App\DependencyInjection\Compiler\TwigContextCompilerPass;
use App\Timesheet\CalculatorInterface; use App\Invoice\CalculatorInterface as InvoiceCalculator;
use App\Invoice\NumberGeneratorInterface;
use App\Invoice\RendererInterface;
use App\Timesheet\CalculatorInterface as TimesheetCalculator;
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait; use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
use Symfony\Component\Config\Loader\LoaderInterface; use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\Compiler\PassConfig;
@@ -26,6 +30,10 @@ class Kernel extends BaseKernel
public const CONFIG_EXTS = '.{php,xml,yaml,yml}'; public const CONFIG_EXTS = '.{php,xml,yaml,yml}';
public const TAG_INVOICE_RENDERER = 'invoice.renderer';
public const TAG_INVOICE_NUMBER_GENERATOR = 'invoice.number_generator';
public const TAG_INVOICE_CALCULATOR = 'invoice.calculator';
public function getCacheDir() public function getCacheDir()
{ {
return $this->getProjectDir() . '/var/cache/' . $this->environment; return $this->getProjectDir() . '/var/cache/' . $this->environment;
@@ -38,7 +46,10 @@ class Kernel extends BaseKernel
protected function build(ContainerBuilder $container) protected function build(ContainerBuilder $container)
{ {
$container->registerForAutoconfiguration(CalculatorInterface::class)->addTag('timesheet.calculator'); $container->registerForAutoconfiguration(TimesheetCalculator::class)->addTag('timesheet.calculator');
$container->registerForAutoconfiguration(RendererInterface::class)->addTag(self::TAG_INVOICE_RENDERER);
$container->registerForAutoconfiguration(NumberGeneratorInterface::class)->addTag(self::TAG_INVOICE_NUMBER_GENERATOR);
$container->registerForAutoconfiguration(InvoiceCalculator::class)->addTag(self::TAG_INVOICE_CALCULATOR);
} }
public function registerBundles() public function registerBundles()
@@ -68,6 +79,7 @@ class Kernel extends BaseKernel
$container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new DoctrineCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000); $container->addCompilerPass(new TwigContextCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
$container->addCompilerPass(new InvoiceServiceCompilerPass(), PassConfig::TYPE_BEFORE_OPTIMIZATION, -1000);
} }
protected function configureRoutes(RouteCollectionBuilder $routes) protected function configureRoutes(RouteCollectionBuilder $routes)

View File

@@ -17,7 +17,8 @@ use App\Invoice\NumberGeneratorInterface;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
/** /**
* Class InvoiceModel is the ONLY value that a renderer template receives for generating the invoice. * InvoiceModel is the ONLY value that a RendererInterface receives for generating the invoice,
* besides the InvoiceDocument which is used as a "template".
*/ */
class InvoiceModel class InvoiceModel
{ {
@@ -51,10 +52,20 @@ class InvoiceModel
*/ */
protected $generator; protected $generator;
/**
* @var \DateTime
*/
protected $invoiceDate;
public function __construct()
{
$this->invoiceDate = new \DateTime();
}
/** /**
* @return InvoiceQuery * @return InvoiceQuery
*/ */
public function getQuery(): InvoiceQuery public function getQuery(): ?InvoiceQuery
{ {
return $this->query; return $this->query;
} }
@@ -103,7 +114,7 @@ class InvoiceModel
* @param InvoiceTemplate $template * @param InvoiceTemplate $template
* @return InvoiceModel * @return InvoiceModel
*/ */
public function setTemplate($template) public function setTemplate(InvoiceTemplate $template)
{ {
$this->template = $template; $this->template = $template;
@@ -132,8 +143,12 @@ class InvoiceModel
/** /**
* @return \DateTime * @return \DateTime
*/ */
public function getDueDate(): \DateTime public function getDueDate(): ?\DateTime
{ {
if (null === $this->getTemplate()) {
return null;
}
return new \DateTime('+' . $this->getTemplate()->getDueDays() . ' days'); return new \DateTime('+' . $this->getTemplate()->getDueDays() . ' days');
} }
@@ -142,7 +157,7 @@ class InvoiceModel
*/ */
public function getInvoiceDate(): \DateTime public function getInvoiceDate(): \DateTime
{ {
return new \DateTime(); return $this->invoiceDate;
} }
/** /**
@@ -160,7 +175,7 @@ class InvoiceModel
/** /**
* @return NumberGeneratorInterface * @return NumberGeneratorInterface
*/ */
public function getNumberGenerator(): NumberGeneratorInterface public function getNumberGenerator(): ?NumberGeneratorInterface
{ {
return $this->generator; return $this->generator;
} }
@@ -180,7 +195,7 @@ class InvoiceModel
/** /**
* @return CalculatorInterface * @return CalculatorInterface
*/ */
public function getCalculator(): CalculatorInterface public function getCalculator(): ?CalculatorInterface
{ {
return $this->calculator; return $this->calculator;
} }

View File

@@ -10,7 +10,7 @@
namespace App\Model\Statistic; namespace App\Model\Statistic;
/** /**
* Yearly statistics * Monthly statistics
*/ */
class Month class Month
{ {
@@ -28,11 +28,14 @@ class Month
protected $totalRate = 0; protected $totalRate = 0;
/** /**
* Month constructor.
* @param string $month * @param string $month
*/ */
public function __construct($month) public function __construct(string $month)
{ {
$monthNumber = (int) $month;
if ($monthNumber < 1 || $monthNumber > 12) {
throw new \InvalidArgumentException('Invalid month given, expected 01-12 but given: ' . $monthNumber);
}
$this->month = $month; $this->month = $month;
} }

View File

@@ -21,7 +21,7 @@ class Year
/** /**
* @var Month[] * @var Month[]
*/ */
protected $months; protected $months = [];
/** /**
* Year constructor. * Year constructor.
@@ -52,10 +52,10 @@ class Year
} }
/** /**
* @param $month * @param int $month
* @return null|Month * @return null|Month
*/ */
public function getMonth($month) public function getMonth(int $month)
{ {
if (isset($this->months[$month])) { if (isset($this->months[$month])) {
return $this->months[$month]; return $this->months[$month];

View File

@@ -0,0 +1,72 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Repository;
use App\Entity\InvoiceDocument;
use Symfony\Component\Finder\Finder;
class InvoiceDocumentRepository
{
/**
* @var array
*/
protected $documentDirs = [];
/**
* @param array $directories
*/
public function __construct(array $directories)
{
$this->documentDirs = $directories;
}
/**
* @param string $name
* @return InvoiceDocument|null
*/
public function findByName(string $name)
{
foreach ($this->findAll() as $document) {
if ($document->getId() === $name) {
return $document;
}
}
return null;
}
/**
* Returns an array of invoice renderer, which will consist of a unique name and a controller action.
*
* @return InvoiceDocument[]
*/
public function findAll()
{
$base = dirname(dirname(__DIR__)) . DIRECTORY_SEPARATOR;
$documents = [];
foreach ($this->documentDirs as $searchPath) {
if (!is_dir($base . $searchPath)) {
continue;
}
$finder = Finder::create()->ignoreDotFiles(true)->files()->in($base . $searchPath)->name('*.*');
foreach ($finder->getIterator() as $file) {
$doc = new InvoiceDocument($file);
// the first found invoice document wins
if (!isset($documents[$doc->getId()])) {
$documents[$doc->getId()] = $doc;
}
}
}
return $documents;
}
}

View File

@@ -44,23 +44,4 @@ class InvoiceQuery extends TimesheetQuery
return $this; return $this;
} }
/**
* @return InvoiceTemplate[]
*/
public function getTemplates(): array
{
return $this->templates;
}
/**
* @param InvoiceTemplate[] $templates
* @return InvoiceQuery
*/
public function setTemplates(array $templates)
{
$this->templates = $templates;
return $this;
}
} }

View File

@@ -44,6 +44,7 @@ class RateCalculator implements CalculatorInterface
$fixedRate = $this->findFixedRate($record); $fixedRate = $this->findFixedRate($record);
if (null !== $fixedRate) { if (null !== $fixedRate) {
$record->setRate($fixedRate); $record->setRate($fixedRate);
$record->setFixedRate($fixedRate);
return; return;
} }
@@ -51,9 +52,11 @@ class RateCalculator implements CalculatorInterface
$hourlyRate = $this->findHourlyRate($record); $hourlyRate = $this->findHourlyRate($record);
$factor = $this->getRateFactor($record); $factor = $this->getRateFactor($record);
$record->setRate( $hourlyRate = (float) $hourlyRate * $factor;
$this->calculateRate($record->getDuration(), $hourlyRate, $factor) $rate = (float) $hourlyRate * ($record->getDuration() / 3600);
);
$record->setHourlyRate($hourlyRate);
$record->setRate($rate);
} }
/** /**
@@ -127,11 +130,6 @@ class RateCalculator implements CalculatorInterface
$weekday = $record->getEnd()->format('l'); $weekday = $record->getEnd()->format('l');
$days = array_map('strtolower', $rateFactor['days']); $days = array_map('strtolower', $rateFactor['days']);
if (in_array(strtolower($weekday), $days)) { if (in_array(strtolower($weekday), $days)) {
if ($rateFactor['factor'] <= 0) {
throw new \InvalidArgumentException(
'A rate factor smaller or equals 0 is not allowed, given: ' . $rateFactor['factor']
);
}
$factor += $rateFactor['factor']; $factor += $rateFactor['factor'];
} }
} }
@@ -142,15 +140,4 @@ class RateCalculator implements CalculatorInterface
return $factor; return $factor;
} }
/**
* @param int $duration
* @param float $hourlyRate
* @param float $factor
* @return float
*/
protected function calculateRate($duration, $hourlyRate, $factor)
{
return (float) $hourlyRate * ($duration / 3600) * $factor;
}
} }

View File

@@ -53,8 +53,12 @@ class MarkdownExtension extends \Twig_Extension
* @param string $content * @param string $content
* @return string * @return string
*/ */
public function timesheetContent(string $content): string public function timesheetContent($content): string
{ {
if (empty($content)) {
return '';
}
if ($this->timesheetIsMarkdown) { if ($this->timesheetIsMarkdown) {
return $this->markdown->toHtml($content, false); return $this->markdown->toHtml($content, false);
} }

View File

@@ -161,6 +161,9 @@
"kevinpapst/adminlte-bundle": { "kevinpapst/adminlte-bundle": {
"version": "0.3" "version": "0.3"
}, },
"markbaker/complex": {
"version": "1.4.6"
},
"monolog/monolog": { "monolog/monolog": {
"version": "1.23.0" "version": "1.23.0"
}, },
@@ -191,6 +194,9 @@
"paragonie/random_compat": { "paragonie/random_compat": {
"version": "v2.0.17" "version": "v2.0.17"
}, },
"pclzip/pclzip": {
"version": "2.8.2"
},
"phar-io/manifest": { "phar-io/manifest": {
"version": "1.0.1" "version": "1.0.1"
}, },
@@ -212,6 +218,15 @@
"phpdocumentor/type-resolver": { "phpdocumentor/type-resolver": {
"version": "0.4.0" "version": "0.4.0"
}, },
"phpoffice/common": {
"version": "0.2.9"
},
"phpoffice/phpspreadsheet": {
"version": "1.4.0"
},
"phpoffice/phpword": {
"version": "0.15.0"
},
"phpoption/phpoption": { "phpoption/phpoption": {
"version": "1.5.0" "version": "1.5.0"
}, },
@@ -593,6 +608,9 @@
"zendframework/zend-code": { "zendframework/zend-code": {
"version": "3.3.0" "version": "3.3.0"
}, },
"zendframework/zend-escaper": {
"version": "2.6.0"
},
"zendframework/zend-eventmanager": { "zendframework/zend-eventmanager": {
"version": "3.2.0" "version": "3.2.0"
}, },

View File

@@ -18,10 +18,33 @@
{% block main %} {% block main %}
{% if model.entries is empty or action is empty %} {% if model.entries is empty %}
{{ widgets.callout('warning', 'invoice.select_filter') }} {{ widgets.callout('warning', 'invoice.select_filter') }}
{% else %} {% else %}
{{ render(controller(action, {'model': model, 'appRequest': app.request})) }}
{{ include('invoice/preview.html.twig') }}
<div class="row no-print">
<div class="col-xs-12">
<button type="button" id="print-invoice-button" class="btn btn-success pull-right">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
{% block javascripts %}
<script type="text/javascript">
$(document).ready(function () {
$( "#print-invoice-button" ).click(function() {
var prevAction = $( "#invoice-print-form" ).attr('action');
$( "#invoice-print-form" ).attr('target', '_blank').attr('action', '{{ path('invoice_print') }}');
$( "#invoice-print-form" ).submit();
$( "#invoice-print-form" ).removeAttr('target').attr('action', prevAction);
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,26 @@
<!DOCTYPE html>
<html lang="{{ app.request.locale }}">
<head>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" name="viewport">
<link rel="stylesheet" href="{{ asset('build/app.css') }}">
<script src="{{ asset('build/app.js') }}"></script>
</head>
<body class="invoice_print">
<div class="wrapper">
<section class="invoice">
{% block invoice %}{% endblock %}
</section>
{% block print_button %}
<div class="row no-print">
<div class="col-xs-12">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>
</div>
{% endblock %}
</div>
</body>
</html>

View File

@@ -0,0 +1,52 @@
{% import "macros/widgets.html.twig" as widgets %}
{{ widgets.callout('success', 'invoice.preview') }}
<div class="row">
<div class="col-md-12">
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>{{ 'label.date'|trans }}</th>
<th class="hidden-xs hidden-sm">{{ 'label.user'|trans }}</th>
<th class="hidden-xs hidden-sm">{{ 'label.activity'|trans }}</th>
<th class="text-center">{{ 'invoice.unit_price'|trans }}</th>
<th class="text-center">{{ 'invoice.amount'|trans }}</th>
<th class="text-right">{{ 'invoice.total_rate'|trans }}</th>
</tr>
</thead>
<tbody>
{% for entry in model.calculator.entries %}
{% set duration = entry.duration|duration() %}
{% if entry.fixedRate is not null %}
{% set rate = entry.fixedRate %}
{% set duration = 1 %}
{% elseif entry.hourlyRate is not null %}
{% set rate = entry.hourlyRate %}
{% else %}
{% set rate = app.user.getPreferenceValue('hourly_rate') %}
{% endif %}
<tr>
<td>{{ entry.begin|date_short }}</td>
<td class="hidden-xs hidden-sm">{{ widgets.username(entry.user) }}</td>
<td class="hidden-xs hidden-sm timesheet-description">
{% if entry.description is not empty %}
{{ entry.description|desc2html }}
{% else %}
{{ entry.activity.name }} / {{ entry.activity.project.name }}
{% endif %}
</td>
<td class="text-center" contenteditable="true">
{{ rate|money(model.calculator.currency) }}
</td>
<td class="text-center">
{{ duration }}
</td>
<td class="text-right">{{ entry.rate|money(model.calculator.currency) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>

Binary file not shown.

View File

@@ -1,3 +1,6 @@
{% extends 'invoice/layout.html.twig' %}
{% block 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">
@@ -108,11 +111,4 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
<div class="row no-print">
<div class="col-xs-12">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>
</div>

View File

@@ -0,0 +1,2 @@
start_date,start_timestamp,end_date,end_timestamp,duration,username,description,rate,amount,total
${entry.begin},${entry.begin_timestamp},${entry.end},${entry.end_timestamp},${entry.duration},${entry.user_name},${entry.description},${entry.rate},${entry.amount},${entry.total}
1 start_date start_timestamp end_date end_timestamp duration username description rate amount total
2 ${entry.begin} ${entry.begin_timestamp} ${entry.end} ${entry.end_timestamp} ${entry.duration} ${entry.user_name} ${entry.description} ${entry.rate} ${entry.amount} ${entry.total}

View File

@@ -1,5 +1,10 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{# You can overrwite these settings in your config/packages.local.yaml under the key "twig.globals.company" #}
{% extends 'invoice/layout.html.twig' %}
{% block invoice %}
{# You can overwrite these settings in your config/packages.local.yaml under the key "twig.globals.company" #}
{% set company = company|default({ {% set company = company|default({
name: 'Kimai Inc.', name: 'Kimai Inc.',
homepage: 'www.kimai.org', homepage: 'www.kimai.org',
@@ -137,11 +142,4 @@
</footer> </footer>
</div> </div>
</div> </div>
{% endblock %}
<div class="row no-print">
<div class="col-xs-12">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>
</div>

Binary file not shown.

Binary file not shown.

View File

@@ -1,5 +1,7 @@
{% import "macros/widgets.html.twig" as widgets %} {% import "macros/widgets.html.twig" as widgets %}
{% extends 'invoice/layout.html.twig' %}
{% block 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">
@@ -23,7 +25,13 @@
</tr> </tr>
<tr> <tr>
<th style="width:50%">{{ 'label.date'|trans }}</th> <th style="width:50%">{{ 'label.date'|trans }}</th>
<td contenteditable="true">{{ model.query.end|month_name|trans }} {{ model.query.end|date('Y') }}</td> <td contenteditable="true">
{% if model.query.begin|date('m') != model.query.end|date('m') or model.query.begin|date('Y') != model.query.end|date('Y') %}
{{ model.query.begin|date_short }} - {{ model.query.end|date_short }}
{% else %}
{{ model.query.end|month_name|trans }} {{ model.query.end|date('Y') }}
{% endif %}
</td>
</tr> </tr>
<tr> <tr>
<th>{{ 'label.customer'|trans }}</th> <th>{{ 'label.customer'|trans }}</th>
@@ -50,6 +58,9 @@
<thead> <thead>
<tr> <tr>
<th>{{ 'label.date'|trans }}</th> <th>{{ 'label.date'|trans }}</th>
{% if model.query.user is empty %}
<th>{{ 'label.user'|trans }}</th>
{% endif %}
<th>{{ 'label.activity'|trans }}</th> <th>{{ 'label.activity'|trans }}</th>
<th>{{ 'label.hours'|trans }}</th> <th>{{ 'label.hours'|trans }}</th>
</tr> </tr>
@@ -58,6 +69,9 @@
{% for entry in model.calculator.entries %} {% for entry in model.calculator.entries %}
<tr> <tr>
<td>{{ entry.begin|date_short }}</td> <td>{{ entry.begin|date_short }}</td>
{% if model.query.user is empty %}
<td>{{ widgets.username(entry.user) }}</td>
{% endif %}
<td>{{ entry.activity.name }} / {{ entry.activity.project.name }}</td> <td>{{ entry.activity.name }} / {{ entry.activity.project.name }}</td>
<td>{{ entry.duration|duration }}</td> <td>{{ entry.duration|duration }}</td>
</tr> </tr>
@@ -99,11 +113,4 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %}
<div class="row no-print">
<div class="col-xs-12">
<button type="button" class="btn btn-success pull-right" onclick="window.print();return false;">
<i class="{{ 'print'|icon }}"></i> {{ 'button.print'|trans }}
</button>
</div>
</div>

View File

@@ -16,7 +16,6 @@
'title': '', 'title': '',
'due_days': 'hidden-xs', 'due_days': 'hidden-xs',
'vat': 'hidden-xs', 'vat': 'hidden-xs',
'invoice_renderer': 'hidden-xs',
'actions': 'alwaysVisible', 'actions': 'alwaysVisible',
}) }} }) }}
@@ -26,7 +25,6 @@
<td>{{ entry.title }}</td> <td>{{ entry.title }}</td>
<td class="hidden-xs hidden-sm">{{ entry.dueDays }}</td> <td class="hidden-xs hidden-sm">{{ entry.dueDays }}</td>
<td class="hidden-xs hidden-sm">{{ entry.vat }}</td> <td class="hidden-xs hidden-sm">{{ entry.vat }}</td>
<td class="hidden-xs hidden-sm">{{ "invoice_renderer.#{entry.renderer}"|trans }}</td>
<td> <td>
{% set actionButtons = {'edit': path('admin_invoice_template_edit', {'id' : entry.id, 'page': page})} %} {% set actionButtons = {'edit': path('admin_invoice_template_edit', {'id' : entry.id, 'page': page})} %}
{% set actionButtons = actionButtons|merge({'trash': path('admin_invoice_template_delete', {'id' : entry.id, 'page': page})}) %} {% set actionButtons = actionButtons|merge({'trash': path('admin_invoice_template_delete', {'id' : entry.id, 'page': page})}) %}

View File

@@ -58,7 +58,7 @@
{% endif %} {% endif %}
<td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'activity') }}">{{ widgets.label_activity(entry.activity) }}</td>
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">{{ entry.description|default('')|desc2html }}</td> <td class="{{ tables.data_table_column_class(tableName, columns, 'description') }} timesheet-description">{{ entry.description|desc2html }}</td>
<td> <td>
{% set actionButtons = {'edit': path('timesheet_edit', {'id' : entry.id, 'page': page})} %} {% set actionButtons = {'edit': path('timesheet_edit', {'id' : entry.id, 'page': page})} %}

View File

@@ -7,7 +7,7 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace App\Tests\Twig; namespace App\Tests\Command;
use App\Command\CreateUserCommand; use App\Command\CreateUserCommand;
use App\Entity\User; use App\Entity\User;

View File

@@ -9,8 +9,6 @@
namespace App\Tests\Controller; namespace App\Tests\Controller;
use App\Entity\Activity;
use App\Entity\Timesheet;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\TimesheetFixtures; use App\Tests\DataFixtures\TimesheetFixtures;

View File

@@ -12,6 +12,7 @@ namespace App\Tests\Controller;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\User; use App\Entity\User;
use App\Tests\DataFixtures\InvoiceFixtures; use App\Tests\DataFixtures\InvoiceFixtures;
use App\Tests\DataFixtures\TimesheetFixtures;
/** /**
* @coversDefaultClass \App\Controller\InvoiceController * @coversDefaultClass \App\Controller\InvoiceController
@@ -46,7 +47,7 @@ class InvoiceControllerTest extends ControllerBaseTest
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead'); $node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertNotEmpty($node->text()); $this->assertNotEmpty($node->text());
$this->assertContains('Before you can create an invoice, you have to select at least a customer filter.', $node->text()); $this->assertContains('No invoice entries were found based on your selected filters.', $node->text());
} }
public function testListTemplateAction() public function testListTemplateAction()
@@ -84,10 +85,88 @@ class InvoiceControllerTest extends ControllerBaseTest
$this->assertIsRedirect($client, $this->createUrl('/invoice/template')); $this->assertIsRedirect($client, $this->createUrl('/invoice/template'));
$client->followRedirect(); $client->followRedirect();
$this->assertTrue($client->getResponse()->isSuccessful()); $this->assertTrue($client->getResponse()->isSuccessful());
$this->assertHasFlashSuccess($client); $this->assertHasFlashSuccess($client);
} }
public function testPrintAction()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
$this->importFixture($em, $fixture);
$begin = new \DateTime('first day of this month');
$end = new \DateTime('last day of this month');
$fixture = new TimesheetFixtures();
$fixture
->setUser($this->getUserByRole($em, User::ROLE_TEAMLEAD))
->setAmount(20)
->setStartDate($begin)
;
$this->importFixture($em, $fixture);
$this->request($client, '/invoice/');
$this->assertTrue($client->getResponse()->isSuccessful());
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$client->submit($form, [
'template' => 1,
'user' => '',
'begin' => $begin->format('Y-m-d'),
'end' => $end->format('Y-m-d'),
'customer' => 1,
]);
$this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('div.callout.callout-warning.lead');
$this->assertEquals(0, $node->count());
$node = $client->getCrawler()->filter('div.callout.callout-success.lead');
$this->assertNotEmpty($node->text());
$this->assertContains('This is a preview of the data that will show up in your invoice document.', $node->text());
$node = $client->getCrawler()->filter('section.invoice div.table-responsive table.table-striped tbody tr');
$this->assertEquals(20, $node->count());
$form = $client->getCrawler()->filter('#invoice-print-form')->form();
$form->getFormNode()->setAttribute('action', $this->createUrl('/invoice/print'));
$client->submit($form, [
'template' => 1,
'user' => '',
'begin' => $begin->format('Y-m-d'),
'end' => $end->format('Y-m-d'),
'customer' => 1,
'project' => 1,
]);
$this->assertTrue($client->getResponse()->isSuccessful());
$node = $client->getCrawler()->filter('body');
$this->assertEquals(1, $node->count());
$this->assertEquals('invoice_print', $node->getIterator()[0]->getAttribute('class'));
}
public function testPrintActionRedirectsToCreateTemplate()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$this->request($client, '/invoice/print');
$this->assertIsRedirect($client, '/invoice/template/create');
}
public function testPrintActionRedirectsToIndex()
{
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
$em = $client->getContainer()->get('doctrine.orm.entity_manager');
$fixture = new InvoiceFixtures();
$this->importFixture($em, $fixture);
$this->request($client, '/invoice/print');
$this->assertIsRedirect($client, '/invoice/');
}
public function testEditTemplateAction() public function testEditTemplateAction()
{ {
$client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);

View File

@@ -56,34 +56,46 @@ class TimesheetFixtures extends Fixture
/** /**
* @param int $amount * @param int $amount
* @return $this
*/ */
public function setAmountRunning($amount) public function setAmountRunning($amount)
{ {
$this->running = $amount; $this->running = $amount;
return $this;
} }
/** /**
* @param int $amount * @param int $amount
* @return $this
*/ */
public function setAmount($amount) public function setAmount($amount)
{ {
$this->amount = $amount; $this->amount = $amount;
return $this;
} }
/** /**
* @param User $user * @param User $user
* @return $this
*/ */
public function setUser(User $user) public function setUser(User $user)
{ {
$this->user = $user; $this->user = $user;
return $this;
} }
/** /**
* @param Activity[] $activities * @param Activity[] $activities
* @return $this
*/ */
public function setActivities(array $activities) public function setActivities(array $activities)
{ {
$this->activities = $activities; $this->activities = $activities;
return $this;
} }
/** /**

View File

@@ -0,0 +1,96 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Calculator;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Invoice\CalculatorInterface;
use App\Model\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
use PHPUnit\Framework\TestCase;
abstract class AbstractCalculatorTest extends TestCase
{
protected function assertEmptyModel(CalculatorInterface $sut)
{
$customer = new Customer();
$template = new InvoiceTemplate();
$query = new InvoiceQuery();
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setQuery($query);
$sut->setModel($model);
$this->assertEquals(0, $sut->getTotal());
$this->assertEquals(0, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals(0, $sut->getSubtotal());
$this->assertEquals(0, $sut->getTimeWorked());
$this->assertEquals(0, count($sut->getEntries()));
$this->assertEquals(0, $sut->getTax());
}
protected function assertDescription(CalculatorInterface $sut, $addProject = false, $addActivity = false)
{
$customer = new Customer();
$template = new InvoiceTemplate();
$template->setVat(19);
$project = new Project();
$project->setName('project description');
$project->setCustomer($customer);
$activity = new Activity();
$activity->setName('activity description');
$activity->setProject($project);
$query = new InvoiceQuery();
if ($addProject === true) {
$query->setProject($project);
} elseif ($addActivity === true) {
$query->setActivity($activity);
}
$timesheet = new Timesheet();
$timesheet
->setDescription('timesheet description')
->setDuration(3600)
->setRate(293.27)
->setUser(new User())
->setActivity((new Activity())->setName('foo'))
;
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries([$timesheet]);
$model->setQuery($query);
$sut->setModel($model);
$this->assertEquals(1, count($sut->getEntries()));
/** @var Timesheet $result */
$result = $sut->getEntries()[0];
if ($addProject === true) {
$this->assertEquals('project description', $result->getDescription());
} elseif ($addActivity === true) {
$this->assertEquals('activity description', $result->getDescription());
} else {
$this->assertEquals('foo', $result->getDescription());
}
}
}

View File

@@ -7,38 +7,23 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace App\Tests\Invoice; namespace App\Tests\Invoice\Calculator;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Invoice\DefaultCalculator; use App\Invoice\Calculator\DefaultCalculator;
use App\Model\InvoiceModel; use App\Model\InvoiceModel;
use PHPUnit\Framework\TestCase;
/** /**
* @covers \App\Invoice\DefaultCalculator * @covers \App\Invoice\Calculator\DefaultCalculator
* @covers \App\Invoice\Calculator\AbstractCalculator
*/ */
class DefaultCalculatorTest extends TestCase class DefaultCalculatorTest extends AbstractCalculatorTest
{ {
public function testEmptyModel() public function testEmptyModel()
{ {
$customer = new Customer(); $this->assertEmptyModel(new DefaultCalculator());
$template = new InvoiceTemplate();
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$sut = new DefaultCalculator();
$sut->setModel($model);
$this->assertEquals(0, $sut->getTotal());
$this->assertEquals(0, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals(0, $sut->getSubtotal());
$this->assertEquals(0, $sut->getTimeWorked());
$this->assertEquals([], $sut->getEntries());
} }
public function testWithMultipleEntries() public function testWithMultipleEntries()
@@ -69,6 +54,7 @@ class DefaultCalculatorTest extends TestCase
$sut = new DefaultCalculator(); $sut = new DefaultCalculator();
$sut->setModel($model); $sut->setModel($model);
$this->assertEquals('default', $sut->getId());
$this->assertEquals(581.17, $sut->getTotal()); $this->assertEquals(581.17, $sut->getTotal());
$this->assertEquals(19, $sut->getVat()); $this->assertEquals(19, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency()); $this->assertEquals('EUR', $sut->getCurrency());

View File

@@ -7,43 +7,26 @@
* file that was distributed with this source code. * file that was distributed with this source code.
*/ */
namespace App\Tests\Invoice; namespace App\Tests\Invoice\Calculator;
use App\Entity\Activity; use App\Entity\Activity;
use App\Entity\Customer; use App\Entity\Customer;
use App\Entity\InvoiceTemplate; use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\Timesheet; use App\Entity\Timesheet;
use App\Invoice\ShortInvoiceCalculator; use App\Entity\User;
use App\Invoice\Calculator\ShortInvoiceCalculator;
use App\Model\InvoiceModel; use App\Model\InvoiceModel;
use App\Repository\Query\InvoiceQuery; use App\Repository\Query\InvoiceQuery;
use PHPUnit\Framework\TestCase;
/** /**
* @covers \App\Invoice\ShortInvoiceCalculator * @covers \App\Invoice\Calculator\ShortInvoiceCalculator
* @covers \App\Invoice\Calculator\AbstractCalculator
*/ */
class ShortInvoiceCalculatorTest extends TestCase class ShortInvoiceCalculatorTest extends AbstractCalculatorTest
{ {
public function testEmptyModel() public function testEmptyModel()
{ {
$customer = new Customer(); $this->assertEmptyModel(new ShortInvoiceCalculator());
$template = new InvoiceTemplate();
$query = new InvoiceQuery();
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setQuery($query);
$sut = new ShortInvoiceCalculator();
$sut->setModel($model);
$this->assertEquals(0, $sut->getTotal());
$this->assertEquals(0, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals(0, $sut->getSubtotal());
$this->assertEquals(0, $sut->getTimeWorked());
$this->assertEquals(1, count($sut->getEntries()));
} }
public function testWithMultipleEntries() public function testWithMultipleEntries()
@@ -56,16 +39,34 @@ class ShortInvoiceCalculatorTest extends TestCase
$activity->setName('activity description'); $activity->setName('activity description');
$timesheet = new Timesheet(); $timesheet = new Timesheet();
$timesheet->setDuration(3600); $timesheet
$timesheet->setRate(293.27); ->setDuration(3600)
->setRate(293.27)
->setUser(new User())
->setActivity($activity)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet2 = new Timesheet(); $timesheet2 = new Timesheet();
$timesheet2->setDuration(400); $timesheet2
$timesheet2->setRate(84); ->setDuration(400)
->setRate(84)
->setUser(new User())
->setActivity($activity)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet3 = new Timesheet(); $timesheet3 = new Timesheet();
$timesheet3->setDuration(1800); $timesheet3
$timesheet3->setRate(111.11); ->setDuration(1800)
->setRate(111.11)
->setUser(new User())
->setActivity($activity)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$entries = [$timesheet, $timesheet2, $timesheet3]; $entries = [$timesheet, $timesheet2, $timesheet3];
@@ -81,6 +82,7 @@ class ShortInvoiceCalculatorTest extends TestCase
$sut = new ShortInvoiceCalculator(); $sut = new ShortInvoiceCalculator();
$sut->setModel($model); $sut->setModel($model);
$this->assertEquals('short', $sut->getId());
$this->assertEquals(581.17, $sut->getTotal()); $this->assertEquals(581.17, $sut->getTotal());
$this->assertEquals(19, $sut->getVat()); $this->assertEquals(19, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency()); $this->assertEquals('EUR', $sut->getCurrency());
@@ -95,30 +97,18 @@ class ShortInvoiceCalculatorTest extends TestCase
$this->assertEquals(5800, $result->getDuration()); $this->assertEquals(5800, $result->getDuration());
} }
public function testDescriptionByTimesheet()
{
$this->assertDescription(new ShortInvoiceCalculator(), false, false);
}
public function testDescriptionByActivity()
{
$this->assertDescription(new ShortInvoiceCalculator(), false, true);
}
public function testDescriptionByProject() public function testDescriptionByProject()
{ {
$customer = new Customer(); $this->assertDescription(new ShortInvoiceCalculator(), true, false);
$template = new InvoiceTemplate();
$template->setVat(19);
$project = new Project();
$project->setName('project description');
$query = new InvoiceQuery();
$query->setProject($project);
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries([]);
$model->setQuery($query);
$sut = new ShortInvoiceCalculator();
$sut->setModel($model);
$this->assertEquals(1, count($sut->getEntries()));
/** @var Timesheet $result */
$result = $sut->getEntries()[0];
$this->assertEquals('project description', $result->getDescription());
} }
} }

View File

@@ -0,0 +1,129 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Calculator;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Invoice\Calculator\UserInvoiceCalculator;
use App\Model\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
/**
* @covers \App\Invoice\Calculator\UserInvoiceCalculator
* @covers \App\Invoice\Calculator\AbstractCalculator
*/
class UserInvoiceCalculatorTest extends AbstractCalculatorTest
{
public function testEmptyModel()
{
$this->assertEmptyModel(new UserInvoiceCalculator());
}
public function testWithMultipleEntries()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$template->setVat(19);
$activity = new Activity();
$activity->setName('activity description');
$user1 = $this->getMockBuilder(User::class)->setMethods(['getId'])->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user2 = $this->getMockBuilder(User::class)->setMethods(['getId'])->disableOriginalConstructor()->getMock();
$user2->method('getId')->willReturn(2);
$timesheet = new Timesheet();
$timesheet
->setDuration(3600)
->setRate(293.27)
->setUser($user1)
->setActivity($activity)
;
$timesheet2 = new Timesheet();
$timesheet2
->setDuration(400)
->setRate(84.75)
->setUser($user2)
->setActivity($activity)
;
$timesheet3 = new Timesheet();
$timesheet3
->setDuration(1800)
->setRate(111.11)
->setUser($user1)
->setActivity($activity)
;
$timesheet4 = new Timesheet();
$timesheet4
->setDuration(400)
->setRate(1947.99)
->setUser($user2)
->setActivity($activity)
;
$timesheet5 = new Timesheet();
$timesheet5
->setDuration(400)
->setRate(84)
->setUser(new User())
->setActivity($activity)
;
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
$query = new InvoiceQuery();
$query->setActivity($activity);
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
$model->setQuery($query);
$sut = new UserInvoiceCalculator();
$sut->setModel($model);
$this->assertEquals('user', $sut->getId());
$this->assertEquals(3000.13, $sut->getTotal());
$this->assertEquals(19, $sut->getVat());
$this->assertEquals('EUR', $sut->getCurrency());
$this->assertEquals(2521.12, $sut->getSubtotal());
$this->assertEquals(6600, $sut->getTimeWorked());
$this->assertEquals(3, count($sut->getEntries()));
$entries = $sut->getEntries();
$this->assertEquals(404.38, $entries[0]->getRate());
$this->assertEquals(2032.74, $entries[1]->getRate());
$this->assertEquals(84, $entries[2]->getRate());
}
public function testDescriptionByTimesheet()
{
$this->assertDescription(new UserInvoiceCalculator(), false, false);
}
public function testDescriptionByActivity()
{
$this->assertDescription(new UserInvoiceCalculator(), false, true);
}
public function testDescriptionByProject()
{
$this->assertDescription(new UserInvoiceCalculator(), true, false);
}
}

View File

@@ -0,0 +1,29 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Calculator;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Model\InvoiceModel;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Invoice\NumberGenerator\DateNumberGenerator
*/
class DateNumberGeneratorTest extends TestCase
{
public function testGetInvoiceNumber()
{
$sut = new DateNumberGenerator();
$sut->setModel(new InvoiceModel());
$this->assertEquals(date('ymd'), $sut->getInvoiceNumber());
$this->assertEquals('default', $sut->getId());
}
}

View File

@@ -0,0 +1,176 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Renderer;
use App\Entity\Activity;
use App\Entity\Customer;
use App\Entity\InvoiceDocument;
use App\Entity\InvoiceTemplate;
use App\Entity\Project;
use App\Entity\Timesheet;
use App\Entity\User;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\AbstractRenderer;
use App\Model\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
use App\Twig\DateExtensions;
use App\Twig\Extensions;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\Translation\TranslatorInterface;
abstract class AbstractRendererTest extends KernelTestCase
{
/**
* @return string
*/
protected function getInvoiceTemplatePath()
{
return __DIR__ . '/../../../templates/invoice/renderer/';
}
/**
* @param string $filename
* @return InvoiceDocument
*/
protected function getInvoiceDocument(string $filename)
{
return new InvoiceDocument(
new \SplFileInfo($this->getInvoiceTemplatePath() . $filename)
);
}
/**
* @param string $classname
* @return AbstractRenderer
*/
protected function getAbstractRenderer(string $classname)
{
$requestStack = new RequestStack();
$dateSettings = [];
$languages = [];
$request = new Request();
$request->setLocale('en');
$requestStack->push($request);
$translator = $this->getMockBuilder(TranslatorInterface::class)->getMock();
$dateExtension = new DateExtensions($requestStack, $dateSettings);
$extensions = new Extensions($requestStack, $languages);
return new $classname($translator, $dateExtension, $extensions);
}
/**
* @return InvoiceModel
*/
protected function getInvoiceModel()
{
$customer = new Customer();
$template = new InvoiceTemplate();
$template->setTitle('a test invoice template title');
$template->setVat(19);
$project = new Project();
$project->setName('project name');
$project->setCustomer($customer);
$activity = new Activity();
$activity->setName('activity description');
$activity->setProject($project);
$userMethods = ['getId', 'getPreferenceValue', 'getUsername'];
$user1 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock();
$user1->method('getId')->willReturn(1);
$user1->method('getPreferenceValue')->willReturn('50');
$user1->method('getUsername')->willReturn('foo-bar');
$user2 = $this->getMockBuilder(User::class)->setMethods($userMethods)->disableOriginalConstructor()->getMock();
$user2->method('getId')->willReturn(2);
$user2->method('getUsername')->willReturn('hello-world');
$timesheet = new Timesheet();
$timesheet
->setDuration(3600)
->setRate(293.27)
->setUser($user1)
->setActivity($activity)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet2 = new Timesheet();
$timesheet2
->setDuration(400)
->setRate(84.75)
->setUser($user2)
->setActivity($activity)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet3 = new Timesheet();
$timesheet3
->setDuration(1800)
->setRate(111.11)
->setUser($user1)
->setActivity($activity)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet4 = new Timesheet();
$timesheet4
->setDuration(400)
->setRate(1947.99)
->setUser($user2)
->setActivity($activity)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$timesheet5 = new Timesheet();
$timesheet5
->setDuration(400)
->setFixedRate(84)
->setUser((new User())->setUsername('kevin'))
->setActivity($activity)
->setBegin(new \DateTime())
->setEnd(new \DateTime())
;
$entries = [$timesheet, $timesheet2, $timesheet3, $timesheet4, $timesheet5];
$query = new InvoiceQuery();
$query->setActivity($activity);
$query->setBegin(new \DateTime());
$query->setEnd(new \DateTime());
$model = new InvoiceModel();
$model->setCustomer($customer);
$model->setTemplate($template);
$model->setEntries($entries);
$model->setQuery($query);
$calculator = new DefaultCalculator();
$calculator->setModel($model);
$model->setCalculator($calculator);
$numberGenerator = new DateNumberGenerator();
$numberGenerator->setModel($model);
$model->setNumberGenerator($numberGenerator);
return $model;
}
}

View File

@@ -0,0 +1,67 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Renderer;
use App\Invoice\Renderer\CsvRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Invoice\Renderer\CsvRenderer
* @covers \App\Invoice\Renderer\AbstractRenderer
* @covers \App\Invoice\Renderer\AbstractSpreadsheetRenderer
*/
class CsvRendererTest extends AbstractRendererTest
{
public function testSupports()
{
$sut = $this->getAbstractRenderer(CsvRenderer::class);
$this->assertFalse($sut->supports($this->getInvoiceDocument('default.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
public function testRender()
{
/** @var CsvRenderer $sut */
$sut = $this->getAbstractRenderer(CsvRenderer::class);
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('export.csv');
/** @var BinaryFileResponse $response */
$response = $sut->render($document, $model);
$file = $response->getFile();
$this->assertEquals('text/csv', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=export.csv', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
$content = file_get_contents($file->getRealPath());
$this->assertNotContains('${', $content);
$this->assertContains(',"1,947.99"', $content);
$this->assertEquals(6, substr_count($content, PHP_EOL));
$this->assertEquals(5, substr_count($content, 'activity description'));
$this->assertEquals(1, substr_count($content, ',"kevin",'));
$this->assertEquals(2, substr_count($content, ',"hello-world",'));
$this->assertEquals(2, substr_count($content, ',"foo-bar",'));
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
$this->assertEquals($content, $content2);
$this->assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -0,0 +1,69 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Renderer;
use App\Invoice\Renderer\DocxRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Invoice\Renderer\DocxRenderer
* @covers \App\Invoice\Renderer\AbstractRenderer
*/
class DocxRendererTest extends AbstractRendererTest
{
public function testSupports()
{
$sut = $this->getAbstractRenderer(DocxRenderer::class);
$this->assertFalse($sut->supports($this->getInvoiceDocument('default.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
public function testRender()
{
/** @var DocxRenderer $sut */
$sut = $this->getAbstractRenderer(DocxRenderer::class);
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('company.docx');
/** @var BinaryFileResponse $response */
$response = $sut->render($document, $model);
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.wordprocessingml.document', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=company.docx', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
// TODO test document content?
/*
$content = file_get_contents($file->getRealPath());
$this->assertNotContains('${', $content);
$this->assertContains(',"1,947.99" ', $content);
$this->assertEquals(6, substr_count($content, PHP_EOL));
$this->assertEquals(5, substr_count($content, 'activity description'));
$this->assertEquals(1, substr_count($content, ',"kevin",'));
$this->assertEquals(2, substr_count($content, ',"hello-world",'));
$this->assertEquals(2, substr_count($content, ',"foo-bar",'));
*/
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
$this->assertNotEmpty($content2);
$this->assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Renderer;
use App\Invoice\Renderer\OdsRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Invoice\Renderer\OdsRenderer
* @covers \App\Invoice\Renderer\AbstractRenderer
* @covers \App\Invoice\Renderer\AbstractSpreadsheetRenderer
*/
class OdsRendererTest extends AbstractRendererTest
{
public function testSupports()
{
$sut = $this->getAbstractRenderer(OdsRenderer::class);
$this->assertFalse($sut->supports($this->getInvoiceDocument('default.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
public function testRender()
{
/** @var OdsRenderer $sut */
$sut = $this->getAbstractRenderer(OdsRenderer::class);
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('open-spreadsheet.ods');
/** @var BinaryFileResponse $response */
$response = $sut->render($document, $model);
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=open-spreadsheet.ods', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
// TODO test spreadsheet content?
/*
$content = file_get_contents($file->getRealPath());
$this->assertNotContains('${', $content);
$this->assertContains(',"1,947.99" ', $content);
$this->assertEquals(6, substr_count($content, PHP_EOL));
$this->assertEquals(5, substr_count($content, 'activity description'));
$this->assertEquals(1, substr_count($content, ',"kevin",'));
$this->assertEquals(2, substr_count($content, ',"hello-world",'));
$this->assertEquals(2, substr_count($content, ',"foo-bar",'));
*/
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
$this->assertNotEmpty($content2);
$this->assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Renderer;
use App\Invoice\Renderer\TwigRenderer;
use Symfony\Component\HttpFoundation\Request;
use Twig\Loader\FilesystemLoader;
/**
* @covers \App\Invoice\Renderer\TwigRenderer
*/
class TwigRendererTest extends AbstractRendererTest
{
public function testSupports()
{
$loader = new FilesystemLoader();
$env = new \Twig_Environment($loader);
$sut = new TwigRenderer($env);
$this->assertTrue($sut->supports($this->getInvoiceDocument('default.html.twig')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
public function testRender()
{
$kernel = self::bootKernel();
/** @var \Twig_Environment $twig */
$twig = $kernel->getContainer()->get('twig');
$stack = $kernel->getContainer()->get('request_stack');
$request = new Request();
$request->setLocale('en');
$stack->push($request);
/** @var FilesystemLoader $loader */
$loader = $twig->getLoader();
$loader->addPath($this->getInvoiceTemplatePath(), 'invoice');
$sut = new TwigRenderer($twig);
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('timesheet.html.twig');
$response = $sut->render($document, $model);
$content = $response->getContent();
$this->assertContains('<h2 class="page-header">
<span contenteditable="true">a test invoice template title</span>
</h2>', $content);
$this->assertEquals(5, substr_count($content, '<td>activity description / project name</td>'));
}
}

View File

@@ -0,0 +1,70 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice\Renderer;
use App\Invoice\Renderer\XlsxRenderer;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
/**
* @covers \App\Invoice\Renderer\XlsxRenderer
* @covers \App\Invoice\Renderer\AbstractRenderer
* @covers \App\Invoice\Renderer\AbstractSpreadsheetRenderer
*/
class XlsxRendererTest extends AbstractRendererTest
{
public function testSupports()
{
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
$this->assertFalse($sut->supports($this->getInvoiceDocument('default.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('freelancer.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('timesheet.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('foo.html.twig')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('company.docx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('export.csv')));
$this->assertTrue($sut->supports($this->getInvoiceDocument('spreadsheet.xlsx')));
$this->assertFalse($sut->supports($this->getInvoiceDocument('open-spreadsheet.ods')));
}
public function testRender()
{
/** @var XlsxRenderer $sut */
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
$model = $this->getInvoiceModel();
$document = $this->getInvoiceDocument('spreadsheet.xlsx');
/** @var BinaryFileResponse $response */
$response = $sut->render($document, $model);
$file = $response->getFile();
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
$this->assertEquals('attachment; filename=spreadsheet.xlsx', $response->headers->get('Content-Disposition'));
$this->assertTrue(file_exists($file->getRealPath()));
// TODO test document content?
/*
$content = file_get_contents($file->getRealPath());
$this->assertNotContains('${', $content);
$this->assertContains(',"1,947.99" ', $content);
$this->assertEquals(6, substr_count($content, PHP_EOL));
$this->assertEquals(5, substr_count($content, 'activity description'));
$this->assertEquals(1, substr_count($content, ',"kevin",'));
$this->assertEquals(2, substr_count($content, ',"hello-world",'));
$this->assertEquals(2, substr_count($content, ',"foo-bar",'));
*/
ob_start();
$response->sendContent();
$content2 = ob_get_clean();
$this->assertNotEmpty($content2);
$this->assertFalse(file_exists($file->getRealPath()));
}
}

View File

@@ -0,0 +1,77 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Invoice;
use App\Entity\InvoiceDocument;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Invoice\Renderer\TwigRenderer;
use App\Invoice\ServiceInvoice;
use App\Repository\InvoiceDocumentRepository;
use PHPUnit\Framework\TestCase;
class ServiceInvoiceTest extends TestCase
{
public function testEmptyObject()
{
$repo = new InvoiceDocumentRepository([]);
$sut = new ServiceInvoice($repo);
$this->assertEmpty($sut->getCalculator());
$this->assertInternalType('array', $sut->getCalculator());
$this->assertEmpty($sut->getRenderer());
$this->assertInternalType('array', $sut->getRenderer());
$this->assertEmpty($sut->getNumberGenerator());
$this->assertInternalType('array', $sut->getNumberGenerator());
$this->assertEmpty($sut->getDocuments());
$this->assertInternalType('array', $sut->getDocuments());
$this->assertNull($sut->getCalculatorByName('default'));
$this->assertNull($sut->getDocumentByName('default'));
$this->assertNull($sut->getNumberGeneratorByName('default'));
}
public function testWithDocumentDirectory()
{
$repo = new InvoiceDocumentRepository(['templates/invoice/renderer/']);
$sut = new ServiceInvoice($repo);
$actual = $sut->getDocuments();
$this->assertNotEmpty($actual);
foreach ($actual as $document) {
$this->assertInstanceOf(InvoiceDocument::class, $document);
}
$actual = $sut->getDocumentByName('default');
$this->assertInstanceOf(InvoiceDocument::class, $actual);
}
public function testAdd()
{
$repo = new InvoiceDocumentRepository([]);
$sut = new ServiceInvoice($repo);
$sut->addCalculator(new DefaultCalculator());
$sut->addNumberGenerator(new DateNumberGenerator());
$sut->addRenderer(
new TwigRenderer(
$this->getMockBuilder(\Twig_Environment::class)->disableOriginalConstructor()->getMock()
)
);
$this->assertEquals(1, count($sut->getCalculator()));
$this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculatorByName('default'));
$this->assertEquals(1, count($sut->getNumberGenerator()));
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGeneratorByName('default'));
$this->assertEquals(1, count($sut->getRenderer()));
}
}

View File

@@ -0,0 +1,39 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model;
use App\Model\ActivityStatistic;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\ActivityStatistic
*/
class ActivityStatisticTest extends TestCase
{
public function testDefaultValues()
{
$sut = new ActivityStatistic();
$this->assertEquals(0, $sut->getCount());
$this->assertEquals(0, $sut->getRecordAmount());
$this->assertEquals(0, $sut->getRecordDuration());
}
public function testSetter()
{
$sut = new ActivityStatistic();
$sut->setCount(123);
$sut->setRecordAmount(7654.298);
$sut->setRecordDuration(826.10);
$this->assertEquals(123, $sut->getCount());
$this->assertEquals(7654, $sut->getRecordAmount());
$this->assertEquals(826, $sut->getRecordDuration());
}
}

View File

@@ -0,0 +1,45 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model;
use App\Model\CustomerStatistic;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\CustomerStatistic
*/
class CustomerStatisticTest extends TestCase
{
public function testDefaultValues()
{
$sut = new CustomerStatistic();
$this->assertEquals(0, $sut->getActivityAmount());
$this->assertEquals(0, $sut->getProjectAmount());
$this->assertEquals(0, $sut->getCount());
$this->assertEquals(0, $sut->getRecordAmount());
$this->assertEquals(0, $sut->getRecordDuration());
}
public function testSetter()
{
$sut = new CustomerStatistic();
$sut->setCount(123);
$sut->setRecordAmount(7654.298);
$sut->setRecordDuration(826.10);
$sut->setActivityAmount(13);
$sut->setProjectAmount(2);
$this->assertEquals(13, $sut->getActivityAmount());
$this->assertEquals(2, $sut->getProjectAmount());
$this->assertEquals(123, $sut->getCount());
$this->assertEquals(7654, $sut->getRecordAmount());
$this->assertEquals(826, $sut->getRecordDuration());
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model;
use App\Model\DashboardSection;
use App\Model\Widget;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\DashboardSection
*/
class DashboardSectionTest extends TestCase
{
public function testDefaultValues()
{
$sut = new DashboardSection('test');
$this->assertEquals('test', $sut->getTitle());
$this->assertEquals(0, $sut->getOrder());
$this->assertEquals([], $sut->getWidgets());
$this->assertEquals(DashboardSection::TYPE_SIMPLE, $sut->getType());
}
public function testSetter()
{
$sut = new DashboardSection('hello-world');
$sut->setType(DashboardSection::TYPE_CHART);
$sut->setOrder(13);
$sut->addWidget(new Widget('bar', []));
$this->assertCount(1, $sut->getWidgets());
$this->assertEquals(DashboardSection::TYPE_CHART, $sut->getType());
$this->assertEquals(13, $sut->getOrder());
$this->assertEquals('bar', $sut->getWidgets()[0]->getTitle());
}
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model;
use App\Entity\Customer;
use App\Entity\InvoiceTemplate;
use App\Entity\Timesheet;
use App\Invoice\Calculator\DefaultCalculator;
use App\Invoice\NumberGenerator\DateNumberGenerator;
use App\Model\InvoiceModel;
use App\Repository\Query\InvoiceQuery;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\InvoiceModel
*/
class InvoiceModelTest extends TestCase
{
public function testDefaultValues()
{
$sut = new InvoiceModel();
$this->assertNull($sut->getNumberGenerator());
$this->assertNull($sut->getCalculator());
$this->assertNull($sut->getTemplate());
$this->assertNull($sut->getCustomer());
$this->assertNull($sut->getQuery());
$this->assertNull($sut->getDueDate());
$this->assertEmpty($sut->getEntries());
$this->assertInternalType('array', $sut->getEntries());
$this->assertInstanceOf(\DateTime::class, $sut->getInvoiceDate());
}
public function testSetter()
{
$sut = new InvoiceModel();
$sut->setTemplate((new InvoiceTemplate())->setDueDays(10));
$sut->setCustomer(new Customer());
$sut->setQuery(new InvoiceQuery());
$sut->setEntries([new Timesheet()]);
$sut->setNumberGenerator(new DateNumberGenerator());
$sut->setCalculator(new DefaultCalculator());
$this->assertInstanceOf(DateNumberGenerator::class, $sut->getNumberGenerator());
$this->assertInstanceOf(DefaultCalculator::class, $sut->getCalculator());
$this->assertInstanceOf(InvoiceTemplate::class, $sut->getTemplate());
$this->assertInstanceOf(Customer::class, $sut->getCustomer());
$this->assertInstanceOf(InvoiceQuery::class, $sut->getQuery());
$this->assertInstanceOf(\DateTime::class, $sut->getDueDate());
$this->assertInstanceOf(Timesheet::class, $sut->getEntries()[0]);
$this->assertInstanceOf(\DateTime::class, $sut->getInvoiceDate());
$this->assertEquals(
(new \DateTime('+10 days'))->format('Y-m-d'),
$sut->getDueDate()->format('Y-m-d')
);
}
}

View File

@@ -0,0 +1,42 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model;
use App\Model\ProjectStatistic;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\ProjectStatistic
*/
class ProjectStatisticTest extends TestCase
{
public function testDefaultValues()
{
$sut = new ProjectStatistic();
$this->assertEquals(0, $sut->getActivityAmount());
$this->assertEquals(0, $sut->getCount());
$this->assertEquals(0, $sut->getRecordAmount());
$this->assertEquals(0, $sut->getRecordDuration());
}
public function testSetter()
{
$sut = new ProjectStatistic();
$sut->setCount(123);
$sut->setRecordAmount(7654.298);
$sut->setRecordDuration(826.10);
$sut->setActivityAmount(13);
$this->assertEquals(13, $sut->getActivityAmount());
$this->assertEquals(123, $sut->getCount());
$this->assertEquals(7654, $sut->getRecordAmount());
$this->assertEquals(826, $sut->getRecordDuration());
}
}

View File

@@ -0,0 +1,65 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model\Statistic;
use App\Model\Statistic\Month;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\Statistic\Month
*/
class MonthTest extends TestCase
{
public function testDefaultValues()
{
$sut = new Month('01');
$this->assertEquals('01', $sut->getMonth());
$this->assertEquals(0, $sut->getTotalDuration());
$this->assertEquals(0, $sut->getTotalRate());
}
public function testAllowedMonths()
{
for ($i = 1; $i < 10; $i++) {
new Month('0' . $i);
}
for ($i = 10; $i < 13; $i++) {
new Month((string) $i);
}
$this->assertTrue(true);
}
public function testInvalidMonths()
{
foreach (['00', '13', '99', '0.9'] as $month) {
$ex = null;
try {
new Month($month);
} catch (\Exception $e) {
$ex = $e;
}
$this->assertInstanceOf(\InvalidArgumentException::class, $ex);
$this->assertEquals(
'Invalid month given, expected 01-12 but given: ' . ((int) $month),
$ex->getMessage()
);
}
}
public function testSetter()
{
$sut = new Month('01');
$sut->setTotalDuration(999.27);
$sut->setTotalRate(0.123456789);
$this->assertEquals(999.27, $sut->getTotalDuration());
$this->assertEquals(0.123456789, $sut->getTotalRate());
}
}

View File

@@ -0,0 +1,48 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model\Statistic;
use App\Model\Statistic\Month;
use App\Model\Statistic\Year;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\Statistic\Year
*/
class YearTest extends TestCase
{
public function testDefaultValues()
{
$sut = new Year('1999');
$this->assertNull($sut->getMonth('01'));
$this->assertEmpty($sut->getMonths());
$this->assertInternalType('array', $sut->getMonths());
$this->assertEquals('1999', $sut->getYear());
}
public function testSetter()
{
$sut = new Year('1999');
$sut->setMonth(new Month('01'));
$sut->setMonth(new Month('02'));
$sut->setMonth(new Month('03'));
$this->assertEquals(3, count($sut->getMonths()));
$sut->setMonth(new Month('01'));
$this->assertEquals(3, count($sut->getMonths()));
$this->assertInstanceOf(Month::class, $sut->getMonth(1));
$this->assertInstanceOf(Month::class, $sut->getMonth(2));
$this->assertInstanceOf(Month::class, $sut->getMonth(3));
$this->assertNull($sut->getMonth(4));
}
}

View File

@@ -0,0 +1,50 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Model;
use App\Model\TimesheetStatistic;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Model\TimesheetStatistic
*/
class TimesheetStatisticTest extends TestCase
{
public function testDefaultValues()
{
$sut = new TimesheetStatistic();
$this->assertEquals(0, $sut->getRecordsTotal());
$this->assertEquals(0, $sut->getAmountTotal());
$this->assertEquals(0, $sut->getAmountThisMonth());
$this->assertEquals(0, $sut->getDurationTotal());
$this->assertEquals(0, $sut->getDurationThisMonth());
$this->assertNull($sut->getFirstEntry());
}
public function testSetter()
{
$date = new \DateTime();
$sut = new TimesheetStatistic();
$sut->setRecordsTotal(2);
$sut->setAmountTotal(7654.298);
$sut->setAmountThisMonth(826.10);
$sut->setDurationTotal(13);
$sut->setDurationThisMonth(200);
$sut->setFirstEntry($date);
$this->assertEquals(2, $sut->getRecordsTotal());
$this->assertEquals(7654, $sut->getAmountTotal());
$this->assertEquals(826, $sut->getAmountThisMonth());
$this->assertEquals(13, $sut->getDurationTotal());
$this->assertEquals(200, $sut->getDurationThisMonth());
$this->assertSame($date, $sut->getFirstEntry());
}
}

View File

@@ -0,0 +1,60 @@
<?php
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace App\Tests\Repository;
use App\Entity\InvoiceDocument;
use App\Repository\InvoiceDocumentRepository;
use PHPUnit\Framework\TestCase;
/**
* @covers \App\Repository\InvoiceDocumentRepository
*/
class InvoiceDocumentRepositoryTest extends TestCase
{
protected static $defaultDirectories = [
'templates/invoice/renderer'
];
protected static $defaultDocuments = [
'company.docx',
'default.html.twig',
'export.csv',
'freelancer.html.twig',
'open-spreadsheet.ods',
'spreadsheet.xlsx',
'timesheet.html.twig',
];
public function testWithEmptyDirectory()
{
$sut = new InvoiceDocumentRepository([]);
$this->assertEmpty($sut->findAll());
$this->assertInternalType('array', $sut->findAll());
$this->assertNull($sut->findByName('default'));
}
public function testDefaultTemplatesExists()
{
$sut = new InvoiceDocumentRepository(self::$defaultDirectories);
$all = $sut->findAll();
$this->assertEquals(count(self::$defaultDocuments), count($all));
foreach ($all as $document) {
$this->assertTrue(in_array($document->getName(), self::$defaultDocuments));
}
foreach (self::$defaultDocuments as $filename) {
$filename = substr($filename, 0, strpos($filename, '.'));
$actual = $sut->findByName($filename);
$this->assertNotNull($actual);
$this->assertInstanceOf(InvoiceDocument::class, $actual);
}
}
}

View File

@@ -157,48 +157,6 @@ class RateCalculatorTest extends TestCase
$this->assertEquals(0, $record->getRate()); $this->assertEquals(0, $record->getRate());
} }
/**
* @expectedException \InvalidArgumentException
* @dataProvider getDisallowedFactors
*/
public function testCalculateWithInvalidFactor($factor)
{
$today = new \DateTime();
$day = $today->format('l');
$rules = [
'default' => [
'days' => [$day],
'factor' => $factor
],
];
$seconds = 41837;
$end = new \DateTime();
$start = clone $end;
$start->setTimestamp($end->getTimestamp() - $seconds);
$record = new Timesheet();
$record->setUser($this->getTestUser());
$record->setBegin($start);
$record->setDuration($seconds);
$record->setActivity(new Activity());
$this->assertEquals(0, $record->getRate());
$record->setEnd($end);
$sut = new RateCalculator($rules);
$sut->calculate($record);
}
public function getDisallowedFactors()
{
return [
[0],
[-1],
];
}
/** /**
* @dataProvider getRuleDefinitions * @dataProvider getRuleDefinitions
*/ */

View File

@@ -124,6 +124,7 @@ class ExtensionsTest extends TestCase
public function getMoneyData() public function getMoneyData()
{ {
return [ return [
['0 €', null, 'EUR', 'de'],
['2.345 €', 2345, 'EUR', 'de'], ['2.345 €', 2345, 'EUR', 'de'],
['2,345 €', 2345, 'EUR', 'en'], ['2,345 €', 2345, 'EUR', 'en'],
['2,345.01 €', 2345.009, 'EUR', 'en'], ['2,345.01 €', 2345.009, 'EUR', 'en'],

View File

@@ -41,6 +41,8 @@ class MarkdownExtensionTest extends TestCase
"- test<br />\n- foo", "- test<br />\n- foo",
$sut->timesheetContent("- test\n- foo") $sut->timesheetContent("- test\n- foo")
); );
$this->assertEquals('', $sut->timesheetContent(null));
$this->assertEquals('', $sut->timesheetContent(''));
$sut = new MarkdownExtension(new Markdown(), true); $sut = new MarkdownExtension(new Markdown(), true);
$this->assertEquals( $this->assertEquals(

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-09-09T16:00:00Z" source-language="en" target-language="de" datatype="plaintext" original="invoice-calculator.en.xliff">
<body>
<trans-unit id="label.invoice_calculator">
<source>label.invoice_calculator</source>
<target>Summen Berechnung</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Eintragsbasiert - ein Eintrag pro Zeitmessung (Standard)</target>
</trans-unit>
<trans-unit id="short">
<source>short</source>
<target>Stunden aufsummiert - nur ein Eintrag</target>
</trans-unit>
<trans-unit id="user">
<source>user</source>
<target>Benutzer gruppiert - ein Eintrag pro Benutzer</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-09-09T16:00:00Z" source-language="en" target-language="en" datatype="plaintext" original="none">
<body>
<trans-unit id="label.invoice_calculator">
<source>label.invoice_calculator</source>
<target>Sum calculation</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Entry-based - one entry per time measurement (default)</target>
</trans-unit>
<trans-unit id="short">
<source>short</source>
<target>Hours added up - only one entry</target>
</trans-unit>
<trans-unit id="user">
<source>user</source>
<target>User grouped - one entry per user</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-09-09T16:00:00Z" source-language="en" target-language="es" datatype="plaintext" original="invoice-calculator.en.xliff">
<body>
<trans-unit id="label.invoice_calculator">
<source>label.invoice_calculator</source>
<target>Cálculo de suma</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Tarifa por horas (default)</target>
</trans-unit>
<trans-unit id="short">
<source>short</source>
<target>Tarifa por horas - version mínima (sólo un registro)</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-09-09T16:00:00Z" source-language="en" target-language="fr" datatype="plaintext" original="invoice-calculator.en.xliff">
<body>
<trans-unit id="label.invoice_calculator">
<source>label.invoice_calculator</source>
<target>Calcul du total</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Tarifs horaires (par defaut)</target>
</trans-unit>
<trans-unit id="short">
<source>short</source>
<target>Tarifs horaires particuliers (seulement pour un enregistrement)</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-09-09T16:00:00Z" source-language="en" target-language="it" datatype="plaintext" original="invoice-calculator.en.xliff">
<body>
<trans-unit id="label.invoice_calculator">
<source>label.invoice_calculator</source>
<target>Calcolo somma</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Tariffa oraria (default)</target>
</trans-unit>
<trans-unit id="short">
<source>short</source>
<target>Tariffa oraria - versione breve (solo 1 riga)</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-09-09T16:00:00Z" source-language="en" target-language="ru" datatype="plaintext" original="invoice-calculator.en.xliff">
<body>
<trans-unit id="label.invoice_calculator">
<source>label.invoice_calculator</source>
<target>Суммарный учет</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Почасовая основа (стандартная)</target>
</trans-unit>
<trans-unit id="short">
<source>short</source>
<target>Почасовая основа - сокращенная версия (только одна запись)</target>
</trans-unit>
</body>
</file>
</xliff>

View File

@@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<xliff version="1.2" xmlns="urn:oasis:names:tc:xliff:document:1.2">
<file date="2018-09-09T16:00:00Z" source-language="en" target-language="de" datatype="plaintext" original="invoice-numbergenerator.en.xliff">
<body>
<trans-unit id="label.invoice_number_generator">
<source>label.invoice_number_generator</source>
<target>Rechnungsnummern-Generator</target>
</trans-unit>
<trans-unit id="default">
<source>default</source>
<target>Datum (Standard)</target>
</trans-unit>
</body>
</file>
</xliff>

Some files were not shown because too many files have changed in this diff Show More