faster spreadsheet exporter based on opensout and other export improvements (#5238)
This commit is contained in:
@@ -41,6 +41,7 @@
|
||||
"nelmio/api-doc-bundle": "^4.0",
|
||||
"nelmio/cors-bundle": "^2.0",
|
||||
"onelogin/php-saml": "^4.0",
|
||||
"openspout/openspout": "^4.0",
|
||||
"pagerfanta/pagerfanta": "^3.0",
|
||||
"phpoffice/phpspreadsheet": "^2.0",
|
||||
"phpoffice/phpword": "^1.0",
|
||||
|
||||
95
composer.lock
generated
95
composer.lock
generated
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "455e6cae5b8c0ce7458599a0f317c2d3",
|
||||
"content-hash": "9f659b51f4e0278a3fc52665e4be66a5",
|
||||
"packages": [
|
||||
{
|
||||
"name": "azuyalabs/yasumi",
|
||||
@@ -3301,6 +3301,99 @@
|
||||
],
|
||||
"time": "2024-05-30T15:10:40+00:00"
|
||||
},
|
||||
{
|
||||
"name": "openspout/openspout",
|
||||
"version": "v4.25.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openspout/openspout.git",
|
||||
"reference": "519affe730d92e1598720a6467227fc28550f0e6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/openspout/openspout/zipball/519affe730d92e1598720a6467227fc28550f0e6",
|
||||
"reference": "519affe730d92e1598720a6467227fc28550f0e6",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-fileinfo": "*",
|
||||
"ext-filter": "*",
|
||||
"ext-libxml": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-zip": "*",
|
||||
"php": "~8.1.0 || ~8.2.0 || ~8.3.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-zlib": "*",
|
||||
"friendsofphp/php-cs-fixer": "^3.64.0",
|
||||
"infection/infection": "^0.29.6",
|
||||
"phpbench/phpbench": "^1.3.1",
|
||||
"phpstan/phpstan": "^1.12.4",
|
||||
"phpstan/phpstan-phpunit": "^1.4.0",
|
||||
"phpstan/phpstan-strict-rules": "^1.6.1",
|
||||
"phpunit/phpunit": "^10.5.20 || ^11.3.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)",
|
||||
"ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "3.3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"OpenSpout\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Adrien Loison",
|
||||
"email": "adrien@box.com"
|
||||
}
|
||||
],
|
||||
"description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way",
|
||||
"homepage": "https://github.com/openspout/openspout",
|
||||
"keywords": [
|
||||
"OOXML",
|
||||
"csv",
|
||||
"excel",
|
||||
"memory",
|
||||
"odf",
|
||||
"ods",
|
||||
"office",
|
||||
"open",
|
||||
"php",
|
||||
"read",
|
||||
"scale",
|
||||
"spreadsheet",
|
||||
"stream",
|
||||
"write",
|
||||
"xlsx"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/openspout/openspout/issues",
|
||||
"source": "https://github.com/openspout/openspout/tree/v4.25.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://paypal.me/filippotessarotto",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/Slamdunk",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2024-09-24T09:03:42+00:00"
|
||||
},
|
||||
{
|
||||
"name": "pagerfanta/pagerfanta",
|
||||
"version": "v3.8.0",
|
||||
|
||||
@@ -25,6 +25,7 @@ services:
|
||||
- '../src/Doctrine/Extensions/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Event/'
|
||||
- '../src/Export/Package/'
|
||||
- '../src/Form/Model/'
|
||||
- '../src/Model/'
|
||||
- '../src/Repository/Loader/'
|
||||
|
||||
55
phpstan.neon
55
phpstan.neon
@@ -1557,61 +1557,6 @@ parameters:
|
||||
count: 1
|
||||
path: src/Export/Annotation/Order.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getCurrency\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 4
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getMetaField\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getName\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getNumber\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getVatId\\(\\) on App\\\\Entity\\\\Customer\\|null\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$id of method Symfony\\\\Contracts\\\\Translation\\\\TranslatorInterface\\:\\:trans\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 5
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Activity\\:\\:getMetaField\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\ExportableItem\\:\\:getMetaField\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\Project\\:\\:getMetaField\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$name of method App\\\\Entity\\\\User\\:\\:getPreference\\(\\) expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Property App\\\\Export\\\\Base\\\\AbstractSpreadsheetRenderer\\:\\:\\$columns type has no value type specified in iterable type array\\.$#"
|
||||
count: 1
|
||||
path: src/Export/Base/AbstractSpreadsheetRenderer.php
|
||||
|
||||
-
|
||||
message: "#^Cannot call method getBudget\\(\\) on App\\\\Entity\\\\Project\\|null\\.$#"
|
||||
count: 1
|
||||
|
||||
@@ -27,7 +27,7 @@ if [[ -n $1 ]]; then
|
||||
elif [ "$1" == 'core' ]; then
|
||||
vendor/bin/phpstan analyse -c phpstan.neon ${ARGS}
|
||||
exit
|
||||
elif [ "$1" == 'test' ]; then
|
||||
elif [ "$1" == 'test' ] || [ "$1" == 'tests' ]; then
|
||||
vendor/bin/phpstan analyse -c tests/phpstan.neon ${ARGS}
|
||||
exit
|
||||
else
|
||||
|
||||
@@ -78,11 +78,6 @@ final class ProjectViewController extends AbstractController
|
||||
{
|
||||
$data = $this->getData($request, $service);
|
||||
|
||||
// Projektübersicht inkl. dem was Projektdetails anzeigen
|
||||
// Budget / Zeitbudget
|
||||
// Abrechenbar
|
||||
// Interner Preis
|
||||
|
||||
$content = $this->renderView('reporting/project_list_export.html.twig', $data);
|
||||
|
||||
$reader = new Html();
|
||||
|
||||
@@ -41,7 +41,7 @@ final class UserMonthController extends AbstractUserReportController
|
||||
#[Route(path: '/month_export', name: 'report_user_month_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$data = $this->getData($request);
|
||||
$data = $this->getData($request, true);
|
||||
|
||||
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
|
||||
|
||||
@@ -53,13 +53,14 @@ final class UserMonthController extends AbstractUserReportController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request): array
|
||||
private function getData(Request $request, bool $export = false): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
$canChangeUser = $this->canSelectUser();
|
||||
|
||||
$values = new MonthByUser();
|
||||
$values->setDecimal($export);
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->getStartOfMonth());
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ final class UserWeekController extends AbstractUserReportController
|
||||
#[Route(path: '/week_export', name: 'report_user_week_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request): Response
|
||||
{
|
||||
$data = $this->getData($request);
|
||||
$data = $this->getData($request, true);
|
||||
|
||||
$content = $this->renderView('reporting/report_by_user_data.html.twig', $data);
|
||||
|
||||
@@ -52,13 +52,14 @@ final class UserWeekController extends AbstractUserReportController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request): array
|
||||
private function getData(Request $request, bool $export = false): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
$canChangeUser = $this->canSelectUser();
|
||||
|
||||
$values = new WeekByUser();
|
||||
$values->setDecimal($export);
|
||||
$values->setUser($currentUser);
|
||||
$values->setDate($dateTimeFactory->getStartOfWeek());
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ final class UserYearController extends AbstractUserReportController
|
||||
#[Route(path: '/year_export', name: 'report_user_year_export', methods: ['GET', 'POST'])]
|
||||
public function export(Request $request, SystemConfiguration $systemConfiguration): Response
|
||||
{
|
||||
$data = $this->getData($request, $systemConfiguration);
|
||||
$data = $this->getData($request, $systemConfiguration, true);
|
||||
|
||||
$content = $this->renderView('reporting/report_by_user_year_export.html.twig', $data);
|
||||
|
||||
@@ -51,13 +51,14 @@ final class UserYearController extends AbstractUserReportController
|
||||
return $writer->getFileResponse($spreadsheet);
|
||||
}
|
||||
|
||||
private function getData(Request $request, SystemConfiguration $systemConfiguration): array
|
||||
private function getData(Request $request, SystemConfiguration $systemConfiguration, bool $export = false): array
|
||||
{
|
||||
$currentUser = $this->getUser();
|
||||
$dateTimeFactory = $this->getDateTimeFactory($currentUser);
|
||||
$canChangeUser = $this->canSelectUser();
|
||||
|
||||
$values = new YearByUser();
|
||||
$values->setDecimal($export);
|
||||
$values->setUser($currentUser);
|
||||
|
||||
$defaultDate = $dateTimeFactory->createStartOfYear();
|
||||
|
||||
@@ -1,809 +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\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\MetaDisplayEventInterface;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use App\Utils\StringHelper;
|
||||
use DateTime;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
|
||||
use PhpOffice\PhpSpreadsheet\Cell\DataType;
|
||||
use PhpOffice\PhpSpreadsheet\Shared\Date;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Style\NumberFormat;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @internal means no BC promise whatsoever!
|
||||
*/
|
||||
abstract class AbstractSpreadsheetRenderer
|
||||
{
|
||||
public const DATETIME_FORMAT = 'yyyy-mm-dd hh:mm';
|
||||
public const TIME_FORMAT = 'hh:mm';
|
||||
public const DURATION_FORMAT = '[hh]:mm';
|
||||
public const DURATION_DECIMAL = '#0.00';
|
||||
|
||||
// https://support.microsoft.com/de-de/office/zahlenformatcodes-5026bbd6-04bc-48cd-bf33-80f18b4eae68
|
||||
// Part 1 = positive; Part 2 = negative; Part 3 = zero; Part 4 = Text
|
||||
public const RATE_FORMAT_DEFAULT = '#.##0,00 [$%1$s];-#.##0,00 [$%1$s]';
|
||||
public const RATE_FORMAT_LEFT = '_("%1$s"* #,##0.00_);_("%1$s"* -#,##0.00;_("%1$s"* "-"??_);_(@_)';
|
||||
public const RATE_FORMAT_RIGHT = '_(* "%1$s" #,##0.00_);_(* "%1$s" -#,##0.00;_(* "%1$s" "-"??_);_(@_)';
|
||||
|
||||
/**
|
||||
* @internal used in html to excel exporter
|
||||
*/
|
||||
public const RATE_FORMAT_NO_CURRENCY = '#,##0.00;-#,##0.00';
|
||||
|
||||
/**
|
||||
* @see self:RATE_FORMAT_*
|
||||
*/
|
||||
protected string $rateFormat = self::RATE_FORMAT_LEFT;
|
||||
protected string $durationFormat = self::DURATION_FORMAT;
|
||||
protected int $durationBase = 86400;
|
||||
/**
|
||||
* @var array<string, array>
|
||||
*/
|
||||
protected array $columns = [
|
||||
'date' => [],
|
||||
'begin' => [],
|
||||
'end' => [],
|
||||
'duration' => [],
|
||||
'rate' => [],
|
||||
'rate_internal' => [
|
||||
'label' => 'internalRate', // different translation key
|
||||
],
|
||||
'user' => [
|
||||
'label' => 'name'
|
||||
],
|
||||
'username' => [],
|
||||
'accountNumber' => [
|
||||
'label' => 'account_number'
|
||||
],
|
||||
'customer' => [],
|
||||
'project' => [],
|
||||
'activity' => [],
|
||||
'description' => [
|
||||
'maxWidth' => 50,
|
||||
'wrapText' => false,
|
||||
'sanitizeDDE' => true,
|
||||
],
|
||||
'exported' => [],
|
||||
'billable' => [],
|
||||
'tags' => [],
|
||||
'hourlyRate' => [],
|
||||
'fixedRate' => [],
|
||||
'timesheet-meta' => [],
|
||||
'customer-meta' => [],
|
||||
'project-meta' => [],
|
||||
'activity-meta' => [],
|
||||
'user-meta' => [],
|
||||
'type' => [],
|
||||
'category' => [],
|
||||
'customer_number' => [],
|
||||
'customer_vat' => [],
|
||||
'order_number' => [],
|
||||
];
|
||||
|
||||
public function __construct(
|
||||
protected TranslatorInterface $translator,
|
||||
protected LocaleFormatExtensions $dateExtension,
|
||||
protected EventDispatcherInterface $dispatcher,
|
||||
protected Security $voter
|
||||
) {
|
||||
}
|
||||
|
||||
protected function isRenderRate(TimesheetQuery $query): bool
|
||||
{
|
||||
if ($this->voter->getUser() === null) {
|
||||
// for command line export
|
||||
return true;
|
||||
}
|
||||
|
||||
if (null !== $query->getUser()) {
|
||||
return $this->voter->isGranted('view_rate_own_timesheet');
|
||||
}
|
||||
|
||||
return $this->voter->isGranted('view_rate_other_timesheet');
|
||||
}
|
||||
|
||||
protected function setFormattedDateTime(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
|
||||
{
|
||||
if (null === $date) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$excelDate = Date::PHPToExcel($date);
|
||||
|
||||
if ($excelDate === false) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
|
||||
// TODO why is that format hardcoded and does not depend on the users locale?
|
||||
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(self::DATETIME_FORMAT);
|
||||
}
|
||||
|
||||
protected function setFormattedTime(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
|
||||
{
|
||||
if (null === $date) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$excelDate = Date::PHPToExcel($date);
|
||||
|
||||
if ($excelDate === false) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
|
||||
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(self::TIME_FORMAT);
|
||||
}
|
||||
|
||||
protected function setFormattedDate(Worksheet $sheet, int $column, int $row, ?DateTime $date): void
|
||||
{
|
||||
if (null === $date) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), '');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$excelDate = Date::PHPToExcel($date);
|
||||
|
||||
if ($excelDate === false) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $date);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $excelDate);
|
||||
// TODO why is that format hardcoded and does not depend on the users locale?
|
||||
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(NumberFormat::FORMAT_DATE_YYYYMMDD);
|
||||
}
|
||||
|
||||
protected function setDurationTotal(Worksheet $sheet, int $column, int $row, string $startCoordinate, string $endCoordinate): void
|
||||
{
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), \sprintf('=SUBTOTAL(9,%s:%s)', $startCoordinate, $endCoordinate));
|
||||
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($column, $row));
|
||||
$style->getNumberFormat()->setFormatCode($this->durationFormat);
|
||||
}
|
||||
|
||||
protected function setDuration(Worksheet $sheet, int $column, int $row, ?int $duration): void
|
||||
{
|
||||
if (null === $duration) {
|
||||
$duration = 0;
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), \sprintf('=%s/%s', $duration, $this->durationBase));
|
||||
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode($this->durationFormat);
|
||||
}
|
||||
|
||||
protected function setRateTotal(Worksheet $sheet, int $column, int $row, string $startCoordinate, string $endCoordinate): void
|
||||
{
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), \sprintf('=SUBTOTAL(9,%s:%s)', $startCoordinate, $endCoordinate));
|
||||
}
|
||||
|
||||
protected function setRateStyle(Worksheet $sheet, int $column, int $row, ?string $currency): void
|
||||
{
|
||||
$sheet->getStyle(CellAddress::fromColumnAndRow($column, $row))->getNumberFormat()->setFormatCode(
|
||||
\sprintf($this->rateFormat, $currency ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
protected function setRate(Worksheet $sheet, int $column, int $row, ?float $rate, ?string $currency): void
|
||||
{
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $rate ?? 0.0);
|
||||
$this->setRateStyle($sheet, $column, $row, $currency);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MetaDisplayEventInterface $event
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
protected function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param TimesheetQuery $query
|
||||
* @param array<string, array<string, callable|int|float|false|null>> $columns
|
||||
* @return array<string, array<string, callable|int|float|false|null>>
|
||||
*/
|
||||
protected function getColumns(array $exportItems, TimesheetQuery $query, array $columns): array
|
||||
{
|
||||
if (null !== $query->getCurrentUser() && $query->getCurrentUser()->isExportDecimal()) {
|
||||
$this->durationFormat = self::DURATION_DECIMAL;
|
||||
$this->durationBase = 3600;
|
||||
}
|
||||
|
||||
$showRates = $this->isRenderRate($query);
|
||||
|
||||
if (isset($columns['date']) && !isset($columns['date']['render'])) {
|
||||
$columns['date']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$this->setFormattedDate($sheet, $column, $row, $entity->getBegin());
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['begin']) && !isset($columns['begin']['render'])) {
|
||||
$columns['begin']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$this->setFormattedTime($sheet, $column, $row, $entity->getBegin());
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['end']) && !isset($columns['end']['render'])) {
|
||||
$columns['end']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$this->setFormattedTime($sheet, $column, $row, $entity->getEnd());
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['duration']) && !isset($columns['duration']['render'])) {
|
||||
$columns['duration']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$this->setDuration($sheet, $column, $row, $entity->getDuration());
|
||||
};
|
||||
}
|
||||
|
||||
if ($showRates && isset($columns['rate']) && !isset($columns['rate']['render'])) {
|
||||
$columns['rate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$currency = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$currency = $entity->getProject()->getCustomer()->getCurrency();
|
||||
}
|
||||
$this->setRate($sheet, $column, $row, $entity->getRate(), $currency);
|
||||
};
|
||||
}
|
||||
|
||||
if ($showRates && isset($columns['rate_internal']) && !isset($columns['rate_internal']['render'])) {
|
||||
$columns['rate_internal']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$currency = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$currency = $entity->getProject()->getCustomer()->getCurrency();
|
||||
}
|
||||
$this->setRate($sheet, $column, $row, $entity->getInternalRate(), $currency);
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['user']) && !isset($columns['user']['render'])) {
|
||||
$columns['user']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$user = '';
|
||||
if (null !== $entity->getUser()) {
|
||||
$user = $entity->getUser()->getDisplayName();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $user);
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['username'])) {
|
||||
if (!isset($columns['username']['render'])) {
|
||||
$columns['username']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$username = '';
|
||||
if (null !== $entity->getUser()) {
|
||||
$username = $entity->getUser()->getUserIdentifier();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $username);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($columns['accountNumber'])) {
|
||||
if (!isset($columns['accountNumber']['render'])) {
|
||||
$columns['accountNumber']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$accountNumber = '';
|
||||
if (null !== $entity->getUser()) {
|
||||
$accountNumber = $entity->getUser()->getAccountNumber();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $accountNumber);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($columns['customer']) && !isset($columns['customer']['render'])) {
|
||||
$columns['customer']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$customer = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$customer = $entity->getProject()->getCustomer()->getName();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customer);
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['project']) && !isset($columns['project']['render'])) {
|
||||
$columns['project']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$project = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$project = $entity->getProject()->getName();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $project);
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['activity']) && !isset($columns['activity']['render'])) {
|
||||
$columns['activity']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$activity = '';
|
||||
if (null !== $entity->getActivity()) {
|
||||
$activity = $entity->getActivity()->getName();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $activity);
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['description']) && !isset($columns['description']['render'])) {
|
||||
$maxWidth = \array_key_exists('maxWidth', $columns['description']) && is_numeric($columns['description']['maxWidth']) ? (int) $columns['description']['maxWidth'] : null;
|
||||
$wrapText = \array_key_exists('wrapText', $columns['description']) ? (bool) $columns['description']['wrapText'] : false;
|
||||
$sanitizeText = \array_key_exists('sanitizeDDE', $columns['description']) ? (bool) $columns['description']['sanitizeDDE'] : true;
|
||||
|
||||
// This column has a column-only formatter to set the maximum width of a column.
|
||||
// It needs to be executed once, so we use this as a flag on when to skip it.
|
||||
$isColumnFormatted = false;
|
||||
|
||||
$columns['description']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use (&$isColumnFormatted, $maxWidth, $wrapText, $sanitizeText) {
|
||||
$cell = $sheet->getCell(CellAddress::fromColumnAndRow($column, $row));
|
||||
$desc = $entity->getDescription();
|
||||
|
||||
if ($sanitizeText && null !== $desc) {
|
||||
$desc = StringHelper::sanitizeDDE($desc);
|
||||
}
|
||||
|
||||
$cell->setValueExplicit($desc, DataType::TYPE_STRING);
|
||||
|
||||
// Apply wrap text if configured
|
||||
if ($wrapText) {
|
||||
$cell->getStyle()->getAlignment()->setWrapText(true);
|
||||
}
|
||||
|
||||
// Apply max width, only needs to be once per column
|
||||
if (!$isColumnFormatted) {
|
||||
if (null !== $maxWidth) {
|
||||
$sheet->getColumnDimensionByColumn($column)->setWidth($maxWidth);
|
||||
}
|
||||
$isColumnFormatted = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['exported']) && !isset($columns['exported']['render'])) {
|
||||
$columns['exported']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$exported = $entity->isExported() ? 'yes' : 'no';
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans($exported));
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['billable']) && !isset($columns['billable']['render'])) {
|
||||
$columns['billable']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$exported = $entity->isBillable() ? 'yes' : 'no';
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans($exported));
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['tags']) && !isset($columns['tags']['render'])) {
|
||||
$columns['tags']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), implode(',', $entity->getTagsAsArray()));
|
||||
};
|
||||
}
|
||||
|
||||
if ($showRates && isset($columns['hourlyRate']) && !isset($columns['hourlyRate']['render'])) {
|
||||
$columns['hourlyRate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$currency = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$currency = $entity->getProject()->getCustomer()->getCurrency();
|
||||
}
|
||||
$this->setRate($sheet, $column, $row, $entity->getHourlyRate(), $currency);
|
||||
};
|
||||
}
|
||||
|
||||
if ($showRates && isset($columns['fixedRate']) && !isset($columns['fixedRate']['render'])) {
|
||||
$columns['fixedRate']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$currency = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$currency = $entity->getProject()->getCustomer()->getCurrency();
|
||||
}
|
||||
$this->setRate($sheet, $column, $row, $entity->getFixedRate(), $currency);
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['timesheet-meta'])) {
|
||||
$timesheetMetaFields = $this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT));
|
||||
|
||||
$columns['timesheet-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($timesheetMetaFields): int {
|
||||
foreach ($timesheetMetaFields as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
|
||||
return \count($timesheetMetaFields);
|
||||
},
|
||||
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($timesheetMetaFields): int {
|
||||
foreach ($timesheetMetaFields as $metaField) {
|
||||
$metaFieldValue = '';
|
||||
$metaField = $entity->getMetaField($metaField->getName());
|
||||
if (null !== $metaField) {
|
||||
$metaFieldValue = $metaField->getValue();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
|
||||
}
|
||||
|
||||
return \count($timesheetMetaFields);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($columns['customer-meta'])) {
|
||||
$customerMetaFields = $this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT));
|
||||
|
||||
$columns['customer-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($customerMetaFields): int {
|
||||
foreach ($customerMetaFields as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
|
||||
return \count($customerMetaFields);
|
||||
},
|
||||
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($customerMetaFields): int {
|
||||
foreach ($customerMetaFields as $metaField) {
|
||||
$metaFieldValue = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$metaField = $entity->getProject()->getCustomer()->getMetaField($metaField->getName());
|
||||
if (null !== $metaField) {
|
||||
$metaFieldValue = $metaField->getValue();
|
||||
}
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
|
||||
}
|
||||
|
||||
return \count($customerMetaFields);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($columns['project-meta'])) {
|
||||
$projectMetaFields = $this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT));
|
||||
$columns['project-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($projectMetaFields): int {
|
||||
foreach ($projectMetaFields as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
|
||||
return \count($projectMetaFields);
|
||||
},
|
||||
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($projectMetaFields): int {
|
||||
foreach ($projectMetaFields as $metaField) {
|
||||
$metaFieldValue = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$metaField = $entity->getProject()->getMetaField($metaField->getName());
|
||||
if (null !== $metaField) {
|
||||
$metaFieldValue = $metaField->getValue();
|
||||
}
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
|
||||
}
|
||||
|
||||
return \count($projectMetaFields);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($columns['activity-meta'])) {
|
||||
$activityMetaFields = $this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT));
|
||||
$columns['activity-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($activityMetaFields): int {
|
||||
foreach ($activityMetaFields as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
|
||||
return \count($activityMetaFields);
|
||||
},
|
||||
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($activityMetaFields): int {
|
||||
foreach ($activityMetaFields as $metaField) {
|
||||
$metaFieldValue = '';
|
||||
if (null !== $entity->getActivity()) {
|
||||
$metaField = $entity->getActivity()->getMetaField($metaField->getName());
|
||||
if (null !== $metaField) {
|
||||
$metaFieldValue = $metaField->getValue();
|
||||
}
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
|
||||
}
|
||||
|
||||
return \count($activityMetaFields);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($columns['user-meta'])) {
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->dispatcher->dispatch($event);
|
||||
$userPreferences = $event->getPreferences();
|
||||
$columns['user-meta'] = [
|
||||
'header' => function (Worksheet $sheet, int $row, int $column) use ($userPreferences): int {
|
||||
foreach ($userPreferences as $metaField) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $this->translator->trans($metaField->getLabel()));
|
||||
}
|
||||
|
||||
return \count($userPreferences);
|
||||
},
|
||||
'render' => function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) use ($userPreferences): int {
|
||||
foreach ($userPreferences as $preference) {
|
||||
$metaFieldValue = '';
|
||||
if (null !== $entity->getUser()) {
|
||||
$metaField = $entity->getUser()->getPreference($preference->getName());
|
||||
if (null !== $metaField) {
|
||||
$metaFieldValue = $metaField->getValue();
|
||||
}
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column++, $row), $metaFieldValue);
|
||||
}
|
||||
|
||||
return \count($userPreferences);
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
if (isset($columns['type']) && !isset($columns['type']['render'])) {
|
||||
$columns['type']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $entity->getType());
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['category']) && !isset($columns['category']['render'])) {
|
||||
$columns['category']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $entity->getCategory());
|
||||
};
|
||||
}
|
||||
|
||||
if (isset($columns['customer_number'])) {
|
||||
if (!isset($columns['customer_number']['header'])) {
|
||||
$columns['customer_number']['header'] = function (Worksheet $sheet, int $row, int $column): int {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('number'));
|
||||
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
|
||||
if (!isset($columns['customer_number']['render'])) {
|
||||
$columns['customer_number']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$customerId = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$customerId = $entity->getProject()->getCustomer()->getNumber();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customerId);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($columns['customer_vat']) && !isset($columns['customer_vat']['render'])) {
|
||||
if (!isset($columns['customer_vat']['header'])) {
|
||||
$columns['customer_vat']['header'] = function (Worksheet $sheet, int $row, int $column): int {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('vat_id'));
|
||||
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
|
||||
if (!isset($columns['customer_vat']['render'])) {
|
||||
$columns['customer_vat']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$customerVat = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$customerVat = $entity->getProject()->getCustomer()->getVatId();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $customerVat);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($columns['order_number']) && !isset($columns['order_number']['render'])) {
|
||||
if (!isset($columns['order_number']['header'])) {
|
||||
$columns['order_number']['header'] = function (Worksheet $sheet, int $row, int $column): int {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $this->translator->trans('orderNumber'));
|
||||
|
||||
return 1;
|
||||
};
|
||||
}
|
||||
|
||||
if (!isset($columns['order_number']['render'])) {
|
||||
$columns['order_number']['render'] = function (Worksheet $sheet, int $row, int $column, ExportableItem $entity) {
|
||||
$orderNumber = '';
|
||||
if (null !== $entity->getProject()) {
|
||||
$orderNumber = $entity->getProject()->getOrderNumber();
|
||||
}
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $orderNumber);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!$showRates) {
|
||||
$removes = ['rate', 'fixedRate', 'hourlyRate', 'rate_internal'];
|
||||
foreach ($removes as $removeMe) {
|
||||
if (\array_key_exists($removeMe, $columns)) {
|
||||
unset($columns[$removeMe]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param TimesheetQuery $query
|
||||
* @return Spreadsheet
|
||||
* @throws \PhpOffice\PhpSpreadsheet\Exception
|
||||
*/
|
||||
protected function fromArrayToSpreadsheet(array $exportItems, TimesheetQuery $query): Spreadsheet
|
||||
{
|
||||
$spreadsheet = new Spreadsheet();
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
|
||||
// Set default row height to automatic, so we can specify wrap text columns later on
|
||||
// without bloating the output file as we would need to store stylesheet info for every cell.
|
||||
// LibreOffice is still not considering this flag, @see https://github.com/PHPOffice/PHPExcel/issues/588
|
||||
// with no solution implemented so nothing we can do about it there.
|
||||
$sheet->getDefaultRowDimension()->setRowHeight(-1);
|
||||
|
||||
$recordsHeaderColumn = 1;
|
||||
$recordsHeaderRow = 1;
|
||||
|
||||
$columns = $this->getColumns($exportItems, $query, $this->columns);
|
||||
|
||||
foreach ($columns as $label => $settings) {
|
||||
if (isset($settings['header'])) {
|
||||
if (!\is_callable($settings['header'])) {
|
||||
throw new \RuntimeException('Invalid header renderer given for: ' . $label);
|
||||
}
|
||||
$amount = $settings['header']($sheet, $recordsHeaderRow, $recordsHeaderColumn);
|
||||
$recordsHeaderColumn += $amount;
|
||||
} else {
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($recordsHeaderColumn++, $recordsHeaderRow), $this->translator->trans((\array_key_exists('label', $settings) && \is_string($settings['label'])) ? $settings['label'] : $label));
|
||||
}
|
||||
}
|
||||
|
||||
$entryHeaderRow = $recordsHeaderRow + 1;
|
||||
|
||||
$durationColumn = null;
|
||||
$rateColumn = null;
|
||||
$internalRateColumn = null;
|
||||
|
||||
foreach ($exportItems as $exportItem) {
|
||||
$entryHeaderColumn = 1;
|
||||
|
||||
foreach ($columns as $label => $settings) {
|
||||
if ($label === 'duration') {
|
||||
$durationColumn = $entryHeaderColumn;
|
||||
} elseif ($label === 'rate') {
|
||||
$rateColumn = $entryHeaderColumn;
|
||||
} elseif ($label === 'rate_internal') {
|
||||
$internalRateColumn = $entryHeaderColumn;
|
||||
}
|
||||
|
||||
if (!\array_key_exists('render', $settings) || !\is_callable($settings['render'])) {
|
||||
throw new \RuntimeException(\sprintf('Missing or invalid renderer for export column %s', $label));
|
||||
}
|
||||
|
||||
$amount = $settings['render']($sheet, $entryHeaderRow, $entryHeaderColumn, $exportItem);
|
||||
$entryHeaderColumn += (null === $amount) ? 1 : (int) $amount;
|
||||
}
|
||||
|
||||
$entryHeaderRow++;
|
||||
}
|
||||
|
||||
if ($this->isTotalRowSupported()) {
|
||||
if (null !== $durationColumn) {
|
||||
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($durationColumn, 2))->getCoordinate();
|
||||
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($durationColumn, $entryHeaderRow - 1))->getCoordinate();
|
||||
$this->setDurationTotal($sheet, $durationColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
|
||||
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($durationColumn, $entryHeaderRow));
|
||||
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
|
||||
$style->getFont()->setBold(true);
|
||||
}
|
||||
|
||||
if (null !== $rateColumn) {
|
||||
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($rateColumn, 2))->getCoordinate();
|
||||
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($rateColumn, $entryHeaderRow - 1))->getCoordinate();
|
||||
$this->setRateTotal($sheet, $rateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
|
||||
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($rateColumn, $entryHeaderRow));
|
||||
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
|
||||
$style->getFont()->setBold(true);
|
||||
}
|
||||
|
||||
if (null !== $internalRateColumn) {
|
||||
$startCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($internalRateColumn, 2))->getCoordinate();
|
||||
$endCoordinate = $sheet->getCell(CellAddress::fromColumnAndRow($internalRateColumn, $entryHeaderRow - 1))->getCoordinate();
|
||||
$this->setRateTotal($sheet, $internalRateColumn, $entryHeaderRow, $startCoordinate, $endCoordinate);
|
||||
$style = $sheet->getStyle(CellAddress::fromColumnAndRow($internalRateColumn, $entryHeaderRow));
|
||||
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
|
||||
$style->getFont()->setBold(true);
|
||||
}
|
||||
}
|
||||
|
||||
return $spreadsheet;
|
||||
}
|
||||
|
||||
protected function isTotalRowSupported(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
* @param TimesheetQuery $query
|
||||
* @return Response
|
||||
* @throws \PhpOffice\PhpSpreadsheet\Exception
|
||||
* @throws \PhpOffice\PhpSpreadsheet\Writer\Exception
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response
|
||||
{
|
||||
$spreadsheet = $this->fromArrayToSpreadsheet($exportItems, $query);
|
||||
$file = $this->saveSpreadsheet($spreadsheet);
|
||||
$filename = new ExportFilename($query);
|
||||
|
||||
return $this->getFileResponse($file, $filename->getFilename() . $this->getFileExtension());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
abstract public function getFileExtension(): string;
|
||||
|
||||
/**
|
||||
* @param string $file
|
||||
* @param string $filename
|
||||
* @return BinaryFileResponse
|
||||
*/
|
||||
protected function getFileResponse(string $file, string $filename): BinaryFileResponse
|
||||
{
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
abstract protected function getContentType(): string;
|
||||
|
||||
/**
|
||||
* @param Spreadsheet $spreadsheet
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
abstract protected function saveSpreadsheet(Spreadsheet $spreadsheet): string;
|
||||
}
|
||||
@@ -9,45 +9,21 @@
|
||||
|
||||
namespace App\Export\Base;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\Package\SpoutSpreadsheet;
|
||||
use App\Export\RendererInterface;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use OpenSpout\Writer\CSV\Writer;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class CsvRenderer extends AbstractSpreadsheetRenderer
|
||||
final class CsvRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getFileExtension(): string
|
||||
use ExportTrait;
|
||||
|
||||
public function __construct(private readonly SpreadsheetRenderer $spreadsheetRenderer)
|
||||
{
|
||||
return '.csv';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
protected function getContentType(): string
|
||||
{
|
||||
return 'text/csv';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Spreadsheet $spreadsheet
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-csv');
|
||||
if (false === $filename) {
|
||||
throw new \Exception('Could not open temporary file');
|
||||
}
|
||||
|
||||
$writer = IOFactory::createWriter($spreadsheet, 'Csv');
|
||||
$writer->save($filename);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
@@ -55,17 +31,38 @@ class CsvRenderer extends AbstractSpreadsheetRenderer
|
||||
return 'csv';
|
||||
}
|
||||
|
||||
protected function setDuration(Worksheet $sheet, int $column, int $row, ?int $duration): void
|
||||
public function getTitle(): string
|
||||
{
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), \sprintf('=%s', $duration ?? 0));
|
||||
return 'csv';
|
||||
}
|
||||
|
||||
protected function setRate(Worksheet $sheet, int $column, int $row, ?float $rate, ?string $currency): void
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response
|
||||
{
|
||||
$sheet->setCellValue(CellAddress::fromColumnAndRow($column, $row), $rate);
|
||||
if ($rate === 0.00) {
|
||||
return;
|
||||
return $this->getFileResponse(
|
||||
$this->renderFile($exportItems, $query),
|
||||
(new ExportFilename($query))->getFilename() . '.csv',
|
||||
'text/csv'
|
||||
);
|
||||
}
|
||||
$this->setRateStyle($sheet, $column, $row, $currency);
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
|
||||
{
|
||||
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-csv');
|
||||
if (false === $filename) {
|
||||
throw new \Exception('Could not open temporary file');
|
||||
}
|
||||
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer());
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
|
||||
return new \SplFileInfo($filename);
|
||||
}
|
||||
}
|
||||
|
||||
31
src/Export/Base/ExportTrait.php
Normal file
31
src/Export/Base/ExportTrait.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Base;
|
||||
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
|
||||
/**
|
||||
* @internal
|
||||
*/
|
||||
trait ExportTrait
|
||||
{
|
||||
protected function getFileResponse(string $file, string $filename, string $contentType): BinaryFileResponse
|
||||
{
|
||||
$response = new BinaryFileResponse($file);
|
||||
$disposition = $response->headers->makeDisposition(ResponseHeaderBag::DISPOSITION_ATTACHMENT, $filename);
|
||||
|
||||
$response->headers->set('Content-Type', $contentType);
|
||||
$response->headers->set('Content-Disposition', $disposition);
|
||||
$response->deleteFileAfterSend(true);
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,7 @@ use App\Event\MetaDisplayEventInterface;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
@@ -29,29 +30,22 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
|
||||
class HtmlRenderer
|
||||
class HtmlRenderer implements ExportRendererInterface
|
||||
{
|
||||
use RendererTrait;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $id = 'html';
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $template = 'default.html.twig';
|
||||
private string $id = 'html';
|
||||
private string $template = 'default.html.twig';
|
||||
|
||||
public function __construct(
|
||||
protected Environment $twig,
|
||||
protected EventDispatcherInterface $dispatcher,
|
||||
private ProjectStatisticService $projectStatisticService,
|
||||
private ActivityStatisticService $activityStatisticService
|
||||
protected readonly Environment $twig,
|
||||
protected readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly ActivityStatisticService $activityStatisticService
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param MetaDisplayEventInterface $event
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
protected function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
@@ -75,11 +69,6 @@ class HtmlRenderer
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $timesheets
|
||||
* @param TimesheetQuery $query
|
||||
* @return Response
|
||||
* @throws \Twig\Error\LoaderError
|
||||
* @throws \Twig\Error\RuntimeError
|
||||
* @throws \Twig\Error\SyntaxError
|
||||
*/
|
||||
public function render(array $timesheets, TimesheetQuery $query): Response
|
||||
{
|
||||
@@ -123,22 +112,23 @@ class HtmlRenderer
|
||||
return '@export/' . $this->template;
|
||||
}
|
||||
|
||||
public function setTemplate(string $filename): HtmlRenderer
|
||||
public function setTemplate(string $filename): void
|
||||
{
|
||||
$this->template = $filename;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setId(string $id): HtmlRenderer
|
||||
public function setId(string $id): void
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'print';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,8 @@ namespace App\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Pdf\PdfContext;
|
||||
use App\Pdf\PdfRendererTrait;
|
||||
@@ -21,7 +23,7 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
use Twig\Environment;
|
||||
use Twig\Extension\SandboxExtension;
|
||||
|
||||
class PDFRenderer implements DispositionInlineInterface
|
||||
class PDFRenderer implements DispositionInlineInterface, ExportRendererInterface, TimesheetExportInterface
|
||||
{
|
||||
use RendererTrait;
|
||||
use PDFRendererTrait;
|
||||
@@ -30,10 +32,19 @@ class PDFRenderer implements DispositionInlineInterface
|
||||
private string $template = 'default.pdf.twig';
|
||||
private array $pdfOptions = [];
|
||||
|
||||
public function __construct(private Environment $twig, private HtmlToPdfConverter $converter, private ProjectStatisticService $projectStatisticService)
|
||||
public function __construct(
|
||||
private readonly Environment $twig,
|
||||
private readonly HtmlToPdfConverter $converter,
|
||||
private readonly ProjectStatisticService $projectStatisticService
|
||||
)
|
||||
{
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'pdf';
|
||||
}
|
||||
|
||||
protected function getTemplate(): string
|
||||
{
|
||||
return '@export/' . $this->template;
|
||||
|
||||
215
src/Export/Base/SpreadsheetRenderer.php
Normal file
215
src/Export/Base/SpreadsheetRenderer.php
Normal file
@@ -0,0 +1,215 @@
|
||||
<?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\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Entity\MetaTableTypeInterface;
|
||||
use App\Event\ActivityMetaDisplayEvent;
|
||||
use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\MetaDisplayEventInterface;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Event\UserPreferenceDisplayEvent;
|
||||
use App\Export\Package\CellFormatter\ArrayFormatter;
|
||||
use App\Export\Package\CellFormatter\BooleanFormatter;
|
||||
use App\Export\Package\CellFormatter\DateFormatter;
|
||||
use App\Export\Package\CellFormatter\DefaultFormatter;
|
||||
use App\Export\Package\CellFormatter\DurationFormatter;
|
||||
use App\Export\Package\CellFormatter\RateFormatter;
|
||||
use App\Export\Package\CellFormatter\TextFormatter;
|
||||
use App\Export\Package\CellFormatter\TimeFormatter;
|
||||
use App\Export\Package\Column;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Repository\Query\ActivityQuery;
|
||||
use App\Repository\Query\CustomerQuery;
|
||||
use App\Repository\Query\ProjectQuery;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @internal means no BC promise whatsoever!
|
||||
*/
|
||||
final class SpreadsheetRenderer
|
||||
{
|
||||
public function __construct(
|
||||
protected TranslatorInterface $translator,
|
||||
protected EventDispatcherInterface $dispatcher,
|
||||
protected Security $voter
|
||||
) {
|
||||
}
|
||||
|
||||
private function isRenderRate(TimesheetQuery $query): bool
|
||||
{
|
||||
if ($this->voter->getUser() === null) {
|
||||
// for command line export
|
||||
return true;
|
||||
}
|
||||
|
||||
if (null !== $query->getUser()) {
|
||||
return $this->voter->isGranted('view_rate_own_timesheet');
|
||||
}
|
||||
|
||||
return $this->voter->isGranted('view_rate_other_timesheet');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return MetaTableTypeInterface[]
|
||||
*/
|
||||
private function findMetaColumns(MetaDisplayEventInterface $event): array
|
||||
{
|
||||
$this->dispatcher->dispatch($event);
|
||||
|
||||
return $event->getFields();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
public function writeSpreadsheet(SpreadsheetPackage $spreadsheetPackage, array $exportItems, TimesheetQuery $query): void
|
||||
{
|
||||
$columns = $this->getColumns($query);
|
||||
|
||||
$headerRow = [];
|
||||
foreach ($columns as $column) {
|
||||
$headerRow[] = $this->translator->trans($column->getHeader());
|
||||
}
|
||||
$spreadsheetPackage->setHeader($headerRow);
|
||||
|
||||
$currentRow = 1;
|
||||
foreach ($exportItems as $exportItem) {
|
||||
$cells = [];
|
||||
foreach ($columns as $column) {
|
||||
$cells[] = $column->getValue($exportItem);
|
||||
}
|
||||
$spreadsheetPackage->addRow($cells);
|
||||
$currentRow++;
|
||||
}
|
||||
|
||||
if ($currentRow > 1) {
|
||||
$totalColumns = ['duration', 'rate', 'internalRate'];
|
||||
$columnNames = range('A', 'Z');
|
||||
$totalRow = [];
|
||||
$totalColumn = 1;
|
||||
foreach ($columns as $column) {
|
||||
$formula = null;
|
||||
if (\in_array($column->getName(), $totalColumns)) {
|
||||
$columnName = $columnNames[$totalColumn - 1];
|
||||
$formula = \sprintf('=SUM(%s2:%s%s)', $columnName, $columnName, $currentRow);
|
||||
}
|
||||
$totalRow[] = $formula;
|
||||
$totalColumn++;
|
||||
}
|
||||
|
||||
$spreadsheetPackage->addRow($totalRow, ['totals' => true]);
|
||||
}
|
||||
|
||||
$spreadsheetPackage->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<Column>
|
||||
*/
|
||||
private function getColumns(TimesheetQuery $query): array
|
||||
{
|
||||
$showRates = $this->isRenderRate($query);
|
||||
|
||||
$columns = [];
|
||||
|
||||
$columns[] = (new Column('date', new DateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
$columns[] = (new Column('begin', new TimeFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getBegin());
|
||||
$columns[] = (new Column('end', new TimeFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getEnd());
|
||||
$columns[] = (new Column('duration', new DurationFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDuration());
|
||||
|
||||
if ($showRates) {
|
||||
$columns[] = (new Column('currency', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getCurrency());
|
||||
$columns[] = (new Column('rate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getRate());
|
||||
$columns[] = (new Column('internalRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getInternalRate());
|
||||
$columns[] = (new Column('hourlyRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getHourlyRate());
|
||||
$columns[] = (new Column('fixedRate', new RateFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getFixedRate());
|
||||
}
|
||||
|
||||
$columns[] = (new Column('username', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getDisplayName());
|
||||
$columns[] = (new Column('account_number', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getUser()?->getAccountNumber());
|
||||
$columns[] = (new Column('customer', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getName());
|
||||
$columns[] = (new Column('project', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getName());
|
||||
$columns[] = (new Column('activity', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getActivity()?->getName());
|
||||
$columns[] = (new Column('description', new TextFormatter(true)))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getDescription());
|
||||
//$columns[] = (new Column('exported', new BooleanFormatter()))->withExtractor(fn(ExportableItem $exportableItem) => $exportableItem->isExported());
|
||||
$columns[] = (new Column('billable', new BooleanFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->isBillable());
|
||||
$columns[] = (new Column('tags', new ArrayFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getTagsAsArray());
|
||||
$columns[] = (new Column('type', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getType());
|
||||
$columns[] = (new Column('category', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getCategory());
|
||||
$columns[] = (new Column('number', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getNumber());
|
||||
$columns[] = (new Column('project_number', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getNumber());
|
||||
$columns[] = (new Column('vat_id', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getCustomer()?->getVatId());
|
||||
$columns[] = (new Column('orderNumber', new DefaultFormatter()))->withExtractor(fn (ExportableItem $exportableItem) => $exportableItem->getProject()?->getOrderNumber());
|
||||
|
||||
foreach ($this->findMetaColumns(new TimesheetMetaDisplayEvent($query, TimesheetMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('timesheet.meta.' . $metaField->getName(), new DefaultFormatter()))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new CustomerMetaDisplayEvent($query->copyTo(new CustomerQuery()), CustomerMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('customer.meta.' . $metaField->getName(), new DefaultFormatter()))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getCustomer()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ProjectMetaDisplayEvent($query->copyTo(new ProjectQuery()), ProjectMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('project.meta.' . $metaField->getName(), new DefaultFormatter()))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getProject()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
foreach ($this->findMetaColumns(new ActivityMetaDisplayEvent($query->copyTo(new ActivityQuery()), ActivityMetaDisplayEvent::EXPORT)) as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('activity.meta.' . $metaField->getName(), new DefaultFormatter()))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getActivity()?->getMetaField($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
$event = new UserPreferenceDisplayEvent(UserPreferenceDisplayEvent::EXPORT);
|
||||
$this->dispatcher->dispatch($event);
|
||||
foreach ($event->getPreferences() as $metaField) {
|
||||
if ($metaField->getName() === null) {
|
||||
continue;
|
||||
}
|
||||
$columns[] = (new Column('user.meta.' . $metaField->getName(), new DefaultFormatter()))
|
||||
->withHeader($metaField->getLabel())
|
||||
->withExtractor(function (ExportableItem $exportableItem) use ($metaField) {
|
||||
return $exportableItem->getUser()?->getPreference($metaField->getName())?->getValue();
|
||||
});
|
||||
}
|
||||
|
||||
return $columns;
|
||||
}
|
||||
}
|
||||
@@ -9,85 +9,60 @@
|
||||
|
||||
namespace App\Export\Base;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\ExportFilename;
|
||||
use App\Export\Package\SpoutSpreadsheet;
|
||||
use App\Export\RendererInterface;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use OpenSpout\Writer\XLSX\Writer;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class XlsxRenderer extends AbstractSpreadsheetRenderer
|
||||
final class XlsxRenderer implements RendererInterface, TimesheetExportInterface
|
||||
{
|
||||
protected function isTotalRowSupported(): bool
|
||||
use ExportTrait;
|
||||
|
||||
public function __construct(private readonly SpreadsheetRenderer $spreadsheetRenderer)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getFileExtension(): string
|
||||
{
|
||||
return '.xlsx';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
protected function getContentType(): string
|
||||
{
|
||||
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Spreadsheet $spreadsheet
|
||||
* @return string
|
||||
* @throws \Exception
|
||||
*/
|
||||
protected function saveSpreadsheet(Spreadsheet $spreadsheet): string
|
||||
{
|
||||
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
|
||||
if (false === $filename) {
|
||||
throw new \Exception('Could not open temporary file');
|
||||
}
|
||||
|
||||
$this->applyStyles($spreadsheet);
|
||||
|
||||
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
|
||||
$writer->save($filename);
|
||||
|
||||
return $filename;
|
||||
}
|
||||
|
||||
protected function applyStyles(Spreadsheet $spreadsheet): void
|
||||
{
|
||||
// Store expensive calculations for later
|
||||
$sheet = $spreadsheet->getActiveSheet();
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
$highestColumn = $sheet->getHighestColumn();
|
||||
|
||||
// Enable auto filter for header row
|
||||
$sheet->setAutoFilter('A1:' . $highestColumn . '1');
|
||||
|
||||
// Freeze first row and date & time columns for easier navigation
|
||||
$sheet->freezePane('D2');
|
||||
|
||||
foreach ($sheet->getColumnIterator() as $columnName => $column) {
|
||||
// We default to a reasonable auto-width decided by the client,
|
||||
// sadly ->getDefaultColumnDimension() is not supported so it needs
|
||||
// to be specific about what column should be auto sized.
|
||||
$col = $sheet->getColumnDimension($columnName);
|
||||
|
||||
// If no other width is specified (which defaults to -1)
|
||||
if ((int) $col->getWidth() === -1) {
|
||||
$col->setAutoSize(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Text inside cells should be top left
|
||||
$sheet
|
||||
->getStyle('A2:' . $highestColumn . $highestRow)
|
||||
->getAlignment()
|
||||
->setVertical(Alignment::VERTICAL_TOP)
|
||||
->setHorizontal(Alignment::HORIZONTAL_LEFT);
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return 'xlsx';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'xlsx';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response
|
||||
{
|
||||
return $this->getFileResponse(
|
||||
$this->renderFile($exportItems, $query),
|
||||
(new ExportFilename($query))->getFilename() . '.xlsx',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param ExportableItem[] $exportItems
|
||||
*/
|
||||
public function renderFile(array $exportItems, TimesheetQuery $query): \SplFileInfo
|
||||
{
|
||||
$filename = @tempnam(sys_get_temp_dir(), 'kimai-export-xlsx');
|
||||
if (false === $filename) {
|
||||
throw new \Exception('Could not open temporary file');
|
||||
}
|
||||
|
||||
$spreadsheet = new SpoutSpreadsheet(new Writer());
|
||||
$spreadsheet->open($filename);
|
||||
|
||||
$this->spreadsheetRenderer->writeSpreadsheet($spreadsheet, $exportItems, $query);
|
||||
|
||||
return new \SplFileInfo($filename);
|
||||
}
|
||||
}
|
||||
|
||||
22
src/Export/Package/CellFormatter/ArrayFormatter.php
Normal file
22
src/Export/Package/CellFormatter/ArrayFormatter.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
final class ArrayFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (!\is_array($value)) {
|
||||
throw new \InvalidArgumentException('Only arrays are supported');
|
||||
}
|
||||
|
||||
return implode(', ', $value);
|
||||
}
|
||||
}
|
||||
26
src/Export/Package/CellFormatter/BooleanFormatter.php
Normal file
26
src/Export/Package/CellFormatter/BooleanFormatter.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
final class BooleanFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!\is_scalar($value)) {
|
||||
throw new \InvalidArgumentException('Only scalar values are supported');
|
||||
}
|
||||
|
||||
return (bool) $value;
|
||||
}
|
||||
}
|
||||
15
src/Export/Package/CellFormatter/CellFormatterInterface.php
Normal file
15
src/Export/Package/CellFormatter/CellFormatterInterface.php
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Package\CellFormatter;
|
||||
|
||||
interface CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed;
|
||||
}
|
||||
26
src/Export/Package/CellFormatter/DateFormatter.php
Normal file
26
src/Export/Package/CellFormatter/DateFormatter.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
final class DateFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('Y-m-d');
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException('Only DateTimeInterface can be formatted');
|
||||
}
|
||||
}
|
||||
26
src/Export/Package/CellFormatter/DefaultFormatter.php
Normal file
26
src/Export/Package/CellFormatter/DefaultFormatter.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
final class DefaultFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!\is_scalar($value)) {
|
||||
throw new \InvalidArgumentException('Only scalar values are supported');
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
22
src/Export/Package/CellFormatter/DurationFormatter.php
Normal file
22
src/Export/Package/CellFormatter/DurationFormatter.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
final class DurationFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return (float) number_format($value / 3600, 2, '.', '');
|
||||
}
|
||||
|
||||
return 0.0;
|
||||
}
|
||||
}
|
||||
26
src/Export/Package/CellFormatter/RateFormatter.php
Normal file
26
src/Export/Package/CellFormatter/RateFormatter.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
final class RateFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if (is_numeric($value)) {
|
||||
return (float) number_format((float) $value, 2, '.', '');
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return 0.0;
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException('Only numeric values be formatted');
|
||||
}
|
||||
}
|
||||
28
src/Export/Package/CellFormatter/TextFormatter.php
Normal file
28
src/Export/Package/CellFormatter/TextFormatter.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
use App\Utils\StringHelper;
|
||||
|
||||
final class TextFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function __construct(private readonly bool $sanitizeDde)
|
||||
{
|
||||
}
|
||||
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if ($this->sanitizeDde && \is_string($value)) {
|
||||
$value = StringHelper::sanitizeDDE($value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
26
src/Export/Package/CellFormatter/TimeFormatter.php
Normal file
26
src/Export/Package/CellFormatter/TimeFormatter.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
final class TimeFormatter implements CellFormatterInterface
|
||||
{
|
||||
public function formatValue(mixed $value): mixed
|
||||
{
|
||||
if ($value instanceof \DateTimeInterface) {
|
||||
return $value->format('H:i');
|
||||
}
|
||||
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
throw new \InvalidArgumentException('Only DateTimeInterface can be formatted');
|
||||
}
|
||||
}
|
||||
61
src/Export/Package/Column.php
Normal file
61
src/Export/Package/Column.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?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\Export\Package;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\Package\CellFormatter\CellFormatterInterface;
|
||||
|
||||
class Column
|
||||
{
|
||||
private ?string $header = null;
|
||||
private \Closure|null $extractor = null;
|
||||
|
||||
public function __construct(private readonly string $name, private readonly CellFormatterInterface $formatter)
|
||||
{
|
||||
}
|
||||
|
||||
public function getName(): string
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
public function withHeader(?string $header): Column
|
||||
{
|
||||
$this->header = $header;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function withExtractor(\Closure $extractor): Column
|
||||
{
|
||||
$this->extractor = $extractor;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function extract(ExportableItem $exportableItem): mixed
|
||||
{
|
||||
if ($this->extractor === null) {
|
||||
throw new \InvalidArgumentException('Missing extractor on column: ' . $this->name);
|
||||
}
|
||||
|
||||
return ($this->extractor)($exportableItem);
|
||||
}
|
||||
|
||||
public function getValue(ExportableItem $exportableItem): mixed
|
||||
{
|
||||
return $this->formatter->formatValue($this->extract($exportableItem));
|
||||
}
|
||||
|
||||
public function getHeader(): string
|
||||
{
|
||||
return $this->header ?? $this->name;
|
||||
}
|
||||
}
|
||||
131
src/Export/Package/PhpOfficeSpreadsheet.php
Normal file
131
src/Export/Package/PhpOfficeSpreadsheet.php
Normal file
@@ -0,0 +1,131 @@
|
||||
<?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\Export\Package;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\CellAddress;
|
||||
use PhpOffice\PhpSpreadsheet\IOFactory;
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Alignment;
|
||||
use PhpOffice\PhpSpreadsheet\Style\Border;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
|
||||
final class PhpOfficeSpreadsheet implements SpreadsheetPackage
|
||||
{
|
||||
private ?Spreadsheet $spreadsheet;
|
||||
private ?Worksheet $worksheet;
|
||||
private int $currentRow = 1;
|
||||
private ?string $filename = null;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->spreadsheet = new Spreadsheet();
|
||||
$this->worksheet = $this->spreadsheet->getActiveSheet();
|
||||
}
|
||||
|
||||
public function open(string $filename): void
|
||||
{
|
||||
$this->filename = $filename;
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
if ($this->filename === null) {
|
||||
throw new \Exception('Need to call open() first before save()');
|
||||
}
|
||||
|
||||
if ($this->spreadsheet === null || $this->worksheet === null) {
|
||||
throw new \Exception('Cannot re-use spreadsheet after calling save()');
|
||||
}
|
||||
|
||||
$sheet = $this->worksheet;
|
||||
// Store expensive calculations for later
|
||||
$highestRow = $sheet->getHighestRow();
|
||||
$highestColumn = $sheet->getHighestColumn();
|
||||
|
||||
// Enable auto filter for header row
|
||||
$sheet->setAutoFilter('A1:' . $highestColumn . '1');
|
||||
|
||||
// Freeze first row and date & time columns for easier navigation
|
||||
$sheet->freezePane('D2');
|
||||
|
||||
foreach ($sheet->getColumnIterator() as $columnName => $column) {
|
||||
// We default to a reasonable auto-width decided by the client,
|
||||
// sadly ->getDefaultColumnDimension() is not supported so it needs
|
||||
// to be specific about what column should be auto sized.
|
||||
$col = $sheet->getColumnDimension($columnName);
|
||||
|
||||
// If no other width is specified (which defaults to -1)
|
||||
if ((int) $col->getWidth() === -1) {
|
||||
$col->setAutoSize(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Text inside cells should be top left
|
||||
$sheet
|
||||
->getStyle('A2:' . $highestColumn . $highestRow)
|
||||
->getAlignment()
|
||||
->setVertical(Alignment::VERTICAL_TOP)
|
||||
->setHorizontal(Alignment::HORIZONTAL_LEFT);
|
||||
|
||||
$writer = IOFactory::createWriter($this->spreadsheet, 'Xlsx');
|
||||
$writer->save($this->filename);
|
||||
|
||||
$this->spreadsheet = null;
|
||||
$this->worksheet = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $columns
|
||||
*/
|
||||
public function setHeader(array $columns): void
|
||||
{
|
||||
if ($this->worksheet === null) {
|
||||
throw new \Exception('Cannot re-use spreadsheet after calling save()');
|
||||
}
|
||||
|
||||
$counter = 1;
|
||||
foreach ($columns as $column) {
|
||||
$pos = CellAddress::fromColumnAndRow($counter, 1);
|
||||
$this->worksheet->setCellValue($pos, $column);
|
||||
$style = $this->worksheet->getStyle($pos);
|
||||
$style->getBorders()->getBottom()->setBorderStyle(Border::BORDER_THIN);
|
||||
$style->getFont()->setBold(true);
|
||||
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$this->currentRow++;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $columns
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function addRow(array $columns, array $options = []): void
|
||||
{
|
||||
if ($this->worksheet === null) {
|
||||
throw new \Exception('Cannot re-use spreadsheet after calling save()');
|
||||
}
|
||||
|
||||
$counter = 1;
|
||||
foreach ($columns as $column) {
|
||||
$this->worksheet->setCellValue(CellAddress::fromColumnAndRow($counter, $this->currentRow), $column);
|
||||
|
||||
if (\array_key_exists('totals', $options) && $options['totals'] === true) {
|
||||
$style = $this->worksheet->getStyle(CellAddress::fromColumnAndRow($counter, $this->currentRow));
|
||||
$style->getBorders()->getTop()->setBorderStyle(Border::BORDER_THIN);
|
||||
$style->getFont()->setBold(true);
|
||||
}
|
||||
$counter++;
|
||||
}
|
||||
|
||||
$this->currentRow++;
|
||||
}
|
||||
}
|
||||
94
src/Export/Package/SpoutSpreadsheet.php
Normal file
94
src/Export/Package/SpoutSpreadsheet.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?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\Export\Package;
|
||||
|
||||
use App\Constants;
|
||||
use OpenSpout\Common\Entity\Cell;
|
||||
use OpenSpout\Common\Entity\Row;
|
||||
use OpenSpout\Common\Entity\Style\Border;
|
||||
use OpenSpout\Common\Entity\Style\BorderPart;
|
||||
use OpenSpout\Common\Entity\Style\Color;
|
||||
use OpenSpout\Common\Entity\Style\Style;
|
||||
use OpenSpout\Writer\AbstractWriterMultiSheets;
|
||||
use OpenSpout\Writer\CSV\Writer;
|
||||
use OpenSpout\Writer\WriterInterface;
|
||||
use OpenSpout\Writer\XLSX\Entity\SheetView;
|
||||
|
||||
class SpoutSpreadsheet implements SpreadsheetPackage
|
||||
{
|
||||
public function __construct(private readonly WriterInterface $writer)
|
||||
{
|
||||
$this->writer->setCreator(Constants::SOFTWARE);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string> $columns
|
||||
*/
|
||||
public function setHeader(array $columns): void
|
||||
{
|
||||
$tmp = [];
|
||||
foreach ($columns as $column) {
|
||||
$tmp[] = Cell::fromValue($column);
|
||||
}
|
||||
|
||||
$style = new Style();
|
||||
$style->setShouldWrapText(false);
|
||||
$style->setShouldShrinkToFit(true);
|
||||
$style->setBackgroundColor('EEEEEE');
|
||||
$style->setBorder(new Border(new BorderPart(Border::BOTTOM, Color::BLACK, Border::WIDTH_THIN, Border::STYLE_SOLID)));
|
||||
$style->setFontBold();
|
||||
|
||||
$this->writer->addRow(new Row($tmp, $style));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $columns
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function addRow(array $columns, array $options = []): void
|
||||
{
|
||||
$style = new Style();
|
||||
$style->setShouldWrapText(false);
|
||||
$style->setShouldShrinkToFit(true);
|
||||
|
||||
if (\array_key_exists('totals', $options) && $options['totals'] === true) {
|
||||
if ($this->writer instanceof Writer) {
|
||||
return;
|
||||
}
|
||||
$style->setBorder(new Border(new BorderPart(Border::TOP, Color::BLACK, Border::WIDTH_THIN, Border::STYLE_SOLID)));
|
||||
$style->setFontBold();
|
||||
}
|
||||
|
||||
$tmp = [];
|
||||
foreach ($columns as $column) {
|
||||
$tmp[] = Cell::fromValue($column); // @phpstan-ignore argument.type
|
||||
}
|
||||
|
||||
$this->writer->addRow(new Row($tmp, $style));
|
||||
}
|
||||
|
||||
public function open(string $filename): void
|
||||
{
|
||||
$this->writer->openToFile($filename);
|
||||
|
||||
if ($this->writer instanceof AbstractWriterMultiSheets) {
|
||||
$sheetView = new SheetView();
|
||||
$sheetView->setFreezeColumn('D');
|
||||
$sheetView->setFreezeRow(2);
|
||||
|
||||
$this->writer->getCurrentSheet()->setSheetView($sheetView);
|
||||
}
|
||||
}
|
||||
|
||||
public function save(): void
|
||||
{
|
||||
$this->writer->close();
|
||||
}
|
||||
}
|
||||
31
src/Export/Package/SpreadsheetPackage.php
Normal file
31
src/Export/Package/SpreadsheetPackage.php
Normal file
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Export\Package;
|
||||
|
||||
interface SpreadsheetPackage
|
||||
{
|
||||
/**
|
||||
* Pass the temporary filename where data will be written to.
|
||||
*/
|
||||
public function open(string $filename): void;
|
||||
|
||||
public function save(): void;
|
||||
|
||||
/**
|
||||
* @param array<string> $columns
|
||||
*/
|
||||
public function setHeader(array $columns): void;
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $columns
|
||||
* @param array<string, mixed> $options
|
||||
*/
|
||||
public function addRow(array $columns, array $options = []): void;
|
||||
}
|
||||
@@ -1,21 +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\Export\Renderer;
|
||||
|
||||
use App\Export\Base\CsvRenderer as BaseCsvRenderer;
|
||||
use App\Export\RendererInterface;
|
||||
|
||||
final class CsvRenderer extends BaseCsvRenderer implements RendererInterface
|
||||
{
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'csv';
|
||||
}
|
||||
}
|
||||
@@ -1,21 +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\Export\Renderer;
|
||||
|
||||
use App\Export\Base\HtmlRenderer as BaseHtmlRenderer;
|
||||
use App\Export\ExportRendererInterface;
|
||||
|
||||
final class HtmlRenderer extends BaseHtmlRenderer implements ExportRendererInterface
|
||||
{
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'print';
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@
|
||||
namespace App\Export\Renderer;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Export\Base\HtmlRenderer;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Twig\Environment;
|
||||
@@ -17,10 +18,10 @@ use Twig\Environment;
|
||||
final class HtmlRendererFactory
|
||||
{
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private EventDispatcherInterface $dispatcher,
|
||||
private ProjectStatisticService $projectStatisticService,
|
||||
private ActivityStatisticService $activityStatisticService
|
||||
private readonly Environment $twig,
|
||||
private readonly EventDispatcherInterface $dispatcher,
|
||||
private readonly ProjectStatisticService $projectStatisticService,
|
||||
private readonly ActivityStatisticService $activityStatisticService
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +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\Export\Renderer;
|
||||
|
||||
use App\Export\Base\PDFRenderer as BasePDFRenderer;
|
||||
use App\Export\ExportRendererInterface;
|
||||
|
||||
final class PDFRenderer extends BasePDFRenderer implements ExportRendererInterface
|
||||
{
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'pdf';
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
namespace App\Export\Renderer;
|
||||
|
||||
use App\Export\Base\PDFRenderer;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use Twig\Environment;
|
||||
@@ -16,9 +17,9 @@ use Twig\Environment;
|
||||
final class PdfRendererFactory
|
||||
{
|
||||
public function __construct(
|
||||
private Environment $twig,
|
||||
private HtmlToPdfConverter $converter,
|
||||
private ProjectStatisticService $projectStatisticService
|
||||
private readonly Environment $twig,
|
||||
private readonly HtmlToPdfConverter $converter,
|
||||
private readonly ProjectStatisticService $projectStatisticService
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +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\Export\Renderer;
|
||||
|
||||
use App\Export\Base\XlsxRenderer as BaseXlsxRenderer;
|
||||
use App\Export\RendererInterface;
|
||||
|
||||
final class XlsxRenderer extends BaseXlsxRenderer implements RendererInterface
|
||||
{
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'xlsx';
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,6 @@ namespace App\Export\Spreadsheet\Writer;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Spreadsheet;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
|
||||
|
||||
final class BinaryFileResponseWriter implements WriterInterface
|
||||
@@ -19,10 +18,9 @@ final class BinaryFileResponseWriter implements WriterInterface
|
||||
private string $prefix;
|
||||
|
||||
/**
|
||||
* @param WriterInterface $writer
|
||||
* @param string $prefix is only urlencoded but not validated and can break the response if you pass in invalid character
|
||||
*/
|
||||
public function __construct(private WriterInterface $writer, string $prefix)
|
||||
public function __construct(private readonly WriterInterface $writer, string $prefix)
|
||||
{
|
||||
$this->prefix = urlencode($prefix);
|
||||
}
|
||||
|
||||
@@ -1,21 +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\Export\Timesheet;
|
||||
|
||||
use App\Export\Base\CsvRenderer as BaseCsvRenderer;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
|
||||
final class CsvRenderer extends BaseCsvRenderer implements TimesheetExportInterface
|
||||
{
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'csv';
|
||||
}
|
||||
}
|
||||
@@ -23,9 +23,4 @@ final class HtmlRenderer extends BaseHtmlRenderer implements TimesheetExportInte
|
||||
{
|
||||
return 'print';
|
||||
}
|
||||
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'print';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +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\Export\Timesheet;
|
||||
|
||||
use App\Export\Base\PDFRenderer as BasePDFRenderer;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
|
||||
final class PDFRenderer extends BasePDFRenderer implements TimesheetExportInterface
|
||||
{
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'pdf';
|
||||
}
|
||||
}
|
||||
@@ -1,21 +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\Export\Timesheet;
|
||||
|
||||
use App\Export\Base\XlsxRenderer as BaseXlsxRenderer;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
|
||||
final class XlsxRenderer extends BaseXlsxRenderer implements TimesheetExportInterface
|
||||
{
|
||||
public function getTitle(): string
|
||||
{
|
||||
return 'xlsx';
|
||||
}
|
||||
}
|
||||
@@ -18,9 +18,9 @@ use Symfony\Component\HttpFoundation\Response;
|
||||
interface TimesheetExportInterface
|
||||
{
|
||||
/**
|
||||
* @param Timesheet[] $timesheets
|
||||
* @param Timesheet[] $exportItems
|
||||
*/
|
||||
public function render(array $timesheets, TimesheetQuery $query): Response;
|
||||
public function render(array $exportItems, TimesheetQuery $query): Response;
|
||||
|
||||
public function getId(): string;
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
'project': {'class': 'd-none d-sm-table-cell', 'orderBy': false},
|
||||
'activity': {'class': 'd-none', 'orderBy': false},
|
||||
'description': {'class': 'd-none d-xl-table-cell timesheet-description', 'orderBy': false},
|
||||
'tags': {'class': 'd-none d-xl-table-cell', 'orderBy': false},
|
||||
'unit_price': {'class': 'd-none text-nowrap text-end', 'orderBy': false},
|
||||
'duration': {'class': 'text-end text-nowrap', 'orderBy': false},
|
||||
'internalRate': {'class': 'text-end d-none text-nowrap', 'orderBy': false},
|
||||
@@ -229,6 +230,11 @@
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'description') }}">
|
||||
{{ entry.description|desc2html }}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'tags') }}">
|
||||
{% if entry.type == 'timesheet' %}
|
||||
{{ widgets.tag_list(entry.tags) }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="{{ tables.data_table_column_class(tableName, columns, 'unit_price') }}">
|
||||
{{ rate|money(currency) }}
|
||||
</td>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
{% if stats is empty %}
|
||||
{{ nothing_found() }}
|
||||
{% else %}
|
||||
{% embed 'reporting/customer/monthly_projects_data.html.twig' with {'dataTypeFormat': null, 'stats': stats, 'dataType': dataType, 'decimal': decimal} only %}
|
||||
{% embed 'reporting/customer/monthly_projects_data.html.twig' with {'stats': stats, 'dataType': dataType, 'decimal': decimal} only %}
|
||||
{% set rowspanStyle = 'vertical-align: middle' %}
|
||||
{% endembed %}
|
||||
{% endif %}
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
{% if project.activities[activity.id] is defined and i <= project.activities[activity.id]['users']|length %}
|
||||
{% set user = project.activities[activity.id]['users']|slice(-i) %}
|
||||
<td>{{ user.0.name }}</td>
|
||||
<td class="text-center"{% if dataTypeFormat is not null %} data-format="{{ dataTypeFormat }}"{% endif %}>
|
||||
<td class="text-center">
|
||||
{% set value = user.0[dataType] %}
|
||||
{% block user_activity %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
@@ -62,7 +62,7 @@
|
||||
{% endblock %}
|
||||
</td>
|
||||
{% if loop.parent.loop.first %}
|
||||
<td{% if rowspan > 1 %} rowspan="{{ rowspan }}"{% endif %} class="text-center text-nowrap"{% if dataTypeFormat is not null %} data-format="{{ dataTypeFormat }}"{% endif %}>
|
||||
<td{% if rowspan > 1 %} rowspan="{{ rowspan }}"{% endif %} class="text-center text-nowrap">
|
||||
{% set value = project.activities[activity.id][dataType] %}
|
||||
{% block project_activity %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
@@ -82,7 +82,7 @@
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% if loop.first %}
|
||||
<th{% if rowspan > 1 %} rowspan="{{ rowspan }}" style="{{ rowspanStyle }}"{% endif %} class="text-center text-nowrap"{% if dataTypeFormat is not null %} data-format="{{ dataTypeFormat }}"{% endif %}>
|
||||
<th{% if rowspan > 1 %} rowspan="{{ rowspan }}" style="{{ rowspanStyle }}"{% endif %} class="text-center text-nowrap">
|
||||
{% set value = project[dataType] %}
|
||||
{% block project_total %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
|
||||
@@ -1,30 +1,24 @@
|
||||
{% embed 'reporting/customer/monthly_projects_data.html.twig' with {'decimal': true, 'stats': stats, 'dataType': dataType} only %}
|
||||
{% set dataTypeFormat = null %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
{% set dataTypeFormat = constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::RATE_FORMAT_NO_CURRENCY') %}
|
||||
{% elseif dataType == 'duration' %}
|
||||
{% set dataTypeFormat = constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::DURATION_DECIMAL') %}
|
||||
{% endif %}
|
||||
{% set rowspanStyle = 'vertical-align:center' %}
|
||||
{% block user_activity %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
{{ value|money(currency) }}
|
||||
{% else %}
|
||||
{{ value / 3600 }}
|
||||
{{ value|duration(true) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block project_activity %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
{{ value|money(currency) }}
|
||||
{% else %}
|
||||
{{ value / 3600 }}
|
||||
{{ value|duration(true) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% block project_total %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
{{ value|money(currency) }}
|
||||
{% else %}
|
||||
{{ value / 3600 }}
|
||||
{{ value|duration(true) }}
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{% set columns = {
|
||||
'customer': {'title': 'customer'|trans},
|
||||
'name': {'title': 'name'|trans},
|
||||
'currency': {'title': 'currency'|trans},
|
||||
'budgetType': {'title': 'budgetType'|trans},
|
||||
} %}
|
||||
{% if is_granted('budget_time', 'project') %}
|
||||
@@ -54,6 +55,8 @@
|
||||
{% endif %}
|
||||
{% elseif name == 'customer' %}
|
||||
{{ project.customer.name }}
|
||||
{% elseif name == 'currency' %}
|
||||
{{ currency }}
|
||||
{% elseif name == 'lastRecord' %}
|
||||
{% if entry.lastRecord is not null %}
|
||||
{{ entry.lastRecord|date_short }}
|
||||
@@ -61,60 +64,56 @@
|
||||
–
|
||||
{% endif %}
|
||||
{% elseif name == 'today' %}
|
||||
{{ entry.durationDay|duration }}
|
||||
{{ entry.durationDay|duration(true) }}
|
||||
{% elseif name == 'week' %}
|
||||
{{ entry.durationWeek|duration }}
|
||||
{{ entry.durationWeek|duration(true) }}
|
||||
{% elseif name == 'month' %}
|
||||
{{ entry.durationMonth|duration }}
|
||||
{{ entry.durationMonth|duration(true) }}
|
||||
{% elseif name == 'timeBudget' %}
|
||||
{% if showTimeBudget %}
|
||||
{{ budgetStatisticModel.getTimeBudget()|duration }}
|
||||
{% else %}
|
||||
–
|
||||
{{ budgetStatisticModel.getTimeBudget()|duration(true) }}
|
||||
{% endif %}
|
||||
{% elseif name == 'durationTotal' %}
|
||||
{{ entry.durationTotal|duration }}
|
||||
{{ entry.durationTotal|duration(true) }}
|
||||
{% elseif name == 'billableTime' %}
|
||||
{% if canSeeTimeBudget %}
|
||||
{{ budgetStatisticModel.getTimeBudgetSpent()|duration }}
|
||||
{{ budgetStatisticModel.getTimeBudgetSpent()|duration(true) }}
|
||||
{% endif %}
|
||||
{% elseif name == 'exported' %}
|
||||
{% if canSeeTimeBudget %}
|
||||
{{ entry.notExportedDuration|duration }}
|
||||
{{ entry.notExportedDuration|duration(true) }}
|
||||
{% endif %}
|
||||
{% elseif name == 'budget' %}
|
||||
{% if showMoneyBudget %}
|
||||
{{ budgetStatisticModel.getBudget()|money(currency) }}
|
||||
{% else %}
|
||||
–
|
||||
{{ budgetStatisticModel.getBudget()|money }}
|
||||
{% endif %}
|
||||
{% elseif name == 'amountTotal' %}
|
||||
{% if canSeeMoneyBudget %}
|
||||
{{ budgetStatisticModel.getStatisticTotal().getRate()|money(currency) }}
|
||||
{{ budgetStatisticModel.getStatisticTotal().getRate()|money }}
|
||||
{% endif %}
|
||||
{% elseif name == 'billableMoney' %}
|
||||
{% if canSeeMoneyBudget %}
|
||||
{{ budgetStatisticModel.getBudgetSpent()|money(currency) }}
|
||||
{{ budgetStatisticModel.getBudgetSpent()|money }}
|
||||
{% endif %}
|
||||
{% elseif name == 'internalRate' %}
|
||||
{% if canSeeMoneyBudget %}
|
||||
{{ budgetStatisticModel.getInternalRate()|money(currency) }}
|
||||
{{ budgetStatisticModel.getInternalRate()|money }}
|
||||
{% endif %}
|
||||
{% elseif name == 'revenue' %}
|
||||
{% if canSeeMoneyBudget %}
|
||||
{{ revenue|money(currency) }}
|
||||
{{ revenue|money }}
|
||||
{% endif %}
|
||||
{% elseif name == 'hourlyBillable' %}
|
||||
{% if canSeeMoneyBudget and durationTotal > 0 %}
|
||||
{{ (budgetStatisticModel.getBudgetSpent() / durationTotal)|money(currency) }}
|
||||
{{ (budgetStatisticModel.getBudgetSpent() / durationTotal)|money }}
|
||||
{% endif %}
|
||||
{% elseif name == 'hourlyTotal' %}
|
||||
{% if canSeeMoneyBudget and durationTotal > 0 %}
|
||||
{{ (revenue / durationTotal)|money(currency) }}
|
||||
{{ (revenue / durationTotal)|money }}
|
||||
{% endif %}
|
||||
{% elseif name == 'invoiced' %}
|
||||
{% if canSeeMoneyBudget %}
|
||||
{{ entry.notExportedRate|money(currency) }}
|
||||
{{ entry.notExportedRate|money }}
|
||||
{% endif %}
|
||||
{% elseif name == 'projectStart' %}
|
||||
{% if project.start is not null %}{{ project.start|date_short }}{% endif %}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
{% embed 'reporting/user_list_period_data.html.twig' with {'avatar': false, 'showAccountNumber': true, 'stats': stats, 'dataType': dataType, 'period_attribute': period_attribute} only %}
|
||||
{% set dataTypeFormat = '' %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
{% set dataTypeFormat = ' data-format="' ~ constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::RATE_FORMAT_NO_CURRENCY') ~ '"' %}
|
||||
{% elseif dataType == 'duration' %}
|
||||
{% set dataTypeFormat = ' data-format="' ~ constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::DURATION_DECIMAL') ~ '"' %}
|
||||
{% endif %}
|
||||
{% block user_column %}
|
||||
{{ userPeriod.user.displayName|sanitize_dde }}
|
||||
{% endblock %}
|
||||
{% block duration -%}
|
||||
{{ period.totalDuration / 3600 }}
|
||||
{{ period.totalDuration|duration(true) }}
|
||||
{%- endblock %}
|
||||
{% block total_duration -%}
|
||||
{{ absoluteDuration / 3600 }}
|
||||
{{ absoluteDuration|duration(true) }}
|
||||
{%- endblock %}
|
||||
{% block total_duration_user -%}
|
||||
{{ usersTotalDuration / 3600 }}
|
||||
{{ usersTotalDuration|duration(true) }}
|
||||
{%- endblock %}
|
||||
{% block total_duration_period -%}
|
||||
{{ total / 3600 }}
|
||||
{{ total|duration(true) }}
|
||||
{%- endblock %}
|
||||
{% block rate %}
|
||||
{{ period.totalRate }}
|
||||
@@ -49,11 +43,4 @@
|
||||
{{ column|date_short }}
|
||||
</th>
|
||||
{% endblock %}
|
||||
{% block user_column_cell_attribute %} data-format="@"{% endblock %}
|
||||
{% block period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block user_total_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block total_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block total_rate_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block total_internal_rate_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block total_duration_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
@@ -1,24 +1,18 @@
|
||||
{% embed 'reporting/user_list_period_data.html.twig' with {'avatar': false, 'showAccountNumber': true, 'stats': stats, 'dataType': dataType, 'period_attribute': period_attribute} only %}
|
||||
{% set dataTypeFormat = '' %}
|
||||
{% if dataType == 'rate' or dataType == 'internalRate' %}
|
||||
{% set dataTypeFormat = ' data-format="' ~ constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::RATE_FORMAT_NO_CURRENCY') ~ '"' %}
|
||||
{% elseif dataType == 'duration' %}
|
||||
{% set dataTypeFormat = ' data-format="' ~ constant('App\\Export\\Base\\AbstractSpreadsheetRenderer::DURATION_DECIMAL') ~ '"' %}
|
||||
{% endif %}
|
||||
{% block user_column %}
|
||||
{{ userPeriod.user.displayName|sanitize_dde }}
|
||||
{% endblock %}
|
||||
{% block duration -%}
|
||||
{{ period.totalDuration / 3600 }}
|
||||
{{ period.totalDuration|duration(true) }}
|
||||
{%- endblock %}
|
||||
{% block total_duration -%}
|
||||
{{ absoluteDuration / 3600 }}
|
||||
{{ absoluteDuration|duration(true) }}
|
||||
{%- endblock %}
|
||||
{% block total_duration_user -%}
|
||||
{{ usersTotalDuration / 3600 }}
|
||||
{{ usersTotalDuration|duration(true) }}
|
||||
{%- endblock %}
|
||||
{% block total_duration_period -%}
|
||||
{{ total / 3600 }}
|
||||
{{ total|duration(true) }}
|
||||
{%- endblock %}
|
||||
{% block rate %}
|
||||
{{ period.totalRate }}
|
||||
@@ -49,11 +43,4 @@
|
||||
{{ column|month_name }} {{ column|date_format('Y') }}
|
||||
</th>
|
||||
{% endblock %}
|
||||
{% block user_column_cell_attribute %} data-format="@"{% endblock %}
|
||||
{% block period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block user_total_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block total_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block total_rate_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block total_internal_rate_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% block total_duration_period_cell_attribute %}{{ dataTypeFormat|raw }}{% endblock %}
|
||||
{% endembed %}
|
||||
|
||||
@@ -12,6 +12,7 @@ namespace App\Tests\Controller\Reporting;
|
||||
use App\Entity\User;
|
||||
use App\Tests\Controller\AbstractControllerBaseTestCase;
|
||||
use App\Tests\DataFixtures\TimesheetFixtures;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* @group integration
|
||||
@@ -30,6 +31,8 @@ abstract class AbstractUserPeriodControllerTestCase extends AbstractControllerBa
|
||||
|
||||
abstract protected function getReportUrl(): string;
|
||||
|
||||
abstract protected function getExportUrl(): string;
|
||||
|
||||
abstract protected function getBoxId(): string;
|
||||
|
||||
public function testIsSecure(): void
|
||||
@@ -61,6 +64,31 @@ abstract class AbstractUserPeriodControllerTestCase extends AbstractControllerBa
|
||||
self::assertEquals($title, $cell->text());
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestData
|
||||
*/
|
||||
public function testUserPeriodReportExport(int $user, string $dataType, string $title): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_SUPER_ADMIN);
|
||||
$this->importReportingFixture(User::ROLE_SUPER_ADMIN);
|
||||
$this->assertAccessIsGranted($client, \sprintf('%s?user=%s&date=2023-12-23&sumType=%s', $this->getExportUrl(), $user, $dataType));
|
||||
|
||||
$response = $client->getResponse();
|
||||
self::assertTrue($response->isSuccessful());
|
||||
self::assertInstanceOf(BinaryFileResponse::class, $response);
|
||||
|
||||
// temporary file will be deleted!
|
||||
$file = $response->getFile();
|
||||
self::assertFileDoesNotExist($response->getFile());
|
||||
|
||||
$disposition = $response->headers->get('Content-Disposition');
|
||||
self::assertIsString($disposition);
|
||||
self::assertNotEmpty($disposition);
|
||||
|
||||
self::assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
|
||||
self::assertStringContainsString('attachment; filename=kimai-export-user-', $disposition);
|
||||
}
|
||||
|
||||
public function testUserPeriodReportAsTeamlead(): void
|
||||
{
|
||||
$client = $this->getClientForAuthenticatedUser(User::ROLE_USER);
|
||||
|
||||
@@ -19,6 +19,11 @@ class UserMonthControllerTest extends AbstractUserPeriodControllerTestCase
|
||||
return '/reporting/user/month';
|
||||
}
|
||||
|
||||
protected function getExportUrl(): string
|
||||
{
|
||||
return '/reporting/user/month_export';
|
||||
}
|
||||
|
||||
protected function getBoxId(): string
|
||||
{
|
||||
return 'user-month-reporting-box';
|
||||
|
||||
@@ -19,6 +19,11 @@ class UserWeekControllerTest extends AbstractUserPeriodControllerTestCase
|
||||
return '/reporting/user/week';
|
||||
}
|
||||
|
||||
protected function getExportUrl(): string
|
||||
{
|
||||
return '/reporting/user/week_export';
|
||||
}
|
||||
|
||||
protected function getBoxId(): string
|
||||
{
|
||||
return 'user-week-reporting-box';
|
||||
|
||||
@@ -19,6 +19,11 @@ class UserYearControllerTest extends AbstractUserPeriodControllerTestCase
|
||||
return '/reporting/user/year';
|
||||
}
|
||||
|
||||
protected function getExportUrl(): string
|
||||
{
|
||||
return '/reporting/user/year_export';
|
||||
}
|
||||
|
||||
protected function getBoxId(): string
|
||||
{
|
||||
return 'user-year-reporting-box';
|
||||
|
||||
@@ -10,13 +10,13 @@
|
||||
namespace App\Tests\DependencyInjection\Compiler;
|
||||
|
||||
use App\DependencyInjection\Compiler\ExportServiceCompilerPass;
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Base\HtmlRenderer;
|
||||
use App\Export\Base\PDFRenderer;
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Export\ExportRepositoryInterface;
|
||||
use App\Export\Renderer\CsvRenderer;
|
||||
use App\Export\Renderer\HtmlRenderer;
|
||||
use App\Export\RendererInterface;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\Timesheet\PDFRenderer;
|
||||
use App\Export\Timesheet\XlsxRenderer;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Export\TimesheetExportRepository;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
@@ -7,41 +7,62 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Renderer;
|
||||
namespace App\Tests\Export\Base;
|
||||
|
||||
use App\Export\Renderer\CsvRenderer;
|
||||
use App\Entity\User;
|
||||
use App\Export\Base\CsvRenderer;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Base\CsvRenderer
|
||||
* @covers \App\Export\Base\AbstractSpreadsheetRenderer
|
||||
* @covers \App\Export\Base\SpreadsheetRenderer
|
||||
* @covers \App\Export\Base\RendererTrait
|
||||
* @covers \App\Export\Renderer\CsvRenderer
|
||||
* @covers \App\Export\Package\SpoutSpreadsheet
|
||||
* @group integration
|
||||
*/
|
||||
class CsvRendererTest extends AbstractRendererTestCase
|
||||
{
|
||||
protected function getAbstractRenderer(): CsvRenderer
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->any())->method('getUser')->willReturn(new User());
|
||||
$security->expects($this->any())->method('isGranted')->willReturn(true);
|
||||
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
|
||||
|
||||
return new CsvRenderer(new SpreadsheetRenderer($translator, $dispatcher, $security));
|
||||
}
|
||||
|
||||
public function testConfiguration(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer(CsvRenderer::class);
|
||||
$sut = $this->getAbstractRenderer();
|
||||
|
||||
self::assertEquals('csv', $sut->getId());
|
||||
self::assertEquals('csv', $sut->getTitle());
|
||||
}
|
||||
|
||||
public static function getTestModel()
|
||||
public static function getTestModel(): array
|
||||
{
|
||||
return [
|
||||
['400', '2437.12', ' EUR 1,947.99 ', 7, 6, 1, 2, 2]
|
||||
['400', '2437.12', '1947.99', 7, 6, 1, 2, 2]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestModel
|
||||
*/
|
||||
public function testRender($totalDuration, $totalRate, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3): void
|
||||
public function testRender(string $totalDuration, string $totalRate, string $expectedRate, int $expectedRows, int $expectedDescriptions, int $expectedUser1, int $expectedUser2, int $expectedUser3): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer(CsvRenderer::class);
|
||||
$sut = $this->getAbstractRenderer();
|
||||
|
||||
/** @var BinaryFileResponse $response */
|
||||
$response = $this->render($sut);
|
||||
@@ -53,17 +74,19 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
|
||||
self::assertTrue(file_exists($file->getRealPath()));
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
self::assertIsString($content);
|
||||
|
||||
self::assertStringContainsString('"' . $expectedRate . '"', $content);
|
||||
self::assertStringContainsString($expectedRate, $content);
|
||||
self::assertEquals($expectedRows, substr_count($content, PHP_EOL));
|
||||
self::assertEquals($expectedDescriptions, substr_count($content, 'activity description'));
|
||||
self::assertEquals($expectedUser1, substr_count($content, ',"kevin",'));
|
||||
self::assertEquals($expectedUser3, substr_count($content, ',"hello-world",'));
|
||||
self::assertEquals($expectedUser2, substr_count($content, ',"foo-bar",'));
|
||||
self::assertEquals($expectedDescriptions, substr_count($content, '"activity description"'));
|
||||
self::assertEquals($expectedUser1, substr_count($content, ',kevin,'));
|
||||
self::assertEquals($expectedUser3, substr_count($content, ',hello-world,'));
|
||||
self::assertEquals($expectedUser2, substr_count($content, ',foo-bar,'));
|
||||
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$content2 = ob_get_clean();
|
||||
self::assertIsString($content2);
|
||||
|
||||
self::assertEquals($content, $content2);
|
||||
self::assertFalse(file_exists($file->getRealPath()));
|
||||
@@ -71,6 +94,7 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
$all = [];
|
||||
$rows = str_getcsv($content2, PHP_EOL);
|
||||
foreach ($rows as $row) {
|
||||
self::assertIsString($row);
|
||||
$all[] = str_getcsv($row);
|
||||
}
|
||||
|
||||
@@ -78,70 +102,70 @@ class CsvRendererTest extends AbstractRendererTestCase
|
||||
'2019-06-16',
|
||||
'12:00',
|
||||
'12:06',
|
||||
'400',
|
||||
'0.11',
|
||||
'EUR',
|
||||
'0',
|
||||
'',
|
||||
'kevin',
|
||||
'0',
|
||||
'0',
|
||||
'84',
|
||||
'kevin',
|
||||
'',
|
||||
'Customer Name',
|
||||
'project name',
|
||||
'activity description',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'1',
|
||||
'foo, bar',
|
||||
'timesheet',
|
||||
'work',
|
||||
'A-0123456789',
|
||||
'',
|
||||
' EUR 84.00 ',
|
||||
'DE-9876543210',
|
||||
'ORDER-123',
|
||||
'meta-bar',
|
||||
'meta-bar2',
|
||||
'customer-bar',
|
||||
'',
|
||||
'project-foo2',
|
||||
'activity-bar',
|
||||
'timesheet',
|
||||
'work',
|
||||
'A-0123456789',
|
||||
'DE-9876543210',
|
||||
'ORDER-123',
|
||||
];
|
||||
|
||||
$expected2 = [
|
||||
'2019-06-16',
|
||||
'12:00',
|
||||
'12:06',
|
||||
'400',
|
||||
'0.11',
|
||||
'EUR',
|
||||
'0',
|
||||
'',
|
||||
'nivek',
|
||||
'0',
|
||||
'0',
|
||||
'-100.92',
|
||||
'nivek',
|
||||
'',
|
||||
'Customer Name',
|
||||
'project name',
|
||||
'activity description',
|
||||
'',
|
||||
'1',
|
||||
'',
|
||||
'timesheet',
|
||||
'work',
|
||||
'A-0123456789',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
' EUR -100.92',
|
||||
'DE-9876543210',
|
||||
'ORDER-123',
|
||||
'',
|
||||
'',
|
||||
'customer-bar',
|
||||
'',
|
||||
'project-foo2',
|
||||
'activity-bar',
|
||||
'timesheet',
|
||||
'work',
|
||||
'A-0123456789',
|
||||
'DE-9876543210',
|
||||
'ORDER-123',
|
||||
];
|
||||
|
||||
self::assertEquals(7, \count($all));
|
||||
self::assertEquals($expected, $all[5]);
|
||||
self::assertEquals($expected2, $all[6]);
|
||||
self::assertEquals(\count($expected), \count($all[0]));
|
||||
self::assertEquals('foo', $all[4][15]);
|
||||
self::assertEquals('foo', $all[4][16]);
|
||||
}
|
||||
}
|
||||
102
tests/Export/Base/SpreadsheetRendererTest.php
Normal file
102
tests/Export/Base/SpreadsheetRendererTest.php
Normal file
@@ -0,0 +1,102 @@
|
||||
<?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\Export\Base;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\Package\SpreadsheetPackage;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use Psr\EventDispatcher\EventDispatcherInterface;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Base\SpreadsheetRenderer
|
||||
* @group integration
|
||||
*/
|
||||
class SpreadsheetRendererTest extends AbstractRendererTestCase
|
||||
{
|
||||
public function testWriteSpreadsheetCreatesSpreadsheetWithCorrectHeaders(): void
|
||||
{
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
$translator->method('trans')->willReturnArgument(0);
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$security = $this->createMock(Security::class);
|
||||
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
|
||||
$spreadsheetPackage->expects(self::once())->method('setHeader')->with([
|
||||
'date', 'begin', 'end', 'duration', 'currency', 'rate', 'internalRate', 'hourlyRate', 'fixedRate', 'username', 'account_number', 'customer', 'project', 'activity', 'description', 'billable', 'tags', 'type', 'category', 'number', 'project_number', 'vat_id', 'orderNumber'
|
||||
]);
|
||||
|
||||
$renderer = new SpreadsheetRenderer($translator, $dispatcher, $security);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [], new TimesheetQuery());
|
||||
}
|
||||
|
||||
public function testWriteSpreadsheetAddsRowsForExportItems(): void
|
||||
{
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
$translator->method('trans')->willReturnArgument(0);
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$security = $this->createMock(Security::class);
|
||||
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
|
||||
$spreadsheetPackage->expects(self::exactly(2))->method('addRow');
|
||||
$spreadsheetPackage->expects(self::once())->method('save');
|
||||
|
||||
$exportItem = $this->createMock(ExportableItem::class);
|
||||
$exportItem->method('getBegin')->willReturn(new \DateTime());
|
||||
$exportItem->method('getEnd')->willReturn(new \DateTime());
|
||||
$exportItem->method('getDuration')->willReturn(3600);
|
||||
$exportItem->method('getRate')->willReturn(100.0);
|
||||
$exportItem->method('getInternalRate')->willReturn(80.0);
|
||||
$exportItem->method('getHourlyRate')->willReturn(50.0);
|
||||
$exportItem->method('getFixedRate')->willReturn(200.0);
|
||||
$exportItem->method('getUser')->willReturn(null);
|
||||
$exportItem->method('getProject')->willReturn(null);
|
||||
$exportItem->method('getActivity')->willReturn(null);
|
||||
$exportItem->method('getDescription')->willReturn('Test description');
|
||||
$exportItem->method('isBillable')->willReturn(true);
|
||||
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
|
||||
$exportItem->method('getType')->willReturn('type');
|
||||
$exportItem->method('getCategory')->willReturn('category');
|
||||
|
||||
$renderer = new SpreadsheetRenderer($translator, $dispatcher, $security);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem], new TimesheetQuery());
|
||||
}
|
||||
|
||||
public function testWriteSpreadsheetAddsTotalRowWhenMoreThanOneRow(): void
|
||||
{
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
$translator->method('trans')->willReturnArgument(0);
|
||||
$dispatcher = $this->createMock(EventDispatcherInterface::class);
|
||||
$security = $this->createMock(Security::class);
|
||||
$spreadsheetPackage = $this->createMock(SpreadsheetPackage::class);
|
||||
$spreadsheetPackage->expects(self::exactly(3))->method('addRow');
|
||||
|
||||
$exportItem = $this->createMock(ExportableItem::class);
|
||||
$exportItem->method('getBegin')->willReturn(new \DateTime());
|
||||
$exportItem->method('getEnd')->willReturn(new \DateTime());
|
||||
$exportItem->method('getDuration')->willReturn(3600);
|
||||
$exportItem->method('getRate')->willReturn(100.0);
|
||||
$exportItem->method('getInternalRate')->willReturn(80.0);
|
||||
$exportItem->method('getHourlyRate')->willReturn(50.0);
|
||||
$exportItem->method('getFixedRate')->willReturn(200.0);
|
||||
$exportItem->method('getUser')->willReturn(null);
|
||||
$exportItem->method('getProject')->willReturn(null);
|
||||
$exportItem->method('getActivity')->willReturn(null);
|
||||
$exportItem->method('getDescription')->willReturn('Test description');
|
||||
$exportItem->method('isBillable')->willReturn(true);
|
||||
$exportItem->method('getTagsAsArray')->willReturn(['tag1', 'tag2']);
|
||||
$exportItem->method('getType')->willReturn('type');
|
||||
$exportItem->method('getCategory')->willReturn('category');
|
||||
|
||||
$renderer = new SpreadsheetRenderer($translator, $dispatcher, $security);
|
||||
$renderer->writeSpreadsheet($spreadsheetPackage, [$exportItem, $exportItem], new TimesheetQuery());
|
||||
}
|
||||
}
|
||||
@@ -7,23 +7,44 @@
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Renderer;
|
||||
namespace App\Tests\Export\Base;
|
||||
|
||||
use App\Export\Renderer\XlsxRenderer;
|
||||
use App\Entity\User;
|
||||
use App\Export\Base\SpreadsheetRenderer;
|
||||
use App\Export\Base\XlsxRenderer;
|
||||
use App\Tests\Export\Renderer\AbstractRendererTestCase;
|
||||
use App\Tests\Export\Renderer\MetaFieldColumnSubscriber;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Base\XlsxRenderer
|
||||
* @covers \App\Export\Base\AbstractSpreadsheetRenderer
|
||||
* @covers \App\Export\Base\SpreadsheetRenderer
|
||||
* @covers \App\Export\Base\RendererTrait
|
||||
* @covers \App\Export\Renderer\XlsxRenderer
|
||||
* @covers \App\Export\Package\SpoutSpreadsheet
|
||||
* @group integration
|
||||
*/
|
||||
class XlsxRendererTest extends AbstractRendererTestCase
|
||||
{
|
||||
protected function getAbstractRenderer(): XlsxRenderer
|
||||
{
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->any())->method('getUser')->willReturn(new User());
|
||||
$security->expects($this->any())->method('isGranted')->willReturn(true);
|
||||
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
|
||||
|
||||
return new XlsxRenderer(new SpreadsheetRenderer($translator, $dispatcher, $security));
|
||||
}
|
||||
|
||||
public function testConfiguration(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
|
||||
$sut = $this->getAbstractRenderer();
|
||||
|
||||
self::assertEquals('xlsx', $sut->getId());
|
||||
self::assertEquals('xlsx', $sut->getTitle());
|
||||
@@ -31,7 +52,7 @@ class XlsxRendererTest extends AbstractRendererTestCase
|
||||
|
||||
public function testRender(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
|
||||
$sut = $this->getAbstractRenderer();
|
||||
|
||||
/** @var BinaryFileResponse $response */
|
||||
$response = $this->render($sut);
|
||||
42
tests/Export/Package/CellFormatter/ArrayFormatterTest.php
Normal file
42
tests/Export/Package/CellFormatter/ArrayFormatterTest.php
Normal 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\Export\Package\CellFormatter;
|
||||
|
||||
use App\Export\Package\CellFormatter\ArrayFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\CellFormatter\ArrayFormatter
|
||||
*/
|
||||
class ArrayFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsCommaSeparatedStringForArray(): void
|
||||
{
|
||||
$formatter = new ArrayFormatter();
|
||||
$result = $formatter->formatValue(['one', 'two', 'three']);
|
||||
self::assertEquals('one, two, three', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsExceptionForNonArray(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only arrays are supported');
|
||||
|
||||
$formatter = new ArrayFormatter();
|
||||
$formatter->formatValue('not an array');
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsEmptyStringForEmptyArray(): void
|
||||
{
|
||||
$formatter = new ArrayFormatter();
|
||||
$result = $formatter->formatValue([]);
|
||||
self::assertEquals('', $result);
|
||||
}
|
||||
}
|
||||
77
tests/Export/Package/CellFormatter/BooleanFormatterTest.php
Normal file
77
tests/Export/Package/CellFormatter/BooleanFormatterTest.php
Normal 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\Export\Package\CellFormatter;
|
||||
|
||||
use App\Export\Package\CellFormatter\BooleanFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\CellFormatter\BooleanFormatter
|
||||
*/
|
||||
class BooleanFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsFalseForNull(): void
|
||||
{
|
||||
$formatter = new BooleanFormatter();
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertFalse($result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsTrueForTrue(): void
|
||||
{
|
||||
$formatter = new BooleanFormatter();
|
||||
$result = $formatter->formatValue(true);
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFalseForFalse(): void
|
||||
{
|
||||
$formatter = new BooleanFormatter();
|
||||
$result = $formatter->formatValue(false);
|
||||
self::assertFalse($result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsTrueForNonZeroNumber(): void
|
||||
{
|
||||
$formatter = new BooleanFormatter();
|
||||
$result = $formatter->formatValue(1);
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFalseForZero(): void
|
||||
{
|
||||
$formatter = new BooleanFormatter();
|
||||
$result = $formatter->formatValue(0);
|
||||
self::assertFalse($result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsTrueForNonEmptyString(): void
|
||||
{
|
||||
$formatter = new BooleanFormatter();
|
||||
$result = $formatter->formatValue('non-empty');
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFalseForEmptyString(): void
|
||||
{
|
||||
$formatter = new BooleanFormatter();
|
||||
$result = $formatter->formatValue('');
|
||||
self::assertFalse($result);
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsExceptionForNonScalar(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only scalar values are supported');
|
||||
|
||||
$formatter = new BooleanFormatter();
|
||||
$formatter->formatValue([]);
|
||||
}
|
||||
}
|
||||
43
tests/Export/Package/CellFormatter/DateFormatterTest.php
Normal file
43
tests/Export/Package/CellFormatter/DateFormatterTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Package\CellFormatter;
|
||||
|
||||
use App\Export\Package\CellFormatter\DateFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\CellFormatter\DateFormatter
|
||||
*/
|
||||
class DateFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsFormattedDateForDateTime(): void
|
||||
{
|
||||
$formatter = new DateFormatter();
|
||||
$date = new \DateTime('2023-10-01');
|
||||
$result = $formatter->formatValue($date);
|
||||
self::assertEquals('2023-10-01', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsNullForNullValue(): void
|
||||
{
|
||||
$formatter = new DateFormatter();
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsExceptionForNonDateTime(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only DateTimeInterface can be formatted');
|
||||
|
||||
$formatter = new DateFormatter();
|
||||
$formatter->formatValue('not a date');
|
||||
}
|
||||
}
|
||||
56
tests/Export/Package/CellFormatter/DefaultFormatterTest.php
Normal file
56
tests/Export/Package/CellFormatter/DefaultFormatterTest.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
use App\Export\Package\CellFormatter\DefaultFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\CellFormatter\DefaultFormatter
|
||||
*/
|
||||
class DefaultFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsSameValueForScalar(): void
|
||||
{
|
||||
$formatter = new DefaultFormatter();
|
||||
$result = $formatter->formatValue('string');
|
||||
self::assertEquals('string', $result);
|
||||
|
||||
$result = $formatter->formatValue(123);
|
||||
self::assertEquals(123, $result);
|
||||
|
||||
$result = $formatter->formatValue(45.67);
|
||||
self::assertEquals(45.67, $result);
|
||||
|
||||
$result = $formatter->formatValue(true);
|
||||
self::assertTrue($result);
|
||||
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsExceptionForNonScalar(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only scalar values are supported');
|
||||
|
||||
$formatter = new DefaultFormatter();
|
||||
$formatter->formatValue([]);
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsExceptionForObject(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only scalar values are supported');
|
||||
|
||||
$formatter = new DefaultFormatter();
|
||||
$formatter->formatValue(new \stdClass());
|
||||
}
|
||||
}
|
||||
54
tests/Export/Package/CellFormatter/DurationFormatterTest.php
Normal file
54
tests/Export/Package/CellFormatter/DurationFormatterTest.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
use App\Export\Package\CellFormatter\DurationFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\CellFormatter\DurationFormatter
|
||||
*/
|
||||
class DurationFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsFormattedDurationForNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(7200);
|
||||
self::assertEquals(2.00, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsZeroForNonNumericValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue('not a number');
|
||||
self::assertEquals(0.0, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForFloatValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(4500.5);
|
||||
self::assertEquals(1.25, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsZeroForNullValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertEquals(0.0, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedDurationForNegativeValue(): void
|
||||
{
|
||||
$formatter = new DurationFormatter();
|
||||
$result = $formatter->formatValue(-3600);
|
||||
self::assertEquals(-1.00, $result);
|
||||
}
|
||||
}
|
||||
78
tests/Export/Package/CellFormatter/RateFormatterTest.php
Normal file
78
tests/Export/Package/CellFormatter/RateFormatterTest.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?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\Export\Package\CellFormatter;
|
||||
|
||||
use App\Export\Package\CellFormatter\RateFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\CellFormatter\RateFormatter
|
||||
*/
|
||||
class RateFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsFormattedFloatForNumericValue(): void
|
||||
{
|
||||
$formatter = new RateFormatter();
|
||||
$result = $formatter->formatValue(1234.5678);
|
||||
self::assertEquals(1234.57, $result);
|
||||
|
||||
$result = $formatter->formatValue('1234.5678');
|
||||
self::assertEquals(1234.57, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsForNonNumericValue(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only numeric values be formatted');
|
||||
|
||||
$formatter = new RateFormatter();
|
||||
$result = $formatter->formatValue('not a number');
|
||||
self::assertEquals(0.0, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsZeroForNullValue(): void
|
||||
{
|
||||
$formatter = new RateFormatter();
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertEquals(0.0, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedFloatForNegativeNumericValue(): void
|
||||
{
|
||||
$formatter = new RateFormatter();
|
||||
$result = $formatter->formatValue(-1234.5678);
|
||||
self::assertEquals(-1234.57, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsFormattedFloatForFloatWithTwoDecimalPlaces(): void
|
||||
{
|
||||
$formatter = new RateFormatter();
|
||||
$result = $formatter->formatValue(1234.56);
|
||||
self::assertEquals(1234.56, $result);
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsExceptionForNonNumericNonNullValue(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only numeric values be formatted');
|
||||
|
||||
$formatter = new RateFormatter();
|
||||
$formatter->formatValue(new \stdClass());
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsExceptionFoArrayValue(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only numeric values be formatted');
|
||||
|
||||
$formatter = new RateFormatter();
|
||||
$formatter->formatValue([]);
|
||||
}
|
||||
}
|
||||
59
tests/Export/Package/CellFormatter/TextFormatterTest.php
Normal file
59
tests/Export/Package/CellFormatter/TextFormatterTest.php
Normal file
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Package\CellFormatter;
|
||||
|
||||
use App\Export\Package\CellFormatter\TextFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\CellFormatter\TextFormatter
|
||||
*/
|
||||
class TextFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsSanitizedStringWhenSanitizeDdeIsTrue(): void
|
||||
{
|
||||
$formatter = new TextFormatter(true);
|
||||
$result = $formatter->formatValue('=cmd|\' /C calc\'!A0');
|
||||
self::assertEquals('\' =cmd|\' /C calc\'!A0', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsOriginalStringWhenSanitizeDdeIsFalse(): void
|
||||
{
|
||||
$formatter = new TextFormatter(false);
|
||||
$result = $formatter->formatValue('=cmd|\' /C calc\'!A0');
|
||||
self::assertEquals('=cmd|\' /C calc\'!A0', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsOriginalValueForNonStringWhenSanitizeDdeIsTrue(): void
|
||||
{
|
||||
$formatter = new TextFormatter(true);
|
||||
$result = $formatter->formatValue(123);
|
||||
self::assertEquals(123, $result);
|
||||
|
||||
$result = $formatter->formatValue(45.67);
|
||||
self::assertEquals(45.67, $result);
|
||||
|
||||
$result = $formatter->formatValue(true);
|
||||
self::assertTrue($result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsOriginalValueForNonStringWhenSanitizeDdeIsFalse(): void
|
||||
{
|
||||
$formatter = new TextFormatter(false);
|
||||
$result = $formatter->formatValue(123);
|
||||
self::assertEquals(123, $result);
|
||||
|
||||
$result = $formatter->formatValue(45.67);
|
||||
self::assertEquals(45.67, $result);
|
||||
|
||||
$result = $formatter->formatValue(true);
|
||||
self::assertTrue($result);
|
||||
}
|
||||
}
|
||||
43
tests/Export/Package/CellFormatter/TimeFormatterTest.php
Normal file
43
tests/Export/Package/CellFormatter/TimeFormatterTest.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
* This file is part of the Kimai time-tracking app.
|
||||
*
|
||||
* For the full copyright and license information, please view the LICENSE
|
||||
* file that was distributed with this source code.
|
||||
*/
|
||||
|
||||
namespace App\Tests\Export\Package\CellFormatter;
|
||||
|
||||
use App\Export\Package\CellFormatter\TimeFormatter;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\CellFormatter\TimeFormatter
|
||||
*/
|
||||
class TimeFormatterTest extends TestCase
|
||||
{
|
||||
public function testFormatValueReturnsFormattedTimeForDateTimeInterface(): void
|
||||
{
|
||||
$formatter = new TimeFormatter();
|
||||
$dateTime = new \DateTime('2023-10-10 14:30:00');
|
||||
$result = $formatter->formatValue($dateTime);
|
||||
self::assertEquals('14:30', $result);
|
||||
}
|
||||
|
||||
public function testFormatValueReturnsNullForNullValue(): void
|
||||
{
|
||||
$formatter = new TimeFormatter();
|
||||
$result = $formatter->formatValue(null);
|
||||
self::assertNull($result);
|
||||
}
|
||||
|
||||
public function testFormatValueThrowsExceptionForNonDateTimeInterface(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Only DateTimeInterface can be formatted');
|
||||
|
||||
$formatter = new TimeFormatter();
|
||||
$formatter->formatValue('not a DateTime');
|
||||
}
|
||||
}
|
||||
88
tests/Export/Package/ColumnTest.php
Normal file
88
tests/Export/Package/ColumnTest.php
Normal 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\Tests\Export\Package;
|
||||
|
||||
use App\Entity\ExportableItem;
|
||||
use App\Export\Package\CellFormatter\CellFormatterInterface;
|
||||
use App\Export\Package\Column;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\Column
|
||||
*/
|
||||
class ColumnTest extends TestCase
|
||||
{
|
||||
public function testGetNameReturnsColumnName(): void
|
||||
{
|
||||
$formatter = $this->createMock(CellFormatterInterface::class);
|
||||
$column = new Column('testName', $formatter);
|
||||
self::assertEquals('testName', $column->getName());
|
||||
}
|
||||
|
||||
public function withHeaderSetsHeader(): void
|
||||
{
|
||||
$formatter = $this->createMock(CellFormatterInterface::class);
|
||||
$column = new Column('testName', $formatter);
|
||||
$column->withHeader('testHeader');
|
||||
self::assertEquals('testHeader', $column->getHeader());
|
||||
}
|
||||
|
||||
public function withExtractorSetsExtractor(): void
|
||||
{
|
||||
$formatter = $this->createMock(CellFormatterInterface::class);
|
||||
$column = new Column('testName', $formatter);
|
||||
$extractor = function (ExportableItem $item) {
|
||||
return $item->getId();
|
||||
};
|
||||
$column->withExtractor($extractor);
|
||||
$exportableItem = $this->createMock(ExportableItem::class);
|
||||
$exportableItem->method('getId')->willReturn(123);
|
||||
self::assertEquals(123, $column->extract($exportableItem));
|
||||
}
|
||||
|
||||
public function testExtractThrowsExceptionWhenExtractorIsNull(): void
|
||||
{
|
||||
$this->expectException(\InvalidArgumentException::class);
|
||||
$this->expectExceptionMessage('Missing extractor on column: testName');
|
||||
|
||||
$formatter = $this->createMock(CellFormatterInterface::class);
|
||||
$column = new Column('testName', $formatter);
|
||||
$exportableItem = $this->createMock(ExportableItem::class);
|
||||
$column->extract($exportableItem);
|
||||
}
|
||||
|
||||
public function testGetValueReturnsFormattedValue(): void
|
||||
{
|
||||
$formatter = $this->createMock(CellFormatterInterface::class);
|
||||
$formatter->method('formatValue')->willReturn('formattedValue');
|
||||
$column = new Column('testName', $formatter);
|
||||
$extractor = function (ExportableItem $item) {
|
||||
return 'rawValue';
|
||||
};
|
||||
$column->withExtractor($extractor);
|
||||
$exportableItem = $this->createMock(ExportableItem::class);
|
||||
self::assertEquals('formattedValue', $column->getValue($exportableItem));
|
||||
}
|
||||
|
||||
public function testGetHeaderReturnsHeaderWhenSet(): void
|
||||
{
|
||||
$formatter = $this->createMock(CellFormatterInterface::class);
|
||||
$column = new Column('testName', $formatter);
|
||||
$column->withHeader('testHeader');
|
||||
self::assertEquals('testHeader', $column->getHeader());
|
||||
}
|
||||
|
||||
public function testGetHeaderReturnsNameWhenHeaderIsNull(): void
|
||||
{
|
||||
$formatter = $this->createMock(CellFormatterInterface::class);
|
||||
$column = new Column('testName', $formatter);
|
||||
self::assertEquals('testName', $column->getHeader());
|
||||
}
|
||||
}
|
||||
121
tests/Export/Package/PhpOfficeSpreadsheetTest.php
Normal file
121
tests/Export/Package/PhpOfficeSpreadsheetTest.php
Normal file
@@ -0,0 +1,121 @@
|
||||
<?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\Export\Package;
|
||||
|
||||
use App\Export\Package\PhpOfficeSpreadsheet;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\PhpOfficeSpreadsheet
|
||||
*/
|
||||
class PhpOfficeSpreadsheetTest extends TestCase
|
||||
{
|
||||
private string $filename;
|
||||
private int $counter = 1;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->filename = realpath(__DIR__ . '/../../_data/') . '/test' . $this->counter++ . '.xlsx';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (file_exists($this->filename)) {
|
||||
unlink($this->filename);
|
||||
}
|
||||
}
|
||||
|
||||
public function testopenSetsFilename(): void
|
||||
{
|
||||
$spreadsheetPackage = new PhpOfficeSpreadsheet();
|
||||
$spreadsheetPackage->open($this->filename);
|
||||
$reflection = new \ReflectionClass($spreadsheetPackage);
|
||||
$property = $reflection->getProperty('filename');
|
||||
$property->setAccessible(true);
|
||||
self::assertEquals($this->filename, $property->getValue($spreadsheetPackage));
|
||||
}
|
||||
|
||||
public function testsaveThrowsExceptionWhenFilenameIsNull(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Need to call open() first before save()');
|
||||
|
||||
$spreadsheetPackage = new PhpOfficeSpreadsheet();
|
||||
$spreadsheetPackage->save();
|
||||
}
|
||||
|
||||
public function testsaveWritesFile(): void
|
||||
{
|
||||
$spreadsheetPackage = new PhpOfficeSpreadsheet();
|
||||
$spreadsheetPackage->open($this->filename);
|
||||
$spreadsheetPackage->setHeader(['Foo', 'Bar']);
|
||||
$spreadsheetPackage->addRow(['Data1', 'Data2']);
|
||||
$spreadsheetPackage->addRow(['Data3', 'Data4']);
|
||||
$spreadsheetPackage->save();
|
||||
|
||||
self::assertGreaterThan(0, filesize($this->filename));
|
||||
}
|
||||
|
||||
public function testsaveThrowsExceptionWhenSpreadsheetIsNull(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Cannot re-use spreadsheet after calling save()');
|
||||
|
||||
$spreadsheetPackage = new PhpOfficeSpreadsheet();
|
||||
$spreadsheetPackage->open($this->filename);
|
||||
$spreadsheetPackage->save();
|
||||
$spreadsheetPackage->save();
|
||||
}
|
||||
|
||||
public function testsetHeaderSetsHeaderRow(): void
|
||||
{
|
||||
$spreadsheetPackage = new PhpOfficeSpreadsheet();
|
||||
$spreadsheetPackage->open($this->filename);
|
||||
$spreadsheetPackage->setHeader(['Column1', 'Column2']);
|
||||
|
||||
$reflection = new \ReflectionClass($spreadsheetPackage);
|
||||
$property = $reflection->getProperty('worksheet');
|
||||
$property->setAccessible(true);
|
||||
/** @var Worksheet $worksheet */
|
||||
$worksheet = $property->getValue($spreadsheetPackage);
|
||||
|
||||
self::assertNotNull($worksheet);
|
||||
self::assertEquals('Column1', $worksheet->getCell('A1')->getValue());
|
||||
self::assertEquals('Column2', $worksheet->getCell('B1')->getValue());
|
||||
}
|
||||
|
||||
public function testaddRowAddsDataRow(): void
|
||||
{
|
||||
$spreadsheetPackage = new PhpOfficeSpreadsheet();
|
||||
$spreadsheetPackage->open($this->filename);
|
||||
$spreadsheetPackage->addRow(['Data1', 'Data2']);
|
||||
|
||||
$reflection = new \ReflectionClass($spreadsheetPackage);
|
||||
$property = $reflection->getProperty('worksheet');
|
||||
$property->setAccessible(true);
|
||||
/** @var Worksheet $worksheet */
|
||||
$worksheet = $property->getValue($spreadsheetPackage);
|
||||
|
||||
self::assertEquals('Data1', $worksheet->getCell('A1')->getValue());
|
||||
self::assertEquals('Data2', $worksheet->getCell('B1')->getValue());
|
||||
}
|
||||
|
||||
public function testaddRowThrowsExceptionWhenSpreadsheetIsNull(): void
|
||||
{
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('Cannot re-use spreadsheet after calling save()');
|
||||
|
||||
$spreadsheetPackage = new PhpOfficeSpreadsheet();
|
||||
$spreadsheetPackage->open($this->filename);
|
||||
$spreadsheetPackage->save();
|
||||
$spreadsheetPackage->addRow(['Data1', 'Data2']);
|
||||
}
|
||||
}
|
||||
47
tests/Export/Package/SpoutSpreadsheetTest.php
Normal file
47
tests/Export/Package/SpoutSpreadsheetTest.php
Normal 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\Tests\Export\Package;
|
||||
|
||||
use App\Export\Package\SpoutSpreadsheet;
|
||||
use OpenSpout\Writer\CSV\Writer;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Package\SpoutSpreadsheet
|
||||
*/
|
||||
class SpoutSpreadsheetTest extends TestCase
|
||||
{
|
||||
private string $filename;
|
||||
private int $counter = 1;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->filename = realpath(__DIR__ . '/../../_data/') . '/test' . $this->counter++ . '.xlsx';
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
if (file_exists($this->filename)) {
|
||||
unlink($this->filename);
|
||||
}
|
||||
}
|
||||
|
||||
public function testsaveWritesFile(): void
|
||||
{
|
||||
$spreadsheetPackage = new SpoutSpreadsheet(new Writer());
|
||||
$spreadsheetPackage->open($this->filename);
|
||||
$spreadsheetPackage->setHeader(['Foo', 'Bar']);
|
||||
$spreadsheetPackage->addRow(['Data1', 'Data2']);
|
||||
$spreadsheetPackage->addRow(['Data3', 'Data4']);
|
||||
$spreadsheetPackage->save();
|
||||
|
||||
self::assertGreaterThan(0, filesize($this->filename));
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Tests\Export\Renderer;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Entity\Customer;
|
||||
@@ -26,41 +25,14 @@ use App\Event\CustomerMetaDisplayEvent;
|
||||
use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Export\ExportRendererInterface;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
abstract class AbstractRendererTestCase extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* @param class-string $classname
|
||||
*/
|
||||
protected function getAbstractRenderer(string $classname): ExportRendererInterface|TimesheetExportInterface
|
||||
{
|
||||
$languages = [
|
||||
'en' => LocaleService::DEFAULT_SETTINGS
|
||||
];
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->any())->method('getUser')->willReturn(new User());
|
||||
$security->expects($this->any())->method('isGranted')->willReturn(true);
|
||||
|
||||
$translator = $this->createMock(TranslatorInterface::class);
|
||||
$dateExtension = new LocaleFormatExtensions(new LocaleService($languages));
|
||||
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
|
||||
|
||||
return new $classname($translator, $dateExtension, $dispatcher, $security);
|
||||
}
|
||||
|
||||
protected function render(ExportRendererInterface $renderer): Response
|
||||
{
|
||||
$customer = new Customer('Customer Name');
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
namespace App\Tests\Export\Renderer;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Export\Renderer\HtmlRenderer;
|
||||
use App\Export\Base\HtmlRenderer;
|
||||
use App\Export\Renderer\HtmlRendererFactory;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
@@ -35,5 +35,6 @@ class HtmlRendererFactoryTest extends TestCase
|
||||
|
||||
self::assertInstanceOf(HtmlRenderer::class, $renderer);
|
||||
self::assertEquals('foo', $renderer->getId());
|
||||
self::assertEquals('print', $renderer->getTitle());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\Tests\Export\Renderer;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Entity\User;
|
||||
use App\Export\Renderer\HtmlRenderer;
|
||||
use App\Export\Base\HtmlRenderer;
|
||||
use App\Project\ProjectStatisticService;
|
||||
use Symfony\Bridge\Twig\AppVariable;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
@@ -24,7 +24,6 @@ use Twig\Environment;
|
||||
/**
|
||||
* @covers \App\Export\Base\HtmlRenderer
|
||||
* @covers \App\Export\Base\RendererTrait
|
||||
* @covers \App\Export\Renderer\HtmlRenderer
|
||||
* @group integration
|
||||
*/
|
||||
class HtmlRendererTest extends AbstractRendererTestCase
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Tests\Export\Renderer;
|
||||
|
||||
use App\Export\Renderer\PDFRenderer;
|
||||
use App\Export\Base\PDFRenderer;
|
||||
use App\Export\Renderer\PdfRendererFactory;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Tests\Export\Renderer;
|
||||
|
||||
use App\Export\Renderer\PDFRenderer;
|
||||
use App\Export\Base\PDFRenderer;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Pdf\MPdfConverter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
@@ -21,7 +21,6 @@ use Twig\Environment;
|
||||
/**
|
||||
* @covers \App\Export\Base\PDFRenderer
|
||||
* @covers \App\Export\Base\RendererTrait
|
||||
* @covers \App\Export\Renderer\PDFRenderer
|
||||
* @group integration
|
||||
*/
|
||||
class PdfRendererTest extends AbstractRendererTestCase
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
namespace App\Tests\Export;
|
||||
|
||||
use App\Activity\ActivityStatisticService;
|
||||
use App\Export\Base\HtmlRenderer;
|
||||
use App\Export\ExportRepositoryInterface;
|
||||
use App\Export\Renderer\HtmlRenderer;
|
||||
use App\Export\ServiceExport;
|
||||
use App\Export\Timesheet\HtmlRenderer as HtmlExporter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
namespace App\Tests\Export\Timesheet;
|
||||
|
||||
use App\Configuration\LocaleService;
|
||||
use App\Entity\Activity;
|
||||
use App\Entity\ActivityMeta;
|
||||
use App\Entity\Customer;
|
||||
@@ -27,42 +26,13 @@ use App\Event\ProjectMetaDisplayEvent;
|
||||
use App\Event\TimesheetMetaDisplayEvent;
|
||||
use App\Export\TimesheetExportInterface;
|
||||
use App\Repository\Query\TimesheetQuery;
|
||||
use App\Twig\LocaleFormatExtensions;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
|
||||
use Symfony\Bundle\SecurityBundle\Security;
|
||||
use Symfony\Component\EventDispatcher\EventDispatcher;
|
||||
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
|
||||
use Symfony\Component\Form\Extension\Core\Type\TextType;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||
|
||||
abstract class AbstractRendererTestCase extends KernelTestCase
|
||||
{
|
||||
/**
|
||||
* @param class-string $classname
|
||||
*/
|
||||
protected function getAbstractRenderer(string $classname): TimesheetExportInterface
|
||||
{
|
||||
$languages = [
|
||||
'en' => LocaleService::DEFAULT_SETTINGS
|
||||
];
|
||||
|
||||
$user = new User();
|
||||
$user->setUserIdentifier('ssdf');
|
||||
|
||||
$security = $this->createMock(Security::class);
|
||||
$security->expects($this->any())->method('getUser')->willReturn($user);
|
||||
$security->expects($this->any())->method('isGranted')->willReturn(true);
|
||||
|
||||
$translator = $this->getMockBuilder(TranslatorInterface::class)->getMock();
|
||||
$dateExtension = new LocaleFormatExtensions(new LocaleService($languages));
|
||||
|
||||
$dispatcher = new EventDispatcher();
|
||||
$dispatcher->addSubscriber(new MetaFieldColumnSubscriber());
|
||||
|
||||
return new $classname($translator, $dateExtension, $dispatcher, $security);
|
||||
}
|
||||
|
||||
protected function render(TimesheetExportInterface $renderer): Response
|
||||
{
|
||||
$customer = new Customer('Customer Name');
|
||||
|
||||
@@ -1,114 +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\Tests\Export\Timesheet;
|
||||
|
||||
use App\Export\Timesheet\CsvRenderer;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Base\CsvRenderer
|
||||
* @covers \App\Export\Base\AbstractSpreadsheetRenderer
|
||||
* @covers \App\Export\Base\RendererTrait
|
||||
* @covers \App\Export\Timesheet\CsvRenderer
|
||||
* @group integration
|
||||
*/
|
||||
class CsvRendererTest extends AbstractRendererTestCase
|
||||
{
|
||||
public function testConfiguration(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer(CsvRenderer::class);
|
||||
|
||||
self::assertEquals('csv', $sut->getId());
|
||||
self::assertEquals('csv', $sut->getTitle());
|
||||
}
|
||||
|
||||
public static function getTestModel()
|
||||
{
|
||||
return [
|
||||
['400', '2437.12', ' EUR 1,947.99 ', 6, 5, 1, 2, 2]
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider getTestModel
|
||||
*/
|
||||
public function testRender($totalDuration, $totalRate, $expectedRate, $expectedRows, $expectedDescriptions, $expectedUser1, $expectedUser2, $expectedUser3): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer(CsvRenderer::class);
|
||||
|
||||
/** @var BinaryFileResponse $response */
|
||||
$response = $this->render($sut);
|
||||
|
||||
$file = $response->getFile();
|
||||
$prefix = date('Ymd');
|
||||
self::assertEquals('text/csv', $response->headers->get('Content-Type'));
|
||||
self::assertEquals('attachment; filename=' . $prefix . '-Customer_Name-project_name.csv', $response->headers->get('Content-Disposition'));
|
||||
|
||||
self::assertTrue(file_exists($file->getRealPath()));
|
||||
$content = file_get_contents($file->getRealPath());
|
||||
|
||||
self::assertStringContainsString('"' . $expectedRate . '"', $content);
|
||||
self::assertEquals($expectedRows, substr_count($content, PHP_EOL));
|
||||
self::assertEquals($expectedDescriptions, substr_count($content, 'activity description'));
|
||||
self::assertEquals($expectedUser1, substr_count($content, ',"kevin",'));
|
||||
self::assertEquals($expectedUser3, substr_count($content, ',"hello-world",'));
|
||||
self::assertEquals($expectedUser2, substr_count($content, ',"foo-bar",'));
|
||||
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$content2 = ob_get_clean();
|
||||
|
||||
self::assertEquals($content, $content2);
|
||||
self::assertFalse(file_exists($file->getRealPath()));
|
||||
|
||||
$all = [];
|
||||
$rows = str_getcsv($content2, PHP_EOL);
|
||||
foreach ($rows as $row) {
|
||||
$all[] = str_getcsv($row);
|
||||
}
|
||||
|
||||
$expected = [
|
||||
'2019-06-16',
|
||||
'12:00',
|
||||
'12:06',
|
||||
'400',
|
||||
'0',
|
||||
'',
|
||||
'kevin',
|
||||
'kevin',
|
||||
'',
|
||||
'Customer Name',
|
||||
'project name',
|
||||
'activity description',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'foo,bar',
|
||||
'',
|
||||
' EUR 84.00 ',
|
||||
'meta-bar',
|
||||
'meta-bar2',
|
||||
'customer-bar',
|
||||
'',
|
||||
'project-foo2',
|
||||
'activity-bar',
|
||||
'timesheet',
|
||||
'work',
|
||||
'A-0123456789',
|
||||
'DE-9876543210',
|
||||
'ORDER-123',
|
||||
];
|
||||
|
||||
self::assertEquals(6, \count($all));
|
||||
self::assertEquals($expected, $all[5]);
|
||||
self::assertEquals(\count($expected), \count($all[0]));
|
||||
self::assertEquals('foo', $all[4][15]);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
namespace App\Tests\Export\Timesheet;
|
||||
|
||||
use App\Export\Timesheet\PDFRenderer;
|
||||
use App\Export\Base\PDFRenderer;
|
||||
use App\Pdf\HtmlToPdfConverter;
|
||||
use App\Pdf\MPdfConverter;
|
||||
use App\Project\ProjectStatisticService;
|
||||
@@ -21,7 +21,6 @@ use Twig\Environment;
|
||||
/**
|
||||
* @covers \App\Export\Base\PDFRenderer
|
||||
* @covers \App\Export\Base\RendererTrait
|
||||
* @covers \App\Export\Timesheet\PDFRenderer
|
||||
* @group integration
|
||||
*/
|
||||
class PdfRendererTest extends AbstractRendererTestCase
|
||||
|
||||
@@ -1,53 +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\Tests\Export\Timesheet;
|
||||
|
||||
use App\Export\Timesheet\XlsxRenderer;
|
||||
use Symfony\Component\HttpFoundation\BinaryFileResponse;
|
||||
|
||||
/**
|
||||
* @covers \App\Export\Base\XlsxRenderer
|
||||
* @covers \App\Export\Base\AbstractSpreadsheetRenderer
|
||||
* @covers \App\Export\Base\RendererTrait
|
||||
* @covers \App\Export\Timesheet\XlsxRenderer
|
||||
* @group integration
|
||||
*/
|
||||
class XlsxRendererTest extends AbstractRendererTestCase
|
||||
{
|
||||
public function testConfiguration(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
|
||||
|
||||
self::assertEquals('xlsx', $sut->getId());
|
||||
self::assertEquals('xlsx', $sut->getTitle());
|
||||
}
|
||||
|
||||
public function testRender(): void
|
||||
{
|
||||
$sut = $this->getAbstractRenderer(XlsxRenderer::class);
|
||||
|
||||
/** @var BinaryFileResponse $response */
|
||||
$response = $this->render($sut);
|
||||
|
||||
$file = $response->getFile();
|
||||
$prefix = date('Ymd');
|
||||
self::assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $response->headers->get('Content-Type'));
|
||||
self::assertEquals('attachment; filename=' . $prefix . '-Customer_Name-project_name.xlsx', $response->headers->get('Content-Disposition'));
|
||||
|
||||
self::assertTrue(file_exists($file->getRealPath()));
|
||||
|
||||
ob_start();
|
||||
$response->sendContent();
|
||||
$content2 = ob_get_clean();
|
||||
self::assertNotEmpty($content2);
|
||||
|
||||
self::assertFalse(file_exists($file->getRealPath()));
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ use App\Entity\Activity;
|
||||
use App\Entity\User;
|
||||
use App\Twig\Extensions;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\Node\Node;
|
||||
use Twig\Node\TextNode;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
use Twig\TwigTest;
|
||||
@@ -59,7 +59,7 @@ class ExtensionsTest extends TestCase
|
||||
// make sure that the nl2str filters does proper escaping
|
||||
self::assertEquals('nl2str', $twigFilters[$id]->getName());
|
||||
self::assertEquals('html', $twigFilters[$id]->getPreEscape());
|
||||
self::assertEquals(['html'], $twigFilters[$id]->getSafe(new Node()));
|
||||
self::assertEquals(['html'], $twigFilters[$id]->getSafe(new TextNode('', 10)));
|
||||
}
|
||||
|
||||
public function testGetFunctions(): void
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace App\Tests\Twig;
|
||||
|
||||
use App\Twig\RuntimeExtensions;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Twig\Node\Node;
|
||||
use Twig\Node\TextNode;
|
||||
use Twig\TwigFilter;
|
||||
use Twig\TwigFunction;
|
||||
|
||||
@@ -80,20 +80,20 @@ class RuntimeExtensionsTest extends TestCase
|
||||
switch ($filter->getName()) {
|
||||
case 'md2html':
|
||||
self::assertEquals('html', $filters[0]->getPreEscape());
|
||||
self::assertEquals(['html'], $filters[0]->getSafe(new Node()));
|
||||
self::assertEquals(['html'], $filters[0]->getSafe(new TextNode('', 10)));
|
||||
$found_md2html = true;
|
||||
break;
|
||||
case 'desc2html':
|
||||
self::assertEquals(['html'], $filters[1]->getSafe(new Node()));
|
||||
self::assertEquals(['html'], $filters[1]->getSafe(new TextNode('', 10)));
|
||||
$found_desc2html = true;
|
||||
break;
|
||||
case 'comment2html':
|
||||
self::assertEquals(['html'], $filters[2]->getSafe(new Node()));
|
||||
self::assertEquals(['html'], $filters[2]->getSafe(new TextNode('', 10)));
|
||||
$found_comment2html = true;
|
||||
break;
|
||||
case 'comment1line':
|
||||
self::assertEquals('html', $filters[3]->getPreEscape());
|
||||
self::assertEquals(['html'], $filters[3]->getSafe(new Node()));
|
||||
self::assertEquals(['html'], $filters[3]->getSafe(new TextNode('', 10)));
|
||||
$found_comment1line = true;
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -1416,81 +1416,6 @@ parameters:
|
||||
count: 1
|
||||
path: EventSubscriber/WizardSubscriberTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\AbstractRendererTestCase\\:\\:getAbstractRenderer\\(\\) should return App\\\\Export\\\\ExportRendererInterface\\|App\\\\Export\\\\TimesheetExportInterface but returns object\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/AbstractRendererTestCase.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedDescriptions with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRows with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser1 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser2 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser3 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$totalDuration with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Renderer\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$totalRate with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#"
|
||||
count: 5
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$renderer of method App\\\\Tests\\\\Export\\\\Renderer\\\\AbstractRendererTestCase\\:\\:render\\(\\) expects App\\\\Export\\\\ExportRendererInterface, App\\\\Export\\\\ExportRendererInterface\\|App\\\\Export\\\\TimesheetExportInterface given\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$string of function str_getcsv expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$string of function str_getcsv expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#"
|
||||
count: 6
|
||||
@@ -1501,11 +1426,6 @@ parameters:
|
||||
count: 7
|
||||
path: Export/Renderer/HtmlRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$renderer of method App\\\\Tests\\\\Export\\\\Renderer\\\\AbstractRendererTestCase\\:\\:render\\(\\) expects App\\\\Export\\\\ExportRendererInterface, App\\\\Export\\\\ExportRendererInterface\\|App\\\\Export\\\\TimesheetExportInterface given\\.$#"
|
||||
count: 1
|
||||
path: Export/Renderer/XlsxRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Spreadsheet\\\\CellFormatter\\\\AbstractFormatterTestCase\\:\\:getActualValue\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
@@ -1606,76 +1526,6 @@ parameters:
|
||||
count: 1
|
||||
path: Export/Spreadsheet/Writer/BinaryFileResponseWriterTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\AbstractRendererTestCase\\:\\:getAbstractRenderer\\(\\) should return App\\\\Export\\\\TimesheetExportInterface but returns object\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/AbstractRendererTestCase.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:getTestModel\\(\\) has no return type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedDescriptions with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRate with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedRows with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser1 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser2 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$expectedUser3 with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$totalDuration with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Method App\\\\Tests\\\\Export\\\\Timesheet\\\\CsvRendererTest\\:\\:testRender\\(\\) has parameter \\$totalRate with no type specified\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$haystack of function substr_count expects string, string\\|false given\\.$#"
|
||||
count: 5
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$string of function str_getcsv expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$string of function str_getcsv expects string, string\\|null given\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
path: Export/Timesheet/CsvRendererTest.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#2 \\$haystack of static method PHPUnit\\\\Framework\\\\Assert\\:\\:assertStringContainsString\\(\\) expects string, string\\|false given\\.$#"
|
||||
count: 1
|
||||
|
||||
Reference in New Issue
Block a user