automatic billable calculation (#3200)

This commit is contained in:
Kevin Papst
2022-03-18 22:31:50 +01:00
committed by GitHub
parent f7480902b4
commit 30c7782d6e
34 changed files with 409 additions and 53 deletions

View File

@@ -317,6 +317,7 @@ class TimesheetController extends BaseApiController
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
'include_billable' => $this->isGranted('edit_billable', $timesheet),
'include_user' => $this->isGranted('create_other_timesheet'),
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
@@ -394,6 +395,7 @@ class TimesheetController extends BaseApiController
$form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [
'include_rate' => $this->isGranted('edit_rate', $timesheet),
'include_exported' => $this->isGranted('edit_export', $timesheet),
'include_billable' => $this->isGranted('edit_billable', $timesheet),
'include_user' => $this->isGranted('edit', $timesheet),
'allow_begin_datetime' => $mode->canUpdateTimesWithAPI(),
'allow_end_datetime' => $mode->canUpdateTimesWithAPI(),
@@ -614,12 +616,10 @@ class TimesheetController extends BaseApiController
@trigger_error('Setting the "copy" attribute in "restart timesheet" API to something else then "all" is deprecated', E_USER_DEPRECATED);
}
$copyTimesheet
->setHourlyRate($timesheet->getHourlyRate())
->setFixedRate($timesheet->getFixedRate())
->setDescription($timesheet->getDescription())
->setBillable($timesheet->isBillable())
;
$copyTimesheet->setHourlyRate($timesheet->getHourlyRate());
$copyTimesheet->setFixedRate($timesheet->getFixedRate());
$copyTimesheet->setDescription($timesheet->getDescription());
$copyTimesheet->setBillable($timesheet->isBillable());
foreach ($timesheet->getTags() as $tag) {
$copyTimesheet->addTag($tag);

View File

@@ -232,11 +232,6 @@ class SystemConfiguration implements SystemBundleConfiguration
return (string) $this->find('timesheet.default_begin');
}
public function getTimesheetDefaultBillable(): bool
{
return (bool) $this->find('defaults.timesheet.billable');
}
public function isTimesheetAllowFutureTimes(): bool
{
return (bool) $this->find('timesheet.rules.allow_future_times');

View File

@@ -383,10 +383,6 @@ final class SystemConfigurationController extends AbstractController
->setConstraints([
new GreaterThanOrEqual(['value' => 0])
]),
(new Configuration())
->setName('defaults.timesheet.billable')
->setType(YesNoType::class)
->setOptions(['help' => 'default_value_new', 'label' => 'label.billable']),
]),
(new SystemConfigurationModel('quick_entry'))
->setTranslation('quick_entry.title')

View File

@@ -455,6 +455,7 @@ abstract class TimesheetAbstractController extends AbstractController
'action' => $this->generateUrl($this->getMultiUpdateRoute(), []),
'method' => 'POST',
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
'include_billable' => $this->isGranted($this->getPermissionEditBillable()),
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
'include_user' => $this->includeUserInForms('multi'),
]);
@@ -482,6 +483,7 @@ abstract class TimesheetAbstractController extends AbstractController
'action' => $action,
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_billable' => $this->isGranted('edit_billable', $entry),
'include_user' => $this->includeUserInForms('create'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
@@ -510,6 +512,7 @@ abstract class TimesheetAbstractController extends AbstractController
]),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_billable' => $this->isGranted('edit_billable', $entry),
'include_user' => $this->includeUserInForms('edit'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
@@ -549,6 +552,11 @@ abstract class TimesheetAbstractController extends AbstractController
return 'edit_export_own_timesheet';
}
protected function getPermissionEditBillable(): string
{
return 'edit_billable_own_timesheet';
}
protected function getPermissionEditRate(): string
{
return 'edit_rate_own_timesheet';

View File

@@ -153,8 +153,9 @@ class TimesheetTeamController extends TimesheetAbstractController
return $this->createForm(TimesheetMultiUserEditForm::class, $entry, [
'action' => $this->generateUrl('admin_timesheet_create_multiuser'),
'include_rate' => $this->isGranted('edit_rate', $entry),
'include_exported' => $this->isGranted('edit_export', $entry),
'include_rate' => $this->isGranted($this->getPermissionEditRate()),
'include_exported' => $this->isGranted($this->getPermissionEditExport()),
'include_billable' => $this->isGranted($this->getPermissionEditBillable()),
'include_user' => $this->includeUserInForms('create'),
'allow_begin_datetime' => $mode->canEditBegin(),
'allow_end_datetime' => $mode->canEditEnd(),
@@ -205,6 +206,11 @@ class TimesheetTeamController extends TimesheetAbstractController
return 'edit_export_other_timesheet';
}
protected function getPermissionEditBillable(): string
{
return 'edit_billable_other_timesheet';
}
protected function getPermissionEditRate(): string
{
return 'edit_rate_other_timesheet';

View File

@@ -47,7 +47,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* }
* )
*
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment"})
* @Exporter\Order({"id", "name", "project", "budget", "timeBudget", "budgetType", "color", "visible", "comment", "billable"})
* @Exporter\Expose("project", label="label.project", exp="object.getProject() === null ? null : object.getProject().getName()")
*/
class Activity implements EntityWithMetaFields, EntityWithBudget
@@ -123,6 +123,18 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
* @Assert\NotNull()
*/
private $visible = true;
/**
* @var bool
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.billable", type="boolean")
*
* @ORM\Column(name="billable", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $billable = true;
/**
* Meta fields
*
@@ -227,6 +239,16 @@ class Activity implements EntityWithMetaFields, EntityWithBudget
return $this->visible;
}
public function setBillable(bool $billable): void
{
$this->billable = $billable;
}
public function isBillable(): bool
{
return $this->billable;
}
/**
* @return Collection|MetaTableTypeInterface[]
*/

View File

@@ -27,7 +27,7 @@ use Symfony\Component\Validator\Constraints as Assert;
*
* @Serializer\ExclusionPolicy("all")
*
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment"})
* @Exporter\Order({"id", "name", "company", "number", "vatId", "address", "contact","email", "phone", "mobile", "fax", "homepage", "country", "currency", "timezone", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment", "billable"})
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
*/
class Customer implements EntityWithMetaFields, EntityWithBudget
@@ -98,6 +98,18 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
* @Assert\NotNull()
*/
private $visible = true;
/**
* @var bool
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.billable", type="boolean")
*
* @ORM\Column(name="billable", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $billable = true;
/**
* @var string|null
*
@@ -353,6 +365,16 @@ class Customer implements EntityWithMetaFields, EntityWithBudget
return $this->visible;
}
public function setBillable(bool $billable): void
{
$this->billable = $billable;
}
public function isBillable(): bool
{
return $this->billable;
}
public function setCompany(?string $company): Customer
{
$this->company = $company;

View File

@@ -48,7 +48,7 @@ use Symfony\Component\Validator\Constraints as Assert;
* }
* )
*
* @Exporter\Order({"id", "name", "customer", "orderNumber", "orderDate", "start", "end", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment"})
* @Exporter\Order({"id", "name", "customer", "orderNumber", "orderDate", "start", "end", "budget", "timeBudget", "budgetType", "color", "visible", "teams", "comment", "billable"})
* @Exporter\Expose("customer", label="label.customer", exp="object.getCustomer() === null ? null : object.getCustomer().getName()")
* @ Exporter\Expose("teams", label="label.team", exp="object.getTeams().toArray()", type="array")
*/
@@ -195,6 +195,18 @@ class Project implements EntityWithMetaFields, EntityWithBudget
* @Assert\NotNull()
*/
private $visible = true;
/**
* @var bool
*
* @Serializer\Expose()
* @Serializer\Groups({"Default"})
*
* @Exporter\Expose(label="label.billable", type="boolean")
*
* @ORM\Column(name="billable", type="boolean", nullable=false)
* @Assert\NotNull()
*/
private $billable = true;
/**
* Meta fields
*
@@ -294,6 +306,16 @@ class Project implements EntityWithMetaFields, EntityWithBudget
return $this->visible;
}
public function setBillable(bool $billable): void
{
$this->billable = $billable;
}
public function isBillable(): bool
{
return $this->billable;
}
public function getOrderNumber(): ?string
{
return $this->orderNumber;

View File

@@ -98,6 +98,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
*/
public const OVERTIME = 'overtime';
public const BILLABLE_AUTOMATIC = 'auto';
public const BILLABLE_YES = 'yes';
public const BILLABLE_NO = 'no';
public const BILLABLE_DEFAULT = 'default';
/**
* @var int|null
*
@@ -269,6 +274,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
* @Assert\NotNull()
*/
private $billable = true;
/**
* Internal property used to determine whether the billable field should be calculated automatically.
* @var string
*/
private $billableMode = self::BILLABLE_DEFAULT;
/**
* @var string
*
@@ -649,6 +659,11 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
return $this->billable;
}
public function getBillable(): bool
{
return $this->billable;
}
public function setBillable(bool $billable): Timesheet
{
$this->billable = $billable;
@@ -656,6 +671,16 @@ class Timesheet implements EntityWithMetaFields, ExportItemInterface
return $this;
}
public function getBillableMode(): string
{
return $this->billableMode;
}
public function setBillableMode(string $billableMode): void
{
$this->billableMode = $billableMode;
}
public function getFixedRate(): ?float
{
return $this->fixedRate;

View File

@@ -10,12 +10,22 @@
namespace App\Form\API;
use App\Form\TimesheetEditForm;
use App\Form\Type\BillableType;
use App\Form\Type\TagsInputType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TimesheetApiEditForm extends TimesheetEditForm
{
protected function addBillable(FormBuilderInterface $builder, array $options)
{
if (!$options['include_billable']) {
return;
}
$builder->add('billable', BillableType::class, []);
}
/**
* {@inheritdoc}
*/
@@ -49,6 +59,7 @@ class TimesheetApiEditForm extends TimesheetEditForm
// because the docs are cached without these fields otherwise
'include_user' => true,
'include_exported' => true,
'include_billable' => true,
'include_rate' => true,
]);
}

View File

@@ -9,6 +9,7 @@
namespace App\Form;
use App\Form\Type\BillableType;
use App\Form\Type\BudgetType;
use App\Form\Type\DurationType;
use App\Form\Type\MetaFieldsCollectionType;
@@ -48,7 +49,9 @@ trait EntityFormTrait
$builder
->add('visible', YesNoType::class, [
'label' => 'label.visible',
]);
])
->add('billable', BillableType::class)
;
}
/**

View File

@@ -265,7 +265,7 @@ class TimesheetMultiUpdate extends AbstractType
'include_user' => false,
'include_rate' => false,
'include_exported' => false,
'include_billable' => true,
'include_billable' => false,
]);
}
}

View File

@@ -10,7 +10,6 @@
namespace App\Form;
use App\Entity\Timesheet;
use App\Form\Type\BillableType;
use App\Form\Type\DateTimePickerType;
use App\Form\Type\DescriptionType;
use App\Form\Type\DurationType;
@@ -18,10 +17,12 @@ use App\Form\Type\FixedRateType;
use App\Form\Type\HourlyRateType;
use App\Form\Type\MetaFieldsCollectionType;
use App\Form\Type\TagsType;
use App\Form\Type\TimesheetBillableType;
use App\Form\Type\UserType;
use App\Form\Type\YesNoType;
use App\Repository\CustomerRepository;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\CallbackTransformer;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\Form\FormEvent;
use Symfony\Component\Form\FormEvents;
@@ -169,7 +170,7 @@ class TimesheetEditForm extends AbstractType
{
$durationOptions = [
'required' => false,
'docu_chapter' => 'timesheet.html#duration-format',
'docu_chapter' => 'duration-format.html',
'attr' => [
'placeholder' => '0:00',
],
@@ -261,11 +262,57 @@ class TimesheetEditForm extends AbstractType
protected function addBillable(FormBuilderInterface $builder, array $options)
{
if (!$options['include_billable']) {
return;
if ($options['include_billable']) {
$builder->add('billableMode', TimesheetBillableType::class, []);
}
$builder->add('billable', BillableType::class, []);
$builder->addModelTransformer(new CallbackTransformer(
function (Timesheet $record) {
if ($record->getBillableMode() === Timesheet::BILLABLE_DEFAULT) {
if ($record->isBillable()) {
$record->setBillableMode(Timesheet::BILLABLE_YES);
} else {
$record->setBillableMode(Timesheet::BILLABLE_NO);
}
}
return $record;
},
function (Timesheet $record) {
switch ($record->getBillableMode()) {
case Timesheet::BILLABLE_NO:
$record->setBillable(false);
break;
case Timesheet::BILLABLE_YES:
$record->setBillable(true);
break;
case Timesheet::BILLABLE_AUTOMATIC:
$billable = true;
$activity = $record->getActivity();
if ($activity !== null && !$activity->isBillable()) {
$billable = false;
}
$project = $record->getProject();
if ($billable && $project !== null && !$project->isBillable()) {
$billable = false;
}
if ($billable && $project !== null) {
$customer = $project->getCustomer();
if ($customer !== null && !$customer->isBillable()) {
$billable = false;
}
}
$record->setBillable($billable);
break;
}
return $record;
}
));
}
/**

View File

@@ -14,7 +14,6 @@ use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select if something is billable.
* To be used in combination with the invoicing system.
*/
class BillableType extends AbstractType
{

View File

@@ -0,0 +1,44 @@
<?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\Form\Type;
use App\Entity\Timesheet;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\OptionsResolver\OptionsResolver;
/**
* Custom form field type to select if a timesheet is billable.
*/
class TimesheetBillableType extends AbstractType
{
/**
* {@inheritdoc}
*/
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'label' => 'label.billable',
'choices' => [
'automatic' => Timesheet::BILLABLE_AUTOMATIC,
'yes' => Timesheet::BILLABLE_YES,
'no' => Timesheet::BILLABLE_NO,
],
]);
}
/**
* {@inheritdoc}
*/
public function getParent()
{
return ChoiceType::class;
}
}

View File

@@ -0,0 +1,49 @@
<?php
declare(strict_types=1);
/*
* This file is part of the Kimai time-tracking app.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace DoctrineMigrations;
use App\Doctrine\AbstractMigration;
use Doctrine\DBAL\Schema\Schema;
final class Version20220315224645 extends AbstractMigration
{
public function getDescription(): string
{
return 'Adds billable fields to Customer, Project and Activity';
}
public function up(Schema $schema): void
{
$customers = $schema->getTable('kimai2_customers');
$customers->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]);
$projects = $schema->getTable('kimai2_projects');
$projects->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]);
$activities = $schema->getTable('kimai2_activities');
$activities->addColumn('billable', 'boolean', ['notnull' => true, 'default' => true]);
$this->addSql('DELETE from kimai2_configuration WHERE `name` = "defaults.timesheet.billable"');
}
public function down(Schema $schema): void
{
$customers = $schema->getTable('kimai2_customers');
$customers->dropColumn('billable');
$projects = $schema->getTable('kimai2_projects');
$projects->dropColumn('billable');
$activities = $schema->getTable('kimai2_activities');
$activities->dropColumn('billable');
}
}

View File

@@ -110,7 +110,7 @@ final class TimesheetService
$mode = $this->trackingModeService->getActiveMode();
$mode->create($timesheet, $request);
$timesheet->setBillable($this->configuration->getTimesheetDefaultBillable());
$timesheet->setBillableMode(Timesheet::BILLABLE_AUTOMATIC);
return $timesheet;
}
@@ -118,6 +118,7 @@ final class TimesheetService
/**
* @param Timesheet $timesheet
* @param Timesheet $copyFrom
* @return Timesheet
* @throws ValidationFailedException for invalid timesheets or running timesheets that should be stopped
* @throws InvalidArgumentException for already persisted timesheets
* @throws AccessDeniedException if user is not allowed to start timesheet

View File

@@ -44,6 +44,7 @@ final class TimesheetVoter extends Voter
self::VIEW_RATE,
self::EDIT_RATE,
self::EDIT_EXPORT,
'edit_billable',
'duplicate'
];
@@ -130,6 +131,7 @@ final class TimesheetVoter extends Voter
case self::VIEW:
case self::EXPORT:
case self::EDIT_EXPORT:
case 'edit_billable':
$permission .= $attribute;
break;