diff --git a/config/flags.py b/config/flags.py new file mode 100644 index 000000000..0d62a8793 --- /dev/null +++ b/config/flags.py @@ -0,0 +1 @@ +ACTUALISATION = "actualisation" diff --git a/conftest.py b/conftest.py new file mode 100644 index 000000000..a275cd59f --- /dev/null +++ b/conftest.py @@ -0,0 +1,17 @@ +import pytest +from django.contrib.auth import get_user_model + + +@pytest.fixture +def test_user(db): + test_user_email = "test@test.com" + test_password = "test_password" + + test_user, _ = get_user_model().objects.get_or_create( + username="test_user", + email=test_user_email, + ) + test_user.set_password(test_password) + test_user.save() + + return test_user diff --git a/core/constants.py b/core/constants.py index b580d3824..9cd05d9b6 100644 --- a/core/constants.py +++ b/core/constants.py @@ -1,4 +1,4 @@ -from .types import Months +from .types import FinancialPeriods, Months MONTHS: Months = ( @@ -15,3 +15,4 @@ "feb", "mar", ) +PERIODS: FinancialPeriods = (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12) diff --git a/core/types.py b/core/types.py index 3e7427ef2..a32670289 100644 --- a/core/types.py +++ b/core/types.py @@ -1,6 +1,21 @@ from typing import Literal, TypedDict +FinancialPeriods = tuple[ + Literal[1], + Literal[2], + Literal[3], + Literal[4], + Literal[5], + Literal[6], + Literal[7], + Literal[8], + Literal[9], + Literal[10], + Literal[11], + Literal[12], +] + Month = Literal[ "apr", "may", diff --git a/forecast/import_actuals.py b/forecast/import_actuals.py index e769cb111..78bfe13fe 100644 --- a/forecast/import_actuals.py +++ b/forecast/import_actuals.py @@ -1,8 +1,12 @@ import datetime import logging +import waffle from django.db import connection +from django.db.models import F +from config import flags +from core.constants import PERIODS from core.import_csv import get_fk, get_fk_from_field from core.models import FinancialYear from forecast.models import ( @@ -292,8 +296,16 @@ def upload_trial_balance_report(file_upload, month_number, financial_year): if check_financial_code.error_found: final_status = FileUpload.PROCESSEDWITHERROR else: + uploaded_actuals = ActualUploadMonthlyFigure.objects.filter( + financial_year=financial_year, financial_period=period_obj + ) + # Now copy the newly uploaded actuals to the correct table if year_obj.current: + if waffle.switch_is_active(flags.ACTUALISATION): + for uploaded_actual in uploaded_actuals: + actualisation(period=period_obj, actual=uploaded_actual) + copy_current_year_actuals_to_monthly_figure(period_obj, financial_year) FinancialPeriod.objects.filter( financial_period_code__lte=period_obj.financial_period_code @@ -307,11 +319,42 @@ def upload_trial_balance_report(file_upload, month_number, financial_year): if check_financial_code.warning_found: final_status = FileUpload.PROCESSEDWITHWARNING - ActualUploadMonthlyFigure.objects.filter( - financial_year=financial_year, financial_period=period_obj - ).delete() + uploaded_actuals.delete() set_file_upload_feedback( file_upload, f"Processed {rows_to_process} rows.", final_status ) return True + + +def actualisation(period: FinancialPeriod, actual: ActualUploadMonthlyFigure) -> None: + # get the current forecast that is being turned into an actual + forecast = ForecastMonthlyFigure.objects.filter( + financial_code_id=actual.financial_code_id, + financial_year_id=actual.financial_year_id, + financial_period_id=actual.financial_period_id, + archived_status__isnull=True, + ).first() + + # work out how many period we have left in the financial year + periods_left = len(PERIODS) - period.financial_period_code + # handle a missing forecast object and assume a forecast amount of 0 + forecast_amount = forecast.amount if forecast else 0 + # work out the difference the actual will leave us with + difference = forecast_amount - actual.amount + + if periods_left: + # floor divide the difference by how many periods are left in the financial year + # TODO: How should monetary values be treated with regards to rounding? + difference //= periods_left + + # adjust the remaining forecast periods by the difference + for i in range(periods_left): + ForecastMonthlyFigure.objects.update_or_create( + financial_code_id=actual.financial_code_id, + financial_year_id=actual.financial_year_id, + financial_period_id=period.pk + i + 1, + archived_status=None, + defaults={"amount": F("amount") + difference}, + create_defaults={"amount": difference}, + ) diff --git a/forecast/models.py b/forecast/models.py index 0670532ca..f02711154 100644 --- a/forecast/models.py +++ b/forecast/models.py @@ -287,9 +287,12 @@ def get_max_period(self): class FinancialPeriod(BaseModel): - """Financial periods: correspond - to month, but there are 3 extra - periods at the end""" + """Financial periods: correspond to month, but there are 3 extra periods at the end. + + There are 15 objects in total. + + The objects are managed in migrations and therefore always available in tests. + """ financial_period_code = models.IntegerField(primary_key=True) # April = 1 period_long_name = models.CharField(max_length=20) @@ -1027,8 +1030,9 @@ class MonthlyFigureAbstract(BaseModel): """It contains the forecast and the actuals. The current month defines what is Actual and what is Forecast""" - amount = models.BigIntegerField(default=0) # stored in pence id = models.AutoField(primary_key=True) + + amount = models.BigIntegerField(default=0) # stored in pence financial_year = models.ForeignKey( FinancialYear, on_delete=models.PROTECT, diff --git a/forecast/services.py b/forecast/services.py index a35514671..341a9d545 100644 --- a/forecast/services.py +++ b/forecast/services.py @@ -1,4 +1,4 @@ -from core.constants import MONTHS +from core.constants import PERIODS from core.models import FinancialYear from forecast.models import FinancialCode, FinancialPeriod, ForecastMonthlyFigure @@ -40,7 +40,7 @@ def update_period(self, *, period: int | FinancialPeriod, amount: int): figure.save() def update(self, forecast: list[int]): - assert len(forecast) == len(MONTHS) + assert len(forecast) == len(PERIODS) - for i, _ in enumerate(MONTHS): - self.update_period(period=i + 1, amount=forecast[i]) + for period in PERIODS: + self.update_period(period=period, amount=forecast[period - 1]) diff --git a/forecast/test/test_assets/actualisation_test.xlsx b/forecast/test/test_assets/actualisation_test.xlsx new file mode 100644 index 000000000..e20c88da5 Binary files /dev/null and b/forecast/test/test_assets/actualisation_test.xlsx differ diff --git a/forecast/test/test_import_actuals.py b/forecast/test/test_import_actuals.py index ed0b91bce..5844e9781 100644 --- a/forecast/test/test_import_actuals.py +++ b/forecast/test/test_import_actuals.py @@ -3,6 +3,8 @@ from unittest.mock import MagicMock, patch from zipfile import BadZipFile +import pytest +import waffle.testutils from django.contrib.auth.models import Group, Permission from django.core.files import File from django.db.models import Sum @@ -11,7 +13,9 @@ from chartofaccountDIT.models import NaturalCode, ProgrammeCode from chartofaccountDIT.test.factories import NaturalCodeFactory, ProgrammeCodeFactory +from config import flags from core.models import FinancialYear +from core.test.factories import FinancialYearFactory from core.test.test_base import TEST_COST_CENTRE, BaseTestCase from core.utils.excel_test_helpers import FakeCell, FakeWorkSheet from core.utils.generic_helpers import make_financial_year_current @@ -25,6 +29,7 @@ NAC_NOT_VALID_WITH_GENERIC_PROGRAMME, TITLE_CELL, UploadFileFormatError, + actualisation, check_trial_balance_format, copy_current_year_actuals_to_monthly_figure, save_trial_balance_row, @@ -36,6 +41,7 @@ FinancialPeriod, ForecastMonthlyFigure, ) +from forecast.test.factories import FinancialCodeFactory from forecast.utils.import_helpers import VALID_ECONOMIC_CODE_LIST, CheckFinancialCode from upload_file.models import FileUpload @@ -306,6 +312,7 @@ def test_save_row_invalid_nac(self): True, ) + @waffle.testutils.override_switch(flags.ACTUALISATION, active=True) def test_upload_trial_balance_report(self): # Check that BadZipFile is raised on # supply of incorrect file format @@ -443,25 +450,43 @@ def test_upload_trial_balance_report(self): # Check that existing figures for the same period have been deleted self.assertEqual( ForecastMonthlyFigure.objects.filter( - financial_code__cost_centre=cost_centre_code_1 + financial_code__cost_centre=cost_centre_code_1, + financial_year=self.test_year, + financial_period__period_calendar_code=self.test_period, ).count(), 0, ) # Check for existence of monthly figures self.assertEqual( ForecastMonthlyFigure.objects.filter( - financial_code__cost_centre=self.cost_centre_code + financial_code__cost_centre=self.cost_centre_code, + financial_year=self.test_year, + financial_period__period_calendar_code=self.test_period, ).count(), 4, ) result = ForecastMonthlyFigure.objects.filter( - financial_code__cost_centre=self.cost_centre_code + financial_code__cost_centre=self.cost_centre_code, + financial_year=self.test_year, + financial_period__period_calendar_code=self.test_period, ).aggregate(total=Sum("amount")) # Check that figures have correct values self.assertEqual( result["total"], - 1000000, + 1_000_000, + ) + + result_inc_actualisation = ForecastMonthlyFigure.objects.filter( + financial_code__cost_centre=self.cost_centre_code, + ).aggregate(total=Sum("amount")) + + # Check that actualisation has been applied and that the total forecast remains + # the same. In this case it will be "zero" (or close to depending on rounding) + # because there was a total forecast of 0 before uploading the actual. + self.assertEqual( + result_inc_actualisation["total"], + -8, # due to floor division ) self.assertTrue( @@ -793,3 +818,159 @@ def test_finance_admin_can_upload_actuals(self, mock_process_uploaded_file): file_path = "uploaded/actuals/{}".format(self.file_mock.name) if os.path.exists(file_path): os.remove(file_path) + + +@waffle.testutils.override_switch(flags.ACTUALISATION, active=True) +def test_actualisation_as_part_of_upload(db, test_user): + # This test is based off an example given to me. + + # figures are in pence + figures = [ + # actuals apr - aug + 10_500_00, + 5_000_00, + 2_500_00, + 3_000_00, + 6_000_00, + # forecast sep - mar + 10_000_00, + 10_000_00, + 10_000_00, + 10_000_00, + 10_000_00, + 10_000_00, + 10_000_00, + ] + expected_figures = [ + # actuals apr - sep + 10_500_00, + 5_000_00, + 2_500_00, + 3_000_00, + 6_000_00, + 7_000_00, + # forecast oct - mar + 10_500_00, + 10_500_00, + 10_500_00, + 10_500_00, + 10_500_00, + 10_500_00, + ] + + actual_period = FinancialPeriod.objects.get(pk=6) # sep + # actual_amount = 7_000 # stored in actualisation_test.xslx + + fin_code: FinancialCode = FinancialCodeFactory( + natural_account_code__economic_budget_code=VALID_ECONOMIC_CODE_LIST[0], + ) + fin_year: FinancialYear = FinancialYearFactory() + + make_financial_year_current(fin_year.pk) + + for i, amount in enumerate(figures): + FinancialPeriod.objects.filter(pk=i + 1).update(actual_loaded=True) + ForecastMonthlyFigure.objects.create( + financial_code=fin_code, + financial_year=fin_year, + financial_period_id=i + 1, + amount=amount, + ) + + excel_file = FileUpload( + s3_document_file=os.path.join( + os.path.dirname(__file__), + "test_assets/actualisation_test.xlsx", + ), + uploading_user=test_user, + document_type=FileUpload.ACTUALS, + ) + excel_file.save() + upload_trial_balance_report( + excel_file, + actual_period.period_calendar_code, + fin_year.pk, + ) + + new_figures = ( + ForecastMonthlyFigure.objects.filter( + financial_code=fin_code, + financial_year=fin_year, + archived_status__isnull=True, + ) + .order_by("financial_period") + .values_list("amount", flat=True) + ) + + assert list(new_figures) == expected_figures + + +@pytest.mark.parametrize( + ["figures", "period", "actual", "expected_figures"], + [ + # fmt: off + ( + [10_500, 5_000, 2_500, 3_000, 6_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000], + 6, # september + 7_000, + [10_500, 5_000, 2_500, 3_000, 6_000, 7_000, 10_500, 10_500, 10_500, 10_500, 10_500, 10_500], + ), + ( + [10_500, 5_000, 2_500, 3_000, 6_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000], + 6, # september + 7050, + [10_500, 5_000, 2_500, 3_000, 6_000, 7_000, 10_491.66, 10_491.66, 10_491.66, 10_491.66, 10_491.66, 10_491.66], + ), + ( + [0, 0, 0, 50_000, 0, 0, 0, 0, 0, 50_000, 0, 0], + 6, # september + 12_000, + [0, 0, 0, 50_000, 0, 12_000, -2000, -2000, -2000, 48_000, -2000, -2000], + ), + ( + [10_500, 5_000, 2_500, 3_000, 6_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000], + 12, # march + 7_000, + [10_500, 5_000, 2_500, 3_000, 6_000, 10_000, 10_000, 10_000, 10_000, 10_000, 10_000, 7_000], + ), + # fmt: on\ + ], +) +def test_actualisation( + db, figures: list[float], period: int, actual: float, expected_figures: list[float] +): + period_obj = FinancialPeriod.objects.get(pk=period) + + fin_code: FinancialCode = FinancialCodeFactory( + natural_account_code__economic_budget_code=VALID_ECONOMIC_CODE_LIST[0], + ) + fin_year: FinancialYear = FinancialYearFactory() + + for i, amount in enumerate(figures): + ForecastMonthlyFigure.objects.create( + financial_code=fin_code, + financial_year=fin_year, + financial_period_id=i + 1, + amount=amount * 100, + ) + + actual_obj = ActualUploadMonthlyFigure.objects.create( + financial_code=fin_code, + financial_year=fin_year, + financial_period=period_obj, + amount=actual * 100, + ) + + actualisation(period=period_obj, actual=actual_obj) + + new_figures = ( + ForecastMonthlyFigure.objects.filter( + financial_code=fin_code, + financial_year=fin_year, + archived_status__isnull=True, + ) + .order_by("financial_period") + .values_list("amount", flat=True) + ) + + assert list(new_figures[period:]) == [x * 100 for x in expected_figures][period:] diff --git a/makefile b/makefile index c5b79d072..29e353dc4 100644 --- a/makefile +++ b/makefile @@ -88,6 +88,8 @@ superuser: # Create superuser feature-flags: # Manage feature flags for local development echo 'Add feature flags here' + $(web) $(manage) waffle_switch actualisation on --create + # Formatting black-check: # Run black-check $(run-host) black --check .