Release 1.20.1 (#3317)

- improved timesheet calculator with changesets and priority
- calculate and include exported stats (e.g. available in export templates)
- added hourly rate column to timesheet listing
This commit is contained in:
Kevin Papst
2022-05-21 13:13:56 +02:00
committed by GitHub
parent 3e6100f368
commit d5f8655827
15 changed files with 277 additions and 43 deletions

View File

@@ -149,10 +149,20 @@ class ActivityStatisticService
$statistic->setInternalRate($statistic->getInternalRate() + $resultRow['internalRate']);
$statistic->setCounter($statistic->getCounter() + $resultRow['counter']);
if ($resultRow['billable']) {
$statistic->setDurationBillable($resultRow['duration']);
$statistic->setRateBillable($resultRow['rate']);
$statistic->setInternalRateBillable($resultRow['internalRate']);
$statistic->setCounterBillable($resultRow['counter']);
$statistic->setDurationBillable($statistic->getDurationBillable() + $resultRow['duration']);
$statistic->setRateBillable($statistic->getRateBillable() + $resultRow['rate']);
$statistic->setInternalRateBillable($statistic->getInternalRateBillable() + $resultRow['internalRate']);
$statistic->setCounterBillable($statistic->getCounterBillable() + $resultRow['counter']);
if ($resultRow['exported']) {
$statistic->setDurationBillableExported($statistic->getDurationBillableExported() + $resultRow['duration']);
$statistic->setRateBillableExported($statistic->getRateBillableExported() + $resultRow['rate']);
}
}
if ($resultRow['exported']) {
$statistic->setDurationExported($statistic->getDurationExported() + $resultRow['duration']);
$statistic->setRateExported($statistic->getRateExported() + $resultRow['rate']);
$statistic->setInternalRateExported($statistic->getInternalRateExported() + $resultRow['internalRate']);
$statistic->setCounterExported($statistic->getCounterExported() + $resultRow['counter']);
}
}
}
@@ -170,9 +180,11 @@ class ActivityStatisticService
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('COUNT(t.id) as counter')
->addSelect('t.billable as billable')
->addSelect('t.exported as exported')
->andWhere($qb->expr()->isNotNull('t.end'))
->groupBy('id')
->addGroupBy('billable')
->addGroupBy('exported')
->andWhere($qb->expr()->in('t.activity', ':activity'))
->setParameter('activity', $activities)
;

View File

@@ -17,11 +17,11 @@ class Constants
/**
* The current release version
*/
public const VERSION = '1.20';
public const VERSION = '1.20.1';
/**
* The current release: major * 10000 + minor * 100 + patch
*/
public const VERSION_ID = 12000;
public const VERSION_ID = 12001;
/**
* The current release status, either "stable" or "dev"
*/

View File

@@ -97,10 +97,20 @@ class CustomerStatisticService
$statistic->setInternalRate($statistic->getInternalRate() + $resultRow['internalRate']);
$statistic->setCounter($statistic->getCounter() + $resultRow['counter']);
if ($resultRow['billable']) {
$statistic->setDurationBillable($resultRow['duration']);
$statistic->setRateBillable($resultRow['rate']);
$statistic->setInternalRateBillable($resultRow['internalRate']);
$statistic->setCounterBillable($resultRow['counter']);
$statistic->setDurationBillable($statistic->getDurationBillable() + $resultRow['duration']);
$statistic->setRateBillable($statistic->getRateBillable() + $resultRow['rate']);
$statistic->setInternalRateBillable($statistic->getInternalRateBillable() + $resultRow['internalRate']);
$statistic->setCounterBillable($statistic->getCounterBillable() + $resultRow['counter']);
if ($resultRow['exported']) {
$statistic->setDurationBillableExported($statistic->getDurationBillableExported() + $resultRow['duration']);
$statistic->setRateBillableExported($statistic->getRateBillableExported() + $resultRow['rate']);
}
}
if ($resultRow['exported']) {
$statistic->setDurationExported($statistic->getDurationExported() + $resultRow['duration']);
$statistic->setRateExported($statistic->getRateExported() + $resultRow['rate']);
$statistic->setInternalRateExported($statistic->getInternalRateExported() + $resultRow['internalRate']);
$statistic->setCounterExported($statistic->getCounterExported() + $resultRow['counter']);
}
}
}
@@ -119,9 +129,11 @@ class CustomerStatisticService
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('COUNT(t.id) as counter')
->addSelect('t.billable as billable')
->addSelect('t.exported as exported')
->andWhere($qb->expr()->isNotNull('t.end'))
->groupBy('id')
->addGroupBy('billable')
->addGroupBy('exported')
->andWhere($qb->expr()->in('p.customer', ':customer'))
->setParameter('customer', $customers)
;

View File

@@ -23,7 +23,11 @@ class TimesheetSubscriber implements EventSubscriber
/**
* @var CalculatorInterface[]
*/
protected $calculator;
private $calculator;
/**
* @var CalculatorInterface[]
*/
private $sorted;
/**
* @param CalculatorInterface[] $calculators
@@ -33,20 +37,14 @@ class TimesheetSubscriber implements EventSubscriber
$this->calculator = $calculators;
}
/**
* @return array
*/
public function getSubscribedEvents()
public function getSubscribedEvents(): array
{
return [
Events::onFlush,
];
}
/**
* @param OnFlushEventArgs $args
*/
public function onFlush(OnFlushEventArgs $args)
public function onFlush(OnFlushEventArgs $args): void
{
$em = $args->getEntityManager();
$uow = $em->getUnitOfWork();
@@ -57,7 +55,7 @@ class TimesheetSubscriber implements EventSubscriber
continue;
}
$this->calculateFields($entity);
$this->calculateFields($entity, $uow->getEntityChangeSet($entity));
$uow->recomputeSingleEntityChangeSet($meta, $entity);
}
@@ -71,13 +69,31 @@ class TimesheetSubscriber implements EventSubscriber
}
}
/**
* @param Timesheet $entity
*/
protected function calculateFields(Timesheet $entity)
protected function calculateFields(Timesheet $entity, array $changes = []): void
{
foreach ($this->calculator as $calculator) {
$calculator->calculate($entity);
if ($this->sorted === null) {
$this->sorted = [];
foreach ($this->calculator as $calculator) {
$i = 0;
$prio = 1000;
if (method_exists($calculator, 'getPriority')) {
$prio = $calculator->getPriority();
}
do {
$key = $prio + $i++;
} while (\array_key_exists($key, $this->sorted));
$this->sorted[$key] = $calculator;
}
ksort($this->sorted);
}
foreach ($this->sorted as $calculator) {
/* @phpstan-ignore-next-line */
$calculator->calculate($entity, $changes);
}
}
}

View File

@@ -654,6 +654,14 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
return $this;
}
public function resetRates(): void
{
$this->rate = 0.00;
$this->internalRate = null;
$this->hourlyRate = null;
$this->fixedRate = null;
}
public function isBillable(): bool
{
return $this->billable;

View File

@@ -11,6 +11,7 @@ namespace App\Export\Base;
use App\Activity\ActivityStatisticService;
use App\Invoice\InvoiceItemInterface;
use App\Model\TimesheetCountedStatistic;
use App\Project\ProjectStatisticService;
use App\Repository\Query\TimesheetQuery;
@@ -172,6 +173,7 @@ trait RendererTrait
{
$summary = [];
$projects = [];
$empty = new TimesheetCountedStatistic();
foreach ($exportItems as $exportItem) {
$customer = null;
@@ -183,15 +185,14 @@ trait RendererTrait
$customer = $project->getCustomer();
$customerId = $customer->getId();
$projectId = $project->getId();
if ($project->hasBudgets()) {
$projects[] = $project;
}
$projects[] = $project;
}
$id = $customerId . '_' . $projectId;
if (!isset($summary[$id])) {
$summary[$id] = [
'totals' => $empty->jsonSerialize(),
'time' => $project->getTimeBudget(),
'money' => $project->getBudget(),
'time_left' => null,
@@ -209,6 +210,8 @@ trait RendererTrait
foreach ($allBudgets as $projectId => $statisticModel) {
$project = $statisticModel->getProject();
$id = $project->getCustomer()->getId() . '_' . $projectId;
$total = $statisticModel->getStatisticTotal();
$summary[$id]['totals'] = $total->jsonSerialize();
if ($statisticModel->hasTimeBudget()) {
$summary[$id]['time_left'] = $statisticModel->getTimeBudgetOpenRelative();
$summary[$id]['time_left_total'] = $statisticModel->getTimeBudgetOpen();
@@ -255,6 +258,7 @@ trait RendererTrait
{
$summary = [];
$activities = [];
$empty = new TimesheetCountedStatistic();
foreach ($exportItems as $exportItem) {
$customerId = 'none';
@@ -270,9 +274,7 @@ trait RendererTrait
continue;
}
if ($activity->hasBudgets()) {
$activities[] = $activity;
}
$activities[] = $activity;
if (null !== ($project = $exportItem->getProject())) {
$projectId = $project->getId();
@@ -289,6 +291,7 @@ trait RendererTrait
if (!isset($summary[$id][$activityId])) {
$summary[$id][$activityId] = [
'totals' => $empty->jsonSerialize(),
'time' => $activity->getTimeBudget(),
'money' => $activity->getBudget(),
'time_left' => null,
@@ -306,6 +309,8 @@ trait RendererTrait
foreach ($allBudgets as $activityId => $statisticModel) {
$project = $statisticModel->getActivity()->getProject();
$id = $project->getCustomer()->getId() . '_' . $project->getId();
$total = $statisticModel->getStatisticTotal();
$summary[$id][$activityId]['totals'] = $total->jsonSerialize();
if ($statisticModel->hasTimeBudget()) {
$summary[$id][$activityId]['time_left'] = $statisticModel->getTimeBudgetOpenRelative();
$summary[$id][$activityId]['time_left_total'] = $statisticModel->getTimeBudgetOpen();

View File

@@ -21,6 +21,14 @@ class TimesheetCountedStatistic implements \JsonSerializable
private $recordRateBillable = 0.0;
private $internalRateBillable = 0.0;
private $recordRateBillableExported = 0.0;
private $recordDurationBillableExported = 0;
private $counterExported = 0;
private $recordDurationExported = 0;
private $recordRateExported = 0.0;
private $internalRateExported = 0.0;
/**
* For unified access, used in frontend.
*
@@ -46,6 +54,16 @@ class TimesheetCountedStatistic implements \JsonSerializable
$this->counterBillable = $counter;
}
public function getCounterExported(): int
{
return $this->counterExported;
}
public function setCounterExported(int $counter): void
{
$this->counterExported = $counter;
}
/**
* Returns the total amount of included timesheet records.
*
@@ -178,6 +196,16 @@ class TimesheetCountedStatistic implements \JsonSerializable
$this->internalRateBillable = $internalRateBillable;
}
public function getInternalRateExported(): float
{
return $this->internalRateExported;
}
public function setInternalRateExported(float $internalRateExported): void
{
$this->internalRateExported = $internalRateExported;
}
/**
* @param float $recordInternalRate
* @return $this
@@ -217,6 +245,16 @@ class TimesheetCountedStatistic implements \JsonSerializable
$this->recordDurationBillable = $recordDuration;
}
public function getDurationBillableExported(): int
{
return $this->recordDurationBillableExported;
}
public function setDurationBillableExported(int $recordDuration): void
{
$this->recordDurationBillableExported = $recordDuration;
}
public function getRateBillable(): float
{
return $this->recordRateBillable;
@@ -227,16 +265,52 @@ class TimesheetCountedStatistic implements \JsonSerializable
$this->recordRateBillable = $recordRate;
}
public function getRateBillableExported(): float
{
return $this->recordRateBillableExported;
}
public function setRateBillableExported(float $recordRate): void
{
$this->recordRateBillableExported = $recordRate;
}
public function getDurationExported(): int
{
return $this->recordDurationExported;
}
public function setDurationExported(int $recordDuration): void
{
$this->recordDurationExported = $recordDuration;
}
public function getRateExported(): float
{
return $this->recordRateExported;
}
public function setRateExported(float $recordRate): void
{
$this->recordRateExported = $recordRate;
}
public function jsonSerialize()
{
return [
'duration' => $this->recordDuration,
'duration_billable' => $this->recordDurationBillable,
'duration_exported' => $this->recordDurationExported,
'duration_billable_exported' => $this->recordDurationBillableExported,
'rate' => $this->recordRate,
'rate_billable' => $this->recordRateBillable,
'rate_exported' => $this->recordRateExported,
'rate_billable_exported' => $this->recordRateBillableExported,
'rate_internal' => $this->recordInternalRate,
'rate_internal_exported' => $this->internalRateExported,
'amount' => $this->counter,
'amount_billable' => $this->counterBillable,
'amount_exported' => $this->counterExported,
];
}
}

View File

@@ -328,10 +328,12 @@ class ProjectStatisticService
->addSelect('COALESCE(SUM(t.internalRate), 0) as internalRate')
->addSelect('COUNT(t.id) as counter')
->addSelect('t.billable as billable')
->addSelect('t.exported as exported')
->andWhere($qb->expr()->in('t.project', ':project'))
->andWhere($qb->expr()->isNotNull('t.end'))
->groupBy('id')
->addGroupBy('billable')
->addGroupBy('exported')
->setParameter('project', array_keys($statistics))
;
@@ -359,10 +361,20 @@ class ProjectStatisticService
$statistic->setInternalRate($statistic->getInternalRate() + $resultRow['internalRate']);
$statistic->setCounter($statistic->getCounter() + $resultRow['counter']);
if ($resultRow['billable']) {
$statistic->setDurationBillable($resultRow['duration']);
$statistic->setRateBillable($resultRow['rate']);
$statistic->setInternalRateBillable($resultRow['internalRate']);
$statistic->setCounterBillable($resultRow['counter']);
$statistic->setDurationBillable($statistic->getDurationBillable() + $resultRow['duration']);
$statistic->setRateBillable($statistic->getRateBillable() + $resultRow['rate']);
$statistic->setInternalRateBillable($statistic->getInternalRateBillable() + $resultRow['internalRate']);
$statistic->setCounterBillable($statistic->getCounterBillable() + $resultRow['counter']);
if ($resultRow['exported']) {
$statistic->setDurationBillableExported($statistic->getDurationBillableExported() + $resultRow['duration']);
$statistic->setRateBillableExported($statistic->getRateBillableExported() + $resultRow['rate']);
}
}
if ($resultRow['exported']) {
$statistic->setDurationExported($statistic->getDurationExported() + $resultRow['duration']);
$statistic->setRateExported($statistic->getRateExported() + $resultRow['rate']);
$statistic->setInternalRateExported($statistic->getInternalRateExported() + $resultRow['internalRate']);
$statistic->setCounterExported($statistic->getCounterExported() + $resultRow['counter']);
}
}
}

View File

@@ -50,4 +50,9 @@ class BillableCalculator implements CalculatorInterface
break;
}
}
public function getPriority(): int
{
return 100;
}
}

View File

@@ -42,4 +42,9 @@ final class DurationCalculator implements CalculatorInterface
$this->roundings->applyRoundings($record);
}
public function getPriority(): int
{
return 200;
}
}

View File

@@ -44,4 +44,9 @@ class RateCalculator implements CalculatorInterface
$record->setFixedRate($rate->getFixedRate());
}
}
public function getPriority(): int
{
return 300;
}
}

View File

@@ -22,6 +22,18 @@ interface CalculatorInterface
* The methods return value will not be evaluated.
*
* @param Timesheet $record
* @ param array<string, array<mixed, mixed>> $changeset
* @return void
*/
public function calculate(Timesheet $record);
public function calculate(Timesheet $record/*, array $changeset*/);
/*
* FIXME use with Kimai 2.0
*
* Default priority is 1000 (after all system Calculator were executed).
* The higher the priority the later it will be executed.
*
* @return int
*/
//public function getPriority(): int;
}

View File

@@ -23,7 +23,10 @@
{% endif %}
{% set columns = columns|merge({'duration': {'class': 'text-right'}}) %}
{% if canSeeRate %}
{% set columns = columns|merge({'rate': {'class': 'text-right'}}) %}
{% set columns = columns|merge({
'hourlyRate': {'class': 'text-right hidden'},
'rate': {'class': 'text-right'}
}) %}
{% endif %}
{% set columns = columns|merge({
'customer': {'class': 'hidden-xs hidden-sm hidden-md'},
@@ -71,17 +74,20 @@
{% set day = null %}
{% set dayDuration = 0 %}
{% set dayRate = {} %}
{% set dayHourlyRate = 0 %}
{% set lastEntry = null %}
{% for entry in entries %}
{%- set customerCurrency = entry.project.customer.currency -%}
{%- set entryHourlyRate = entry.hourlyRate|money(customerCurrency) -%}
{%- if day is same as(null) -%}
{% set day = entry.begin|date_short %}
{% endif %}
{%- if showSummary and day is not same as(entry.begin|date_short) -%}
{{ _self.summary(day, dayDuration, dayRate, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) }}
{{ _self.summary(day, dayDuration, dayHourlyRate, dayRate, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) }}
{% set day = entry.begin|date_short %}
{% set dayDuration = 0 %}
{% set dayRate = {} %}
{% set dayHourlyRate = 0 %}
{%- endif -%}
{% set class = '' %}
{% if checkOverlappingDesc or checkOverlappingAsc %}
@@ -124,11 +130,14 @@
{% endif %}
{% if canSeeRate %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'hourlyRate') }}">
{{ entryHourlyRate }}
</td>
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% if not entry.end or not is_granted('view_rate', entry) %}
&dash;
{% else %}
{{ entry.rate|money(entry.project.customer.currency) }}
{{ entry.rate|money(customerCurrency) }}
{% endif %}
</td>
{% endif %}
@@ -179,12 +188,19 @@
{% endif %}
{% set dayRate = dayRate|merge({(customerCurrency): dayRate[customerCurrency] + entry.rate}) %}
{%- endif -%}
{% if dayHourlyRate is not null %}
{% if dayHourlyRate == 0 %}
{% set dayHourlyRate = entryHourlyRate %}
{% elseif dayHourlyRate != entryHourlyRate %}
{% set dayHourlyRate = null %}
{% endif %}
{% endif %}
{%- set dayDuration = dayDuration + entry.duration -%}
{% set lastEntry = entry %}
{% endfor %}
{% if showSummary %}
{{ _self.summary(day, dayDuration, dayRate, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) }}
{{ _self.summary(day, dayDuration, dayHourlyRate, dayRate, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) }}
{% endif %}
{{ tables.data_table_footer(entries, paginationRoute, multiUpdateForm) }}
@@ -192,7 +208,7 @@
{% endblock %}
{% macro summary(day, duration, dayRates, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) %}
{% macro summary(day, duration, dayHourlyRate, dayRates, columns, canSeeRate, canSeeUsername, showStartEndTime, tableName, metaColumns) %}
{% import "macros/datatables.html.twig" as tables %}
<tr class="summary info">
<td></td>
@@ -203,6 +219,11 @@
{% endif %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'duration') }}">{{ duration|duration }}</td>
{% if canSeeRate %}
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'hourlyRate') }}">
{% if dayHourlyRate is not null and dayHourlyRate != 0 %}
{{ dayHourlyRate }}
{% endif %}
</td>
<td class="text-nowrap {{ tables.data_table_column_class(tableName, columns, 'rate') }}">
{% for currency, rate in dayRates %}
{{ rate|money(currency) }}

View File

@@ -157,6 +157,26 @@ class TimesheetTest extends TestCase
self::assertTrue($sut->isBillable());
}
public function testResetRates()
{
$sut = new Timesheet();
self::assertSame(0.00, $sut->getRate());
self::assertNull($sut->getFixedRate());
self::assertNull($sut->getInternalRate());
self::assertNull($sut->getHourlyRate());
$sut->setRate(123.45);
$sut->setFixedRate(42.32);
$sut->setInternalRate(212);
$sut->setHourlyRate(123);
$sut->resetRates();
self::assertSame(0.00, $sut->getRate());
self::assertNull($sut->getFixedRate());
self::assertNull($sut->getInternalRate());
self::assertNull($sut->getHourlyRate());
}
public function testCategory()
{
$sut = new Timesheet();

View File

@@ -28,17 +28,25 @@ abstract class AbstractTimesheetCountedStatisticTest extends TestCase
self::assertSame(0.0, $sut->getRateBillable());
self::assertSame(0, $sut->getRecordAmountBillable());
self::assertSame(0.0, $sut->getInternalRateBillable());
self::assertSame(0, $sut->getDurationBillableExported());
self::assertSame(0, $sut->getDurationExported());
self::assertSame(0.0, $sut->getRateExported());
self::assertSame(0.0, $sut->getInternalRateExported());
$json = $sut->jsonSerialize();
$expected = [
'duration' => 0,
'duration_billable' => 0,
'duration_exported' => 0,
'duration_billable_exported' => 0,
'rate' => 0.0,
'rate_billable' => 0.0,
'rate_exported' => 0.0,
'rate_internal' => 0.0,
'amount' => 0,
'amount_billable' => 0,
'amount_exported' => 0,
];
foreach ($expected as $key => $value) {
@@ -55,12 +63,22 @@ abstract class AbstractTimesheetCountedStatisticTest extends TestCase
$sut->setRecordAmountBillable(15);
self::assertInstanceOf(TimesheetCountedStatistic::class, $sut->setRecordInternalRate(99.09));
$sut->setDurationBillableExported(199);
$sut->setDurationExported(299);
$sut->setRateExported(456.48);
$sut->setInternalRateExported(27.15);
self::assertSame(23.97, $sut->getRecordRate());
self::assertSame(21, $sut->getRecordDuration());
self::assertSame(5, $sut->getRecordAmount());
self::assertSame(15, $sut->getRecordAmountBillable());
self::assertSame(99.09, $sut->getRecordInternalRate());
self::assertSame(199, $sut->getDurationBillableExported());
self::assertSame(299, $sut->getDurationExported());
self::assertSame(456.48, $sut->getRateExported());
self::assertSame(27.15, $sut->getInternalRateExported());
self::assertSame(21, $sut->getValue());
$sut->setCounter(524);
@@ -93,15 +111,24 @@ abstract class AbstractTimesheetCountedStatisticTest extends TestCase
$sut->setDurationBillable(1234);
$sut->setRecordAmountBillable(4321);
$sut->setDurationBillableExported(199);
$sut->setRateBillableExported(654.23);
$sut->setDurationExported(299);
$sut->setRateExported(456.48);
$sut->setInternalRateExported(27.15);
$json = $sut->jsonSerialize();
foreach (['duration', 'duration_billable', 'rate', 'rate_billable', 'rate_internal', 'amount', 'amount_billable'] as $key) {
foreach (['duration', 'duration_billable', 'duration_exported', 'rate', 'rate_billable', 'rate_billable_exported', 'rate_exported', 'rate_internal', 'amount', 'amount_billable', 'amount_exported'] as $key) {
self::assertArrayHasKey($key, $json);
}
self::assertSame(21, $json['duration']);
self::assertSame(1234, $json['duration_billable']);
self::assertSame(199, $json['duration_billable_exported']);
self::assertSame(299, $json['duration_exported']);
self::assertSame(23.97, $json['rate']);
self::assertSame(123.456, $json['rate_billable']);
self::assertSame(654.23, $json['rate_billable_exported']);
self::assertSame(99.09, $json['rate_internal']);
self::assertSame(5, $json['amount']);
self::assertSame(4321, $json['amount_billable']);