Skip to content

Commit

Permalink
FFT-152 Commit payroll figures into forecast (#610)
Browse files Browse the repository at this point in the history
  • Loading branch information
SamDudley authored Feb 5, 2025
1 parent 2393899 commit 1c9f180
Show file tree
Hide file tree
Showing 34 changed files with 751 additions and 130 deletions.
2 changes: 1 addition & 1 deletion .env.ci
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ AUTHBROKER_URL=
SENTRY_ENVIRONMENT=ci
SENTRY_DSN=
CSP_REPORT_URI=
PAYROLL='{"BASIC_PAY_NAC": "71111001", "PENSION_NAC": "71111002", "ERNIC_NAC": "71111003", "VACANCY_NAC": "71111001"}'
PAYROLL='{"BASIC_PAY_NAC": 71111001, "PENSION_NAC": 71111002, "ERNIC_NAC": 71111003, "VACANCY_NAC": 71111001, "ENABLE_FORECAST": true}'
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ CSP_REPORT_URI=
# Vite
VITE_DEV=True

PAYROLL='{"BASIC_PAY_NAC": "71111001", "PENSION_NAC": "71111002", "ERNIC_NAC": "71111003", "VACANCY_NAC": "71111001"}'
PAYROLL='{"BASIC_PAY_NAC": 71111001, "PENSION_NAC": 71111002, "ERNIC_NAC": 71111003, "VACANCY_NAC": 71111001, "ENABLE_FORECAST": true}'

# Not documented (needed?)
# RESTRICT_ADMIN=True
Expand Down
54 changes: 53 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ The names of the management commands denote their function.
- Add the user app initial migration to the list of django migrations that have been run
- Deploy new codebase

# Setup DebugPy
## Setup DebugPy

Add environment variable in your .env file

Expand Down Expand Up @@ -173,3 +173,55 @@ Create launch.json file inside .vscode directory
]
}
```

## Implementation notes

### Reducers

All the reducers in `front_end/src/Reducers/` are for the `Forecast` React "app".

### ForecastMonthlyFigure

`ForecastMonthlyFigure` is treated as a sparse matrix of actual/forecast data. What do I
mean by this? Well if the `amount` is `0`, then the object is not always created and
therefore does not exist. The code then relies on a default behaviour when the object
related to that figure does not exist.

I guess that this was done to reduce the number of rows that would be needed in that
table.

**Examples**

- Paste to excel - not created if amount is 0
- Edit forecast table cell - created regardless

### Rounding

> For context, FFT stores monetary values as integer pence.

I have found that FFT is using a couple of different rounding methods. The differences
could introduce unexpected behaviour so I wanted to document the differences here.

Python's `round` uses a round to nearest even number approach.

```python
round(0.5) == 0
round(1.5) == 2
```

JavaScript's `Math.round` uses a round half up to nearest number approach.

```javascript
Math.round(0.5) === 1;
Math.round(1.5) === 2;
```

NumPy's `numpy.round` uses a round to nearest even number approach as well.

Python's `Decimal` is also used to parse decimal numbers which are then multiplied by
100 and stored in Django's `IntegerField`. This will truncate the decimal point numbers,
effectively flooring them.

It might be that these inconsistencies don't come up in practice, or that they are there
on purpose and expected/useful to users. However, I still think it's worth noting that
all these approaches are used and that there could be issues.
12 changes: 6 additions & 6 deletions chartofaccountDIT/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,12 @@ class Meta:

# define level1 values: Capital, staff, etc is Level 1 in UKTI nac hierarchy
class NaturalCodeAbstract(models.Model):
class Meta:
abstract = True
verbose_name = "Natural Account Code (NAC)"
verbose_name_plural = "Natural Account Codes (NAC)"
ordering = ["natural_account_code"]

natural_account_code = models.IntegerField(primary_key=True, verbose_name="NAC",)
natural_account_code_description = models.CharField(
max_length=200, verbose_name="NAC Description"
Expand Down Expand Up @@ -354,12 +360,6 @@ def __str__(self):
self.natural_account_code, self.natural_account_code_description
)

class Meta:
abstract = True
verbose_name = "Natural Account Code (NAC)"
verbose_name_plural = "Natural Account Codes (NAC)"
ordering = ["natural_account_code"]


class NaturalCode(NaturalCodeAbstract, IsActiveModel):
expenditure_category = models.ForeignKey(
Expand Down
23 changes: 19 additions & 4 deletions config/settings/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@ def FILTERS_VERBOSE_LOOKUPS():
SETTINGS_EXPORT = [
"DEBUG",
"GTM_CODE",
"PAYROLL",
]

MIDDLEWARE = [
Expand Down Expand Up @@ -421,13 +422,27 @@ def FILTERS_VERBOSE_LOOKUPS():


# Payroll
# TODO: Should we flatten this?
@dataclass
class Payroll:
BASIC_PAY_NAC: str | None = None
PENSION_NAC: str | None = None
ERNIC_NAC: str | None = None
VACANCY_NAC: str | None = None
BASIC_PAY_NAC: int | None = None
PENSION_NAC: int | None = None
ERNIC_NAC: int | None = None
VACANCY_NAC: int | None = None
AVERAGE_SALARY_THRESHOLD: int = 2
# TODO (FFT-176): Payroll post-release cleanup
ENABLE_FORECAST: bool = False

@property
def nacs(self):
return set(
(
self.BASIC_PAY_NAC,
self.PENSION_NAC,
self.ERNIC_NAC,
self.VACANCY_NAC,
)
)


PAYROLL: Payroll = Payroll(**env.json("PAYROLL", default={}))
9 changes: 9 additions & 0 deletions core/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,15 @@ def clean(self):
"Monthly pay uplifts must be greater than or equal to 1.0"
)

def save(self, *args, **kwargs):
super().save(*args, **kwargs)

# TODO: Update payroll forecast when pay uplift is updated.
# Avoid circular import
# from payroll.services.payroll import update_all_payroll_forecast

# update_all_payroll_forecast(financial_year=self.financial_year)


class Attrition(PayModifiers):
class Meta:
Expand Down
41 changes: 41 additions & 0 deletions core/static/core/js/feature-flags.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Returns a feature flag checker.
*
* @example
* // <meta name="app:features:foo" content="true">
* const FEATURES = FeatureFlags("app:features");
* FEATURES.foo === true;
* FEATURES.bar === false;
*
* @param {string} namespace - Prefix for the meta tag names.
* @returns {object} Proxy object to check feature flags.
*/
export default function FeatureFlags(namespace) {
const cache = new Map();

const featureFlagHandler = {
get(target, prop, receiver) {
const selector = `meta[name="${namespace}:${prop}"]`;

const el = document.querySelector(selector);

if (!el) {
return false;
}

if (cache.has(prop)) {
return cache.get(prop);
}

const value = el.content.toLowerCase() === "true";

cache.set(prop, value);

return value;
},
};

const featureFlagProxy = new Proxy({}, featureFlagHandler);

return featureFlagProxy;
}
23 changes: 15 additions & 8 deletions core/templates/base_generic.html
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@

<meta http-equiv="X-UA-Compatible" content="IE=edge"/>

<!-- Feature flags -->
<meta name="fft:features:payroll_enable_forecast" content="{{ settings.PAYROLL.ENABLE_FORECAST }}">

<link rel="shortcut icon" sizes="16x16 32x32 48x48" href="{% static 'govuk/assets/images/favicon.ico' %}" type="image/x-icon"/>
<link rel="mask-icon" href="{% static 'govuk/assets/images/govuk-mask-icon.svg' %}" color="#0b0c0c">
<link rel="apple-touch-icon" sizes="180x180" href="{% static 'govuk/assets/images/govuk-apple-touch-icon-180x180.png' %}">
Expand Down Expand Up @@ -252,15 +255,19 @@
</div>
</footer>

{% block scripts %}
<script src="/static/govuk/all.js"></script>
<script>
window.GOVUKFrontend.initAll();
<script src="/static/govuk/all.js"></script>
<script type="module">
import FeatureFlags from "{% static 'core/js/feature-flags.js' %}";

function getCsrfToken() {
return "{{ csrf_token }}";
}
</script>
window.GOVUKFrontend.initAll();

function getCsrfToken() {
return "{{ csrf_token }}";
}

window.FEATURES = FeatureFlags("fft:features");
</script>
{% block scripts %}
{% endblock %}
{% vite_dev_client react=False %}
</body>
Expand Down
1 change: 1 addition & 0 deletions core/test/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
class FinancialYearFactory(factory.django.DjangoModelFactory):
class Meta:
model = FinancialYear
django_get_or_create = ("financial_year",)

current = True
financial_year = 2019
Expand Down
File renamed without changes.
14 changes: 14 additions & 0 deletions forecast/models.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import copy
import hashlib

from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.db import models
Expand Down Expand Up @@ -442,6 +443,19 @@ def save(self, *args, **kwargs):

super(FinancialCodeAbstract, self).save(*args, **kwargs)

@property
def is_locked(self) -> bool:
# TODO: Should `FinancialCode` have knowledge of payroll?
if settings.PAYROLL.ENABLE_FORECAST is False:
return False

return (
self.natural_account_code_id in settings.PAYROLL.nacs
and self.analysis1_code is None
and self.analysis2_code is None
and self.project_code is None
)


class FinancialCode(FinancialCodeAbstract, BaseModel):
programme = models.ForeignKey(ProgrammeCode, on_delete=models.PROTECT)
Expand Down
5 changes: 5 additions & 0 deletions forecast/serialisers.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,15 @@ class FinancialCodeSerializer(serializers.ModelSerializer):
nac_description = serializers.SerializerMethodField(
"get_nac_description",
)
is_locked = serializers.SerializerMethodField()

class Meta:
model = FinancialCode
fields = [
"programme_description",
"nac_description",
"natural_account_code",
"is_locked",
"programme",
"cost_centre",
"analysis1_code",
Expand All @@ -62,3 +64,6 @@ def get_programme_description(self, obj):

def get_nac_description(self, obj):
return obj.natural_account_code.natural_account_code_description

def get_is_locked(self, obj):
return obj.is_locked
46 changes: 46 additions & 0 deletions forecast/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
from core.constants import MONTHS
from core.models import FinancialYear
from forecast.models import FinancialCode, FinancialPeriod, ForecastMonthlyFigure


class FinancialCodeForecastService:
def __init__(
self,
*,
financial_code: FinancialCode,
year: FinancialYear,
override_locked: bool = False,
):
self.financial_code = financial_code
self.year = year
self.override_locked = override_locked

def update_period(self, *, period: int | FinancialPeriod, amount: int):
if isinstance(period, int):
period = FinancialPeriod.objects.get(pk=period)

assert isinstance(period, FinancialPeriod)

if period.actual_loaded:
return

if self.financial_code.is_locked and not self.override_locked:
return

figure, _ = ForecastMonthlyFigure.objects.get_or_create(
financial_code=self.financial_code,
financial_year=self.year,
financial_period=period,
archived_status=None,
)

# NOTE: Not deleting the figure if the amount is 0 like in other places.

figure.amount = amount
figure.save()

def update(self, forecast: list[int]):
assert len(forecast) == len(MONTHS)

for i, _ in enumerate(MONTHS):
self.update_period(period=i + 1, amount=forecast[i])
3 changes: 1 addition & 2 deletions forecast/templates/forecast/view/cost_centre.html
Original file line number Diff line number Diff line change
Expand Up @@ -90,8 +90,7 @@ <h2 class="govuk-heading-l">{{ table.attrs.caption }}</h2>
{% endblock %}

{% block scripts %}
{{ block.super }}
<script type="application/javascript">
<script>
let allCC = document.getElementById("all-cc");
let myCC = document.getElementById("my-cc");

Expand Down
1 change: 1 addition & 0 deletions forecast/test/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
class FinancialPeriodFactory(factory.django.DjangoModelFactory):
class Meta:
model = FinancialPeriod
django_get_or_create = ("financial_period_code",)

financial_period_code = 1
period_long_name = "April"
Expand Down
12 changes: 11 additions & 1 deletion forecast/test/test_edit_forecast_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,10 @@
ProjectCodeFactory,
)
from core.test.test_base import TEST_COST_CENTRE, BaseTestCase
from core.utils.generic_helpers import get_current_financial_year
from core.utils.generic_helpers import (
get_current_financial_year,
get_financial_year_obj,
)
from costcentre.test.factories import (
CostCentreFactory,
DepartmentalGroupFactory,
Expand Down Expand Up @@ -236,6 +239,13 @@ def test_duplicate_values_different_cost_centre_valid(self):
self.assertEqual(FinancialCode.objects.count(), 2)


class AddFutureForecastRowTest(AddForecastRowTest):
def setUp(self):
super().setUp()
future_year_obj = get_financial_year_obj(self.financial_year + 1)
self.financial_year = future_year_obj.financial_year


class ChooseCostCentreTest(BaseTestCase):
def setUp(self):
self.client.force_login(self.test_user)
Expand Down
Loading

0 comments on commit 1c9f180

Please sign in to comment.