Skip to content

Commit

Permalink
Merge pull request #222 from RSE-Sheffield/refactor/over-100-fte
Browse files Browse the repository at this point in the history
Refactor - allow directly incurred project to have more than 100 FTE allocated
  • Loading branch information
yld-weng authored Aug 10, 2023
2 parents 6f2594d + 48cc06e commit 26e6be9
Show file tree
Hide file tree
Showing 4 changed files with 146 additions and 35 deletions.
19 changes: 19 additions & 0 deletions rse/migrations/0008_alter_directlyincurredproject_percentage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Generated by Django 3.2.19 on 2023-07-26 16:16

import django.core.validators
from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('rse', '0007_auto_20230724_1726'),
]

operations = [
migrations.AlterField(
model_name='directlyincurredproject',
name='percentage',
field=models.FloatField(validators=[django.core.validators.MinValueValidator(0), django.core.validators.MaxValueValidator(1000)]),
),
]
61 changes: 37 additions & 24 deletions rse/models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from __future__ import annotations
from datetime import date, timedelta
from django.utils import timezone
from math import floor
Expand Down Expand Up @@ -28,7 +29,7 @@

class TypedQuerySet(Generic[T]):
"""
Django type hints for query sets are not typed (not very usefull).
Django type hints for query sets are not typed (not very useful).
The following class can eb used to provide type information (see: https://stackoverflow.com/a/54797356)
"""
def __iter__(self) -> Iterator[Union[T, QuerySet]]:
Expand All @@ -38,12 +39,13 @@ def __iter__(self) -> Iterator[Union[T, QuerySet]]:
class SalaryValue():
"""
Class to represent a salary calculation.
Has a salary aa dictionary for each to log how the salary/overhead was calculated (for each chargable period)
Has a salary aa dictionary for each to log how the salary/overhead was calculated (for each chargeable period)
"""
def __init__(self):
self.staff_cost = 0
self.cost_breakdown = []
self.allocation_breakdown = {}
self.oncosts_multiplier = settings.ONCOSTS_SALARY_MULTIPLIER

def add_staff_cost(self, salary_band, from_date: date, until_date: date, percentage: float = 100.0):
cost_in_period = SalaryBand.salaryCost(days=(until_date - from_date).days, salary=salary_band.salary, percentage=percentage)
Expand Down Expand Up @@ -225,7 +227,6 @@ def staff_cost(self, start: date, end: date, percentage: float = 100.0) -> Salar
Note: Should only be used for costing project staff budget values (i.e. non allocated staff time) as
RSEs may have increments which need to be considered in the costing.
"""

# Check for obvious stupid
if end < start:
raise ValueError('End date is before start date')
Expand All @@ -247,7 +248,6 @@ def staff_cost(self, start: date, end: date, percentage: float = 100.0) -> Salar

# Update salary cost
salary_value.add_staff_cost(salary_band=next_sb, from_date=next_increment, until_date=temp_next_increment, percentage=percentage)

# Calculate the next salary band
# This cant be done before cost calculation salary_band_next_financial_year may modify the next_sb object
if next_increment.month < 8: # If date is before financial year then date range spans financial year
Expand All @@ -257,10 +257,9 @@ def staff_cost(self, start: date, end: date, percentage: float = 100.0) -> Salar

# update chargeable period date and band
next_increment = temp_next_increment

# Final salary cost for period not spanning a salary change
salary_value.add_staff_cost(salary_band=next_sb, from_date=next_increment, until_date=end, percentage=percentage)

return salary_value


Expand Down Expand Up @@ -397,7 +396,7 @@ def employed_in_period(self, from_date: date, until_date: date):
def staff_cost(self, from_date: date, until_date: date, percentage:float = 100):
"""
Calculates the staff cost between a given period. This function must consider any increments, changes in financial
year as well as any additional salary grade changes. It works by iterating through chargable periods looking for
year as well as any additional salary grade changes. It works by iterating through chargeable periods looking for
changes in staff salary.
This is different to a salary bad staff cost as it also considers salary grade changes which may be the result of
Expand Down Expand Up @@ -619,7 +618,7 @@ def spans_salary_grade_change(self, start: date, end: date) -> bool:
else:
return False

def next_salary_grade_change(self, start: date, end: date) -> bool:
def next_salary_grade_change(self, start: date, end: date) -> Union[SalaryGradeChange, None]:
"""
Returns the next salary grade change if there is one or None
"""
Expand Down Expand Up @@ -683,8 +682,8 @@ class Project(PolymorphicModel):
)

@property
def chargable(self):
""" Indicates if the project is chargable in a cost distribution. I.e. Internal projects are not chargable and neither are non charged service projects. """
def chargeable(self):
""" Indicates if the project is chargeable in a cost distribution. I.e. Internal projects are not chargeable and neither are non charged service projects. """
pass

@staticmethod
Expand Down Expand Up @@ -749,7 +748,7 @@ def fte(self) -> int:
@property
def project_days(self) -> float:
""" Duration times by fte """
return self.duration * self.fte / 100.0
return self.duration * (self.fte / 100.0)

@property
def committed_days(self) -> float:
Expand Down Expand Up @@ -881,13 +880,20 @@ class DirectlyIncurredProject(Project):
Previously the 'Allocated' project, this model was renamed because it does not fit with terminology used at UoS.
Allocated is generally an academic member of staff rather than charged to the grant.
"""
percentage = models.FloatField(validators=[MinValueValidator(0), MaxValueValidator(100)]) # FTE percentage
overheads = models.DecimalField(max_digits=8, decimal_places=2) # Overheads are a pro rota amount per year
salary_band = models.ForeignKey(SalaryBand, on_delete=models.PROTECT) # Don't allow salary band deletion if there are allocations associated with it

percentage = models.FloatField(validators=[MinValueValidator(0), MaxValueValidator(1000)])
"""
FTE percentage for the project. This was increased from 100 because sometimes
there are more than 100% FTE spent on the project.
"""
overheads = models.DecimalField(max_digits=8, decimal_places=2)
"""Overheads are a pro rota amount per year."""
salary_band = models.ForeignKey(SalaryBand, on_delete=models.PROTECT)
"""Don't allow salary band deletion if there are allocations associated with it."""

@property
def chargable(self):
""" Indicates if the project is chargable in a cost distribution. I.e. Internal projects are not chargable."""
def chargeable(self):
""" Indicates if the project is chargeable in a cost distribution. I.e. Internal projects are not chargeable."""
return not self.internal

@property
Expand Down Expand Up @@ -928,7 +934,7 @@ def staff_budget(self) -> float:
def overhead_value(self, from_date: date = None, until_date: date = None, percentage: float = None) -> float:
"""
Function calculates the value of any overheads generated.
For allocated projects this is based on duration and a fixed overhead rate.
For directly incurred projects this is based on duration and a fixed overhead rate.
Cap the from and end dates according to the project as certain queries may have dates based on financial years rather than project dates
"""

Expand Down Expand Up @@ -968,15 +974,22 @@ class ServiceProject(Project):
"""
ServiceProject is a number of service days in which work should be undertaken. The projects dates set parameters for which the work can be undertaken but do not define the exact dates in which the work will be conducted. An allocation will convert the service days into an FTE equivalent so that time can be scheduled including holidays.
"""
days = models.IntegerField(default=1) # duration in days
rate = models.DecimalField(max_digits=8, decimal_places=2) # service rate
charged = models.BooleanField(default=True) # Should staff time be charged to serice account
days = models.IntegerField(default=1)
""" Duration of the project in days. """
rate = models.DecimalField(max_digits=8, decimal_places=2)
""" Service rate """
charged = models.BooleanField(default=True)
""" Should staff time be charged to service account """
invoice_received = models.DateField(null=True, blank=True)
""" Whether the invoice is received, if yes, specifies the date. """

@property
def chargable(self):
""" Indicates if the project is chargable in a cost distribution. I.e. Internal projects are not chargable and neither are non charged service projects. """
return not self.internal and charged
def chargeable(self):
"""
Indicates if the project is chargeable in a cost distribution.
I.e. Internal projects are not chargeable and neither are non charged service projects.
"""
return not self.internal and self.charged

@staticmethod
def days_to_fte_days(days: int) -> int:
Expand All @@ -991,7 +1004,7 @@ def days_to_fte_days(days: int) -> int:
@property
def duration(self) -> int:
"""
Use the avilable static method to convert days to FTE days
Use the available static method to convert days to FTE days
"""
return ServiceProject.days_to_fte_days(self.days)

Expand Down
79 changes: 76 additions & 3 deletions rse/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ def setup_user_and_rse_data():
rse = RSE(user=user)
rse.employed_until = date(2025, 10, 1)
rse.save()

# create a user to test a project over 100 FTE
user = User.objects.create_user(username='testuser4', password='12345')
rse = RSE(user=user)
rse.employed_until = date(2027, 10, 1)
rse.save()


def setup_salary_and_banding_data():
Expand Down Expand Up @@ -98,12 +104,14 @@ def setup_salary_and_banding_data():
sb15_2019.save()

# Create salary grade changes (requires that an RSE has been created in database)
# The following assign grade 1.1 to RSE at 08/2017, then increment to grade 1.3 at 08/2018
sgc1 = SalaryGradeChange(rse=rse, salary_band=sb11_2017, date=sb11_2017.year.start_date())
sgc1.save()
sgc2 = SalaryGradeChange(rse=rse, salary_band=sb13_2018, date=sb13_2018.year.start_date())
sgc2.save()

# Create a salary grade change on 1st january to test for double increments
# The following assign grade 1.1 to RSE2 at 1/1/2018, then increment to grade 1.3 at 1/1/2019
rse2 = RSE.objects.get(user__username='testuser2')
sgc3 = SalaryGradeChange(rse=rse2, salary_band=sb11_2017, date=date(2018, 1, 1))
sgc3.save()
Expand All @@ -115,7 +123,11 @@ def setup_salary_and_banding_data():
sgc4 = SalaryGradeChange(rse=rse3, salary_band=sb11_2019, date=sb11_2019.year.start_date())
sgc4.save()


rse4 = RSE.objects.get(user__username='testuser4')
sgc5 = SalaryGradeChange(rse=rse4, salary_band=sb15_2018, date=date(2018, 1, 1))
sgc5.save()
sgc6 = SalaryGradeChange(rse=rse4, salary_band=sb15_2019, date=date(2019, 1, 1))
sgc6.save()


def setup_project_and_allocation_data():
Expand All @@ -128,12 +140,15 @@ def setup_project_and_allocation_data():

# get expected salary band from database
sb15_2017 = SalaryBand.objects.get(grade=1, grade_point=5, year=2017)
sb15_2018 = SalaryBand.objects.get(grade=1, grade_point=5, year=2018)
# get user from database
user = User.objects.get(username='testuser')
user3 = User.objects.get(username='testuser3')
# get rse from database
rse = RSE.objects.get(user=user)
rse3 = RSE.objects.get(user=user3)

# create some test projects
# create a client and some test projects
c = Client(name="test_client")
c.department = "COM"
c.save()
Expand Down Expand Up @@ -172,6 +187,25 @@ def setup_project_and_allocation_data():
status='F')
p2.save()


# Create a directly incurred project to test over 100 FTE
p3 = DirectlyIncurredProject(
percentage=110,
overheads=250.00,
salary_band=sb15_2018,
# base class values
creator=user,
created=timezone.now(),
proj_costing_id="12345",
name="test_project_2",
description="none",
client=c,
start=date(2018, 1, 1),
end=date(2019, 2, 1),
status='F'
)
p3.save()

# Create an allocation for the DirectlyIncurredProject (spanning full 2017 financial year)
a = RSEAllocation(rse=rse,
project=p,
Expand All @@ -196,6 +230,24 @@ def setup_project_and_allocation_data():
end=date(2017, 9, 1))
a3.save()

a4 = RSEAllocation(
rse=rse,
project=p3,
percentage=20,
start=date(2018, 1, 1),
end=date(2019, 2, 1)
)
a4.save()

a5 = RSEAllocation(
rse=rse3,
project=p3,
percentage=90,
start=date(2018, 1, 1),
end=date(2018, 12, 31)
)
a5.save()



##############
Expand Down Expand Up @@ -590,7 +642,8 @@ def test_polymorphism(self):
# Should return correctly typed concrete implementations of abstract Project type
p = Project.objects.all()[1]
self.assertIsInstance(p, ServiceProject)



def test_project_duration(self):
"""
Tests polymorphic function duration which differs depending on project type
Expand All @@ -609,6 +662,12 @@ def test_project_duration(self):
p = Project.objects.all()[1]
self.assertEqual(p.duration, 49)

# 2018.1.1 - 2018.7.31 212 days
# 2018.8.1 - 2019.2.1 186 days
p = Project.objects.get(name="test_project_2")
self.assertEqual(p.duration, 396)


# Remove Oncosts in settings
@override_settings(ONCOSTS_SALARY_MULTIPLIER=1.0)
def test_project_value(self):
Expand All @@ -631,6 +690,20 @@ def test_project_value(self):
p = Project.objects.all()[1]
self.assertAlmostEqual(p.value(), 8250.00, places=2)


"""Calculate the value for a 110% FTE project
Cost breakdown (in project salary band rather than individual RSE salaries)
110% of
5001 (2018 G1.5) * 212/365 (17 FY) +
5002 (2018 G1.5) * 153/365 (18 FY) +
5002 (2019 G1.5) * 31/365
Increment to 5002 from Aug 2018 because we use next year's salary on Jan
for FY.
"""
p = Project.objects.get(name="test_project_2")
self.assertIsInstance(p, DirectlyIncurredProject)
self.assertAlmostEqual(p.staff_budget(), 5968.87, places=2)


class EdgeCasesDivByZeros(TestCase):

Expand Down
22 changes: 14 additions & 8 deletions rse/tests/test_random_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@
from rse.models import *
import random

#####
MIN_PROJECT_PERCENTAGE = 5
MAX_PROJECT_PERCENTAGE = 120
PERCENTAGE_STEP = 5


###########################################
# Helper functions for creating test data #
###########################################
Expand Down Expand Up @@ -139,7 +145,7 @@ def random_project_and_allocation_data():
#random choice between allocated or service project
if random.random()>0.5:
# allocated
percentage = random.randrange(5, 50, 5) # 5% to 50% with 5% step
percentage = random.randrange(MIN_PROJECT_PERCENTAGE, MAX_PROJECT_PERCENTAGE, PERCENTAGE_STEP) # 5% to 50% with 5% step
p_temp = DirectlyIncurredProject(
percentage=percentage,
overheads=250.00,
Expand Down Expand Up @@ -181,12 +187,12 @@ def random_project_and_allocation_data():
for p in Project.objects.all():
allocated = 0
# fill allocations on project until fully allocated
while allocated<p.fte:
while allocated < p.fte:
r = random.choice(RSE.objects.all())
if p.fte-allocated == 5:
percentage = 5
if p.fte-allocated == MIN_PROJECT_PERCENTAGE:
percentage = MIN_PROJECT_PERCENTAGE
else:
percentage = random.randrange(5, p.fte-allocated, 5) # 5% step
percentage = random.randrange(MIN_PROJECT_PERCENTAGE, p.fte-allocated, PERCENTAGE_STEP) # 5% step
# Create the allocation
allocation = RSEAllocation(rse=r,
project=p,
Expand Down Expand Up @@ -226,9 +232,9 @@ def test_random_projects(self):
self.assertIn(p.status, Project.status_choice_keys())

if isinstance(p, DirectlyIncurredProject):
# percentage should be between 5% and 50%
self.assertLessEqual(p.percentage, 50)
self.assertGreaterEqual(p.percentage, 5)
# percentage should be between min% and max%
self.assertLessEqual(p.percentage, MAX_PROJECT_PERCENTAGE)
self.assertGreaterEqual(p.percentage, MIN_PROJECT_PERCENTAGE)

# start must be before end
self.assertLess(p.start, p.end)
Expand Down

0 comments on commit 26e6be9

Please sign in to comment.