diff --git a/.env.ci b/.env.ci index b868fd46e..f5d3c52cb 100644 --- a/.env.ci +++ b/.env.ci @@ -10,3 +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"} diff --git a/.env.example b/.env.example index d0afcc561..3d8059296 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,8 @@ CSP_REPORT_URI= # Vite VITE_DEV=True +PAYROLL={"BASIC_PAY_NAC": "71111001", "PENSION_NAC": "71111002", "ERNIC_NAC": "71111003", "VACANCY_NAC": "71111001"} + # Not documented (needed?) # RESTRICT_ADMIN=True # PUBLIC_PATH="http://localhost:8000" diff --git a/config/settings/base.py b/config/settings/base.py index 9ee479b67..2d3c4c5c5 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -11,6 +11,7 @@ """ import os +from dataclasses import dataclass from pathlib import Path import dj_database_url @@ -415,3 +416,16 @@ def FILTERS_VERBOSE_LOOKUPS(): CSP_REPORT_ONLY = True CSP_REPORT_URI = env.str("CSP_REPORT_URI", default=None) + + +# Payroll +@dataclass +class Payroll: + BASIC_PAY_NAC: str | None = None + PENSION_NAC: str | None = None + ERNIC_NAC: str | None = None + VACANCY_NAC: str | None = None + AVERAGE_SALARY_THRESHOLD: int = 2 + + +PAYROLL: Payroll = Payroll(**env.json("PAYROLL", default={})) diff --git a/core/constants.py b/core/constants.py new file mode 100644 index 000000000..b580d3824 --- /dev/null +++ b/core/constants.py @@ -0,0 +1,17 @@ +from .types import Months + + +MONTHS: Months = ( + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + "jan", + "feb", + "mar", +) diff --git a/core/types.py b/core/types.py new file mode 100644 index 000000000..3e7427ef2 --- /dev/null +++ b/core/types.py @@ -0,0 +1,46 @@ +from typing import Literal, TypedDict + + +Month = Literal[ + "apr", + "may", + "jun", + "jul", + "aug", + "sep", + "oct", + "nov", + "dec", + "jan", + "feb", + "mar", +] +Months = tuple[ + Literal["apr"], + Literal["may"], + Literal["jun"], + Literal["jul"], + Literal["aug"], + Literal["sep"], + Literal["oct"], + Literal["nov"], + Literal["dec"], + Literal["jan"], + Literal["feb"], + Literal["mar"], +] + + +class MonthsDict[T](TypedDict): + apr: T + may: T + jun: T + jul: T + aug: T + sep: T + oct: T + nov: T + dec: T + jan: T + feb: T + mar: T diff --git a/core/urls.py b/core/urls.py index 50babc924..8c5a6febf 100644 --- a/core/urls.py +++ b/core/urls.py @@ -7,5 +7,5 @@ path("", views.index, name="index"), path("logout", views.logout, name="logout"), path("accessibility", views.AccessibilityPageView.as_view(), name="accessibility"), - path("report/budget-report", views.budget_report, name="report:budget_report"), + path("report/budget-report", views.budget_report, name="budget-report"), ] diff --git a/forecast/templatetags/forecast_format.py b/forecast/templatetags/forecast_format.py index 42a84a0fc..8b01103b8 100644 --- a/forecast/templatetags/forecast_format.py +++ b/forecast/templatetags/forecast_format.py @@ -37,6 +37,19 @@ ] +@register.filter +def format_money(value: float) -> str: + """Format as a monetary value. + + `value` is expected to be in pence and will be divided by 100. + + Examples: + >>> format_money(1024312) + '10,243.12' + """ + return f"{value / 100:,.2f}" + + @register.filter() def is_forecast_figure(_, column): if str(column) in forecast_figure_cols: diff --git a/front_end/src/Util.js b/front_end/src/Util.js index 7aeb4deda..5f20d070f 100644 --- a/front_end/src/Util.js +++ b/front_end/src/Util.js @@ -168,10 +168,8 @@ export const processForecastData = (forecastData, payrollData = null, isPayrollE let overrideAmount = null if (isPayrollEnabled && mappedPayrollData[forecastKey]) { - const period = `period_${(parseInt(key)+1)}_sum` - // TODO (FFT-99): Decide on decimal vs pence - // Old code stores monetary values in pence whereas new code has used decimals. - overrideAmount = mappedPayrollData[forecastKey][period] * 100 + const period = months[parseInt(key)] + overrideAmount = mappedPayrollData[forecastKey][period] } cells[monthlyFigure.month] = { @@ -197,8 +195,8 @@ const processPayrollData = (payrollData) => { const results = {}; for (const [key, value] of Object.entries(payrollData)) { - const generatedKey = makeFinancialCodeKey(value.programme_code, value.pay_element__type__group__natural_code) - + const generatedKey = makeFinancialCodeKey(value.programme_code, value.natural_account_code) + results[generatedKey] = value; } diff --git a/payroll/fixtures/test_payroll_data.json b/payroll/fixtures/test_payroll_data.json index 3c75997cb..ad5af4fb5 100644 --- a/payroll/fixtures/test_payroll_data.json +++ b/payroll/fixtures/test_payroll_data.json @@ -9,7 +9,10 @@ "first_name": "John", "last_name": "Smith", "grade": "Grade 7", - "assignment_status": "Active Assignment" + "assignment_status": "Active Assignment", + "basic_pay": 230000, + "pension": 40000, + "ernic": 9000 } }, { @@ -22,7 +25,10 @@ "first_name": "Jane", "last_name": "Doe", "grade": "Grade 7", - "assignment_status": "Active Contingent Assignment" + "assignment_status": "Active Contingent Assignment", + "basic_pay": 268000, + "pension": 60000, + "ernic": 50000 } }, { @@ -35,7 +41,10 @@ "first_name": "John", "last_name": "Doe", "grade": "Grade 7", - "assignment_status": "Loan Out - Non Payroll" + "assignment_status": "Loan Out - Non Payroll", + "basic_pay": 0, + "pension": 0, + "ernic": 0 } }, { @@ -48,7 +57,10 @@ "first_name": "Jane", "last_name": "Smith", "grade": "Grade 7", - "assignment_status": "Active Assignment" + "assignment_status": "Active Assignment", + "basic_pay": 0, + "pension": 0, + "ernic": 0 } }, { diff --git a/payroll/migrations/0015_employee_basic_pay_employee_ernic_employee_pension.py b/payroll/migrations/0015_employee_basic_pay_employee_ernic_employee_pension.py new file mode 100644 index 000000000..59f056722 --- /dev/null +++ b/payroll/migrations/0015_employee_basic_pay_employee_ernic_employee_pension.py @@ -0,0 +1,28 @@ +# Generated by Django 4.2.16 on 2024-11-27 14:59 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("payroll", "0014_alter_vacancypayperiods_vacancy"), + ] + + operations = [ + migrations.AddField( + model_name="employee", + name="basic_pay", + field=models.BigIntegerField(db_comment="pence", default=0), + ), + migrations.AddField( + model_name="employee", + name="ernic", + field=models.BigIntegerField(db_comment="pence", default=0), + ), + migrations.AddField( + model_name="employee", + name="pension", + field=models.BigIntegerField(db_comment="pence", default=0), + ), + ] diff --git a/payroll/models.py b/payroll/models.py index 0bba5a49f..237012aa1 100644 --- a/payroll/models.py +++ b/payroll/models.py @@ -17,6 +17,9 @@ def with_basic_pay(self): ) ) + def payroll(self): + return self.filter(basic_pay__gt=0) + class Position(models.Model): class Meta: @@ -87,6 +90,9 @@ class Employee(Position): first_name = models.CharField(max_length=32) last_name = models.CharField(max_length=32) assignment_status = models.CharField(max_length=32) + basic_pay = models.BigIntegerField(default=0, db_comment="pence") + pension = models.BigIntegerField(default=0, db_comment="pence") + ernic = models.BigIntegerField(default=0, db_comment="pence") # TODO: Missing fields from Admin Tool which aren't required yet. # EU/Non-EU (from programme code model) @@ -148,44 +154,46 @@ class EmployeePayElement(models.Model): credit_amount = models.DecimalField(max_digits=9, decimal_places=2) -class RecruitmentType(models.TextChoices): - EXPRESSION_OF_INTEREST = "expression_of_interest", "Expression of Interest" - EXTERNAL_RECRUITMENT_NON_BULK = ( - "external_recruitment_non_bulk", - "External Recruitment (Non Bulk)", - ) - EXTERNAL_RECRUITMENT_BULK = ( - "external_recruitment_bulk", - "External Recruitment (Bulk campaign)", - ) - INTERNAL_MANAGED_MOVE = "internal_managed_move", "Internal Managed Move" - INTERNAL_REDEPLOYMENT = "internal_redeployment", "Internal Redeployment" - OTHER = "other", "Other" - INACTIVE_POST = "inactive_post", "Inactive Post" - EXPECTED_UNKNOWN_LEAVERS = "expected_unknown_leavers", "Expected Unknown Leavers" - MISSING_STAFF = "missing_staff", "Missing Staff" - - -class RecruitmentStage(models.IntegerChoices): - PREPARING = 1, "Preparing" - ADVERT = 2, "Advert (Vac ref to be provided)" - SIFT = 3, "Sift" - INTERVIEW = 4, "Interview" - ONBOARDING = 5, "Onboarding" - UNSUCCESSFUL_RECRUITMENT = 6, "Unsuccessful recruitment" - NOT_YET_ADVERTISED = 7, "Not (yet) advertised" - NOT_REQUIRED = 8, "Not required" - - class Vacancy(Position): class Meta: verbose_name_plural = "Vacancies" + class RecruitmentType(models.TextChoices): + EXPRESSION_OF_INTEREST = "expression_of_interest", "Expression of Interest" + EXTERNAL_RECRUITMENT_NON_BULK = ( + "external_recruitment_non_bulk", + "External Recruitment (Non Bulk)", + ) + EXTERNAL_RECRUITMENT_BULK = ( + "external_recruitment_bulk", + "External Recruitment (Bulk campaign)", + ) + INTERNAL_MANAGED_MOVE = "internal_managed_move", "Internal Managed Move" + INTERNAL_REDEPLOYMENT = "internal_redeployment", "Internal Redeployment" + OTHER = "other", "Other" + INACTIVE_POST = "inactive_post", "Inactive Post" + EXPECTED_UNKNOWN_LEAVERS = ( + "expected_unknown_leavers", + "Expected Unknown Leavers", + ) + MISSING_STAFF = "missing_staff", "Missing Staff" + recruitment_type = models.CharField( max_length=29, choices=RecruitmentType.choices, default=RecruitmentType.EXPRESSION_OF_INTEREST, ) + + class RecruitmentStage(models.IntegerChoices): + PREPARING = 1, "Preparing" + ADVERT = 2, "Advert (Vac ref to be provided)" + SIFT = 3, "Sift" + INTERVIEW = 4, "Interview" + ONBOARDING = 5, "Onboarding" + UNSUCCESSFUL_RECRUITMENT = 6, "Unsuccessful recruitment" + NOT_YET_ADVERTISED = 7, "Not (yet) advertised" + NOT_REQUIRED = 8, "Not required" + recruitment_stage = models.IntegerField( choices=RecruitmentStage.choices, default=RecruitmentStage.PREPARING ) diff --git a/payroll/services/payroll.py b/payroll/services/payroll.py index 8592da33d..b0418f15d 100644 --- a/payroll/services/payroll.py +++ b/payroll/services/payroll.py @@ -1,11 +1,18 @@ -from decimal import Decimal +from collections import defaultdict +from statistics import mean from typing import Iterator, TypedDict +import numpy as np +import numpy.typing as npt +from django.conf import settings from django.db import transaction -from django.db.models import F, Q, Sum +from django.db.models import Avg, Count, Q +from core.constants import MONTHS from core.models import FinancialYear +from core.types import MonthsDict from costcentre.models import CostCentre +from gifthospitality.models import Grade from ..models import Employee, EmployeePayPeriods, Vacancy, VacancyPayPeriods @@ -51,36 +58,89 @@ def create_pay_periods(instance, pay_period_enabled=None) -> None: ) -def payroll_forecast_report(cost_centre: CostCentre, financial_year: FinancialYear): - period_sum_annotations = { - f"period_{i+1}_sum": Sum( - F("pay_element__debit_amount") - F("pay_element__credit_amount"), - filter=Q(**{f"pay_periods__period_{i+1}": True}), - default=Decimal(0), - ) - for i in range(12) - } +class PayrollForecast(MonthsDict[float]): + programme_code: str + natural_account_code: str - qs = ( - Employee.objects.filter( - cost_centre=cost_centre, - pay_periods__year=financial_year, - pay_element__isnull=False, - ) - .order_by( - "programme_code", - "pay_element__type__group", - ) - .values( - "programme_code", - "pay_element__type__group__natural_code", - "pay_element__type__group", - "pay_element__type__group__name", - ) - .annotate(**period_sum_annotations) + +def payroll_forecast_report( + cost_centre: CostCentre, financial_year: FinancialYear +) -> Iterator[PayrollForecast]: + # { programme_code: { natural_account_code: np.array[ np.float64 ] } } + report: dict[str, dict[str, npt.NDArray[np.float64]]] = defaultdict( + lambda: defaultdict(lambda: np.zeros(12)) + ) + + employee_qs = Employee.objects.filter( + cost_centre=cost_centre, + pay_periods__year=financial_year, ) + for employee in employee_qs.iterator(): + periods = employee.pay_periods.first().periods + periods = np.array(periods) + + prog_report = report[employee.programme_code_id] + prog_report[settings.PAYROLL.BASIC_PAY_NAC] += periods * employee.basic_pay + prog_report[settings.PAYROLL.PENSION_NAC] += periods * employee.pension + prog_report[settings.PAYROLL.ERNIC_NAC] += periods * employee.ernic + + vacancy_qs = Vacancy.objects.filter( + cost_centre=cost_centre, + pay_periods__year=financial_year, + ) + for vacancy in vacancy_qs.iterator(): + avg_salary = get_average_salary_for_grade(vacancy.grade, cost_centre) + salary = vacancy.fte * avg_salary + + periods = vacancy.pay_periods.first().periods + periods = np.array(periods) + + prog_report = report[vacancy.programme_code_id] + prog_report[settings.PAYROLL.VACANCY_NAC] += periods * salary - return qs + for programme_code in report: + for nac, forecast in report[programme_code].items(): + forecast_months: MonthsDict[float] = dict(zip(MONTHS, forecast, strict=False)) # type: ignore + + yield PayrollForecast( + programme_code=programme_code, + natural_account_code=nac, + **forecast_months, + ) + + +# TODO (FFT-131): Apply caching to the average salary calculation +def get_average_salary_for_grade(grade: Grade, cost_centre: CostCentre) -> int: + employee_count_threshold = settings.PAYROLL.AVERAGE_SALARY_THRESHOLD + + # Expanding scope filters which start at the cost centre and end at all employees. + filters = [ + Q(cost_centre=cost_centre), + Q(cost_centre__directorate=cost_centre.directorate), + Q(cost_centre__directorate__group=cost_centre.directorate.group), + Q(), + ] + + salaries: list[int] = [] + + for filter in filters: + employee_qs = Employee.objects.payroll().filter(grade=grade).filter(filter) + + basic_pay = employee_qs.aggregate( + count=Count("basic_pay"), avg=Avg("basic_pay") + ) + + if basic_pay["count"] >= employee_count_threshold: + return basic_pay["avg"] + + if basic_pay["count"]: + salaries += list(employee_qs.values_list("basic_pay", flat=True)) + + if salaries: + return round(mean(salaries)) # pence + + # TODO: What do we do if there were no employees at all found at that grade? + return 0 class EmployeePayroll(TypedDict): @@ -111,7 +171,6 @@ def get_payroll_data( cost_centre=cost_centre, pay_periods__year=financial_year, ) - .with_basic_pay() ) for obj in qs: yield EmployeePayroll( @@ -168,6 +227,7 @@ class Vacancies(TypedDict): id: int grade: str programme_code: str + budget_type: str recruitment_type: str recruitment_stage: str appointee_name: str diff --git a/payroll/templates/payroll/page/edit_payroll.html b/payroll/templates/payroll/page/edit_payroll.html index fcb92437d..fb4bf739c 100644 --- a/payroll/templates/payroll/page/edit_payroll.html +++ b/payroll/templates/payroll/page/edit_payroll.html @@ -1,5 +1,5 @@ {% extends "base_generic.html" %} -{% load breadcrumbs vite %} +{% load breadcrumbs vite forecast_format %} {% block title %}Edit payroll{% endblock title %} @@ -15,40 +15,39 @@

Edit payroll

Payroll forecast

+
- - - - - - - {% for month in months %} - - {% endfor %} - - - - {% for row in payroll_forecast_report %} - - - - - - - - - - - - - - - - - - {% endfor %} - -
Programme codeNatural codePay type{{ month }}
{{ row.programme_code }}{{ row.pay_element__type__group__natural_code }}{{ row.pay_element__type__group__name }}{{ row.period_1_sum }}{{ row.period_2_sum }}{{ row.period_3_sum }}{{ row.period_4_sum }}{{ row.period_5_sum }}{{ row.period_6_sum }}{{ row.period_7_sum }}{{ row.period_8_sum }}{{ row.period_9_sum }}{{ row.period_10_sum }}{{ row.period_11_sum }}{{ row.period_12_sum }}
+ + + + + + {% for month in months %} + + {% endfor %} + + + + {% for row in payroll_forecast_report %} + + + + + + + + + + + + + + + + + {% endfor %} + +
Programme codeNatural account code{{ month }}
{{ row.programme_code }}{{ row.natural_account_code }}£{{ row.apr|format_money }}£{{ row.may|format_money }}£{{ row.jun|format_money }}£{{ row.jul|format_money }}£{{ row.aug|format_money }}£{{ row.sep|format_money }}£{{ row.oct|format_money }}£{{ row.nov|format_money }}£{{ row.dec|format_money }}£{{ row.jan|format_money }}£{{ row.feb|format_money }}£{{ row.mar|format_money }}
{% endblock content %} diff --git a/payroll/tests/factories.py b/payroll/tests/factories.py index ed2f5534a..0cba8b153 100644 --- a/payroll/tests/factories.py +++ b/payroll/tests/factories.py @@ -1,10 +1,11 @@ import factory +import factory.fuzzy from faker import Faker from chartofaccountDIT.test.factories import NaturalCodeFactory, ProgrammeCodeFactory from costcentre.test.factories import CostCentreFactory from gifthospitality.test.factories import GradeFactory -from payroll.models import Employee, PayElementType, PayElementTypeGroup +from payroll.models import Employee, PayElementType, PayElementTypeGroup, Vacancy fake = Faker() @@ -42,3 +43,23 @@ class Meta: # name natural_code = factory.SubFactory(NaturalCodeFactory) group = factory.SubFactory(PayElementTypeGroupFactory) + + +class VacancyFactory(factory.django.DjangoModelFactory): + class Meta: + model = Vacancy + + cost_centre = factory.SubFactory(CostCentreFactory) + programme_code = factory.SubFactory(ProgrammeCodeFactory) + grade = factory.SubFactory(GradeFactory) + fte = 1.0 + + recruitment_type = factory.fuzzy.FuzzyChoice( + Vacancy.RecruitmentType.choices, getter=lambda c: c[0] + ) + recruitment_stage = factory.fuzzy.FuzzyChoice( + Vacancy.RecruitmentStage.choices, getter=lambda c: c[0] + ) + appointee_name = factory.Faker("name") + hiring_manager = factory.Faker("name") + hr_ref = factory.Faker("name") diff --git a/payroll/tests/services/test_payroll.py b/payroll/tests/services/test_payroll.py index 81778dc5e..589c43afb 100644 --- a/payroll/tests/services/test_payroll.py +++ b/payroll/tests/services/test_payroll.py @@ -1,107 +1,138 @@ +from statistics import mean + import pytest from core.models import FinancialYear -from payroll.services.payroll import employee_created, payroll_forecast_report +from costcentre.test.factories import CostCentreFactory +from payroll.services.payroll import ( + employee_created, + payroll_forecast_report, + vacancy_created, +) -from ..factories import EmployeeFactory, PayElementTypeFactory +from ..factories import EmployeeFactory, VacancyFactory def test_payroll_forecast(db): - SALARY_NAC = "77770001" - PENSION_NAC = "77770002" - - salary_1 = PayElementTypeFactory.create( - name="Salary 1", - group__name="Salary", - group__natural_code__natural_account_code=SALARY_NAC, - ) - salary_2 = PayElementTypeFactory.create( - name="Salary 2", - group__name="Salary", - group__natural_code__natural_account_code=SALARY_NAC, - ) - pension_1 = PayElementTypeFactory.create( - name="Pension 1", - group__name="Pension", - group__natural_code__natural_account_code=PENSION_NAC, + # NOTE: These must match the PAYROLL.BASIC_PAY_NAC and PAYROLL.PENSION_NAC settings. + SALARY_NAC = "71111001" + PENSION_NAC = "71111002" + + cost_centre = CostCentreFactory.create(cost_centre_code="123456") + + # salary_1 = PayElementTypeFactory.create( + # name="Salary 1", + # group__name="Salary", + # group__natural_code__natural_account_code=SALARY_NAC, + # ) + # salary_2 = PayElementTypeFactory.create( + # name="Salary 2", + # group__name="Salary", + # group__natural_code__natural_account_code=SALARY_NAC, + # ) + # pension_1 = PayElementTypeFactory.create( + # name="Pension 1", + # group__name="Pension", + # group__natural_code__natural_account_code=PENSION_NAC, + # ) + + payroll_employee_1 = EmployeeFactory.create( + cost_centre=cost_centre, + programme_code__programme_code="123456", + grade__grade="Grade 7", + basic_pay=195000, + pension=7550, + ernic=0, ) - - payroll_employees = EmployeeFactory.create_batch( - size=2, - cost_centre__cost_centre_code="123456", + payroll_employee_2 = EmployeeFactory.create( + cost_centre=cost_centre, programme_code__programme_code="123456", grade__grade="Grade 7", + basic_pay=152440, + pension=11525, + ernic=0, ) # non-payroll employees _ = EmployeeFactory.create_batch( size=2, - cost_centre__cost_centre_code="123456", + cost_centre=cost_centre, programme_code__programme_code="123456", grade__grade="Grade 7", ) # TODO: Consider an ergonomic way of avoiding this pattern all the time. - for x in payroll_employees: - employee_created(x) - - payroll_employees[0].pay_element.create( - type=salary_1, - debit_amount=2000, - credit_amount=100, - ) - payroll_employees[0].pay_element.create( - type=salary_2, - debit_amount=100, - credit_amount=50, - ) - payroll_employees[0].pay_element.create( - type=pension_1, - debit_amount=75.5, - credit_amount=0, + employee_created(payroll_employee_1) + employee_created(payroll_employee_2) + + # payroll_employees[0].pay_element.create( + # type=salary_1, + # debit_amount=2000, + # credit_amount=100, + # ) + # payroll_employees[0].pay_element.create( + # type=salary_2, + # debit_amount=100, + # credit_amount=50, + # ) + # payroll_employees[0].pay_element.create( + # type=pension_1, + # debit_amount=75.5, + # credit_amount=0, + # ) + + # payroll_employees[1].pay_element.create( + # type=salary_1, + # debit_amount=1500, + # credit_amount=55.6, + # ) + # payroll_employees[1].pay_element.create( + # type=salary_2, + # debit_amount=80, + # credit_amount=0, + # ) + # payroll_employees[1].pay_element.create( + # type=pension_1, + # debit_amount=130.25, + # credit_amount=15, + # ) + + vacancy = VacancyFactory.create( + cost_centre=cost_centre, + programme_code__programme_code="123456", + grade__grade="Grade 7", + fte=0.5, ) - payroll_employees[1].pay_element.create( - type=salary_1, - debit_amount=1500, - credit_amount=55.6, - ) - payroll_employees[1].pay_element.create( - type=salary_2, - debit_amount=80, - credit_amount=0, - ) - payroll_employees[1].pay_element.create( - type=pension_1, - debit_amount=130.25, - credit_amount=15, - ) + vacancy_created(vacancy) financial_year = FinancialYear.objects.current() # In April, both employees are paid. # In May, only the first employee is paid. - # In June, neither employee is paid. - payroll_employees[0].pay_periods.filter(year=financial_year).update(period_3=False) - payroll_employees[1].pay_periods.filter(year=financial_year).update( + # In June, neither employee is paid, but the vacancy is filled. + payroll_employee_1.pay_periods.filter(year=financial_year).update(period_3=False) + payroll_employee_2.pay_periods.filter(year=financial_year).update( period_2=False, period_3=False ) + vacancy.pay_periods.filter(year=financial_year).update(period_3=True) - report = payroll_forecast_report(payroll_employees[0].cost_centre, financial_year) + report = payroll_forecast_report(cost_centre, financial_year) - report_by_name = {x["pay_element__type__group__name"]: x for x in report} + report_by_nac = {x["natural_account_code"]: x for x in report} # eN = employee (e.g. employee 1) / s = salary / p = pension # debit_amount - credit_amount - e1s = (2000 - 100) + (100 - 50) - e1p = 75.5 - 0 - e2s = (1500 - 55.6) + (80 - 0) - e2p = 130.25 - 15 + e1s = ((2000 - 100) + (100 - 50)) * 100 + e1p = (75.5 - 0) * 100 + e2s = ((1500 - 55.6) + (80 - 0)) * 100 + e2p = (130.25 - 15) * 100 + v1s = mean([e1s, e2s]) * 0.5 # employee 3 and 4 are non-payroll (no basic pay) - assert float(report_by_name["Salary"]["period_1_sum"]) == pytest.approx(e1s + e2s) - assert float(report_by_name["Pension"]["period_1_sum"]) == pytest.approx(e1p + e2p) - assert float(report_by_name["Salary"]["period_2_sum"]) == pytest.approx(e1s) - assert float(report_by_name["Pension"]["period_2_sum"]) == pytest.approx(e1p) - assert float(report_by_name["Salary"]["period_3_sum"]) == pytest.approx(0) - assert float(report_by_name["Pension"]["period_3_sum"]) == pytest.approx(0) + assert float(report_by_nac[SALARY_NAC]["apr"]) == pytest.approx(e1s + e2s) + assert float(report_by_nac[PENSION_NAC]["apr"]) == pytest.approx(e1p + e2p) + assert float(report_by_nac[SALARY_NAC]["may"]) == pytest.approx(e1s) + assert float(report_by_nac[PENSION_NAC]["may"]) == pytest.approx(e1p) + assert float(report_by_nac[SALARY_NAC]["jun"]) == pytest.approx(v1s) + assert float(report_by_nac[PENSION_NAC]["jun"]) == pytest.approx(0) diff --git a/poetry.lock b/poetry.lock index ce9811eb6..80cf3bff7 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.4 and should not be changed by hand. [[package]] name = "amqp" @@ -1660,6 +1660,70 @@ files = [ {file = "nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f"}, ] +[[package]] +name = "numpy" +version = "2.1.3" +description = "Fundamental package for array computing in Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "numpy-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c894b4305373b9c5576d7a12b473702afdf48ce5369c074ba304cc5ad8730dff"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b47fbb433d3260adcd51eb54f92a2ffbc90a4595f8970ee00e064c644ac788f5"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:825656d0743699c529c5943554d223c021ff0494ff1442152ce887ef4f7561a1"}, + {file = "numpy-2.1.3-cp310-cp310-macosx_14_0_x86_64.whl", hash = "sha256:6a4825252fcc430a182ac4dee5a505053d262c807f8a924603d411f6718b88fd"}, + {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e711e02f49e176a01d0349d82cb5f05ba4db7d5e7e0defd026328e5cfb3226d3"}, + {file = "numpy-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78574ac2d1a4a02421f25da9559850d59457bac82f2b8d7a44fe83a64f770098"}, + {file = "numpy-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c7662f0e3673fe4e832fe07b65c50342ea27d989f92c80355658c7f888fcc83c"}, + {file = "numpy-2.1.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:fa2d1337dc61c8dc417fbccf20f6d1e139896a30721b7f1e832b2bb6ef4eb6c4"}, + {file = "numpy-2.1.3-cp310-cp310-win32.whl", hash = "sha256:72dcc4a35a8515d83e76b58fdf8113a5c969ccd505c8a946759b24e3182d1f23"}, + {file = "numpy-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:ecc76a9ba2911d8d37ac01de72834d8849e55473457558e12995f4cd53e778e0"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4d1167c53b93f1f5d8a139a742b3c6f4d429b54e74e6b57d0eff40045187b15d"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c80e4a09b3d95b4e1cac08643f1152fa71a0a821a2d4277334c88d54b2219a41"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:576a1c1d25e9e02ed7fa5477f30a127fe56debd53b8d2c89d5578f9857d03ca9"}, + {file = "numpy-2.1.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:973faafebaae4c0aaa1a1ca1ce02434554d67e628b8d805e61f874b84e136b09"}, + {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:762479be47a4863e261a840e8e01608d124ee1361e48b96916f38b119cfda04a"}, + {file = "numpy-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc6f24b3d1ecc1eebfbf5d6051faa49af40b03be1aaa781ebdadcbc090b4539b"}, + {file = "numpy-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:17ee83a1f4fef3c94d16dc1802b998668b5419362c8a4f4e8a491de1b41cc3ee"}, + {file = "numpy-2.1.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:15cb89f39fa6d0bdfb600ea24b250e5f1a3df23f901f51c8debaa6a5d122b2f0"}, + {file = "numpy-2.1.3-cp311-cp311-win32.whl", hash = "sha256:d9beb777a78c331580705326d2367488d5bc473b49a9bc3036c154832520aca9"}, + {file = "numpy-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:d89dd2b6da69c4fff5e39c28a382199ddedc3a5be5390115608345dec660b9e2"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f55ba01150f52b1027829b50d70ef1dafd9821ea82905b63936668403c3b471e"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:13138eadd4f4da03074851a698ffa7e405f41a0845a6b1ad135b81596e4e9958"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:a6b46587b14b888e95e4a24d7b13ae91fa22386c199ee7b418f449032b2fa3b8"}, + {file = "numpy-2.1.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:0fa14563cc46422e99daef53d725d0c326e99e468a9320a240affffe87852564"}, + {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8637dcd2caa676e475503d1f8fdb327bc495554e10838019651b76d17b98e512"}, + {file = "numpy-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2312b2aa89e1f43ecea6da6ea9a810d06aae08321609d8dc0d0eda6d946a541b"}, + {file = "numpy-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a38c19106902bb19351b83802531fea19dee18e5b37b36454f27f11ff956f7fc"}, + {file = "numpy-2.1.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02135ade8b8a84011cbb67dc44e07c58f28575cf9ecf8ab304e51c05528c19f0"}, + {file = "numpy-2.1.3-cp312-cp312-win32.whl", hash = "sha256:e6988e90fcf617da2b5c78902fe8e668361b43b4fe26dbf2d7b0f8034d4cafb9"}, + {file = "numpy-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:0d30c543f02e84e92c4b1f415b7c6b5326cbe45ee7882b6b77db7195fb971e3a"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96fe52fcdb9345b7cd82ecd34547fca4321f7656d500eca497eb7ea5a926692f"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f653490b33e9c3a4c1c01d41bc2aef08f9475af51146e4a7710c450cf9761598"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:dc258a761a16daa791081d026f0ed4399b582712e6fc887a95af09df10c5ca57"}, + {file = "numpy-2.1.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:016d0f6f5e77b0f0d45d77387ffa4bb89816b57c835580c3ce8e099ef830befe"}, + {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c181ba05ce8299c7aa3125c27b9c2167bca4a4445b7ce73d5febc411ca692e43"}, + {file = "numpy-2.1.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5641516794ca9e5f8a4d17bb45446998c6554704d888f86df9b200e66bdcce56"}, + {file = "numpy-2.1.3-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ea4dedd6e394a9c180b33c2c872b92f7ce0f8e7ad93e9585312b0c5a04777a4a"}, + {file = "numpy-2.1.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0df3635b9c8ef48bd3be5f862cf71b0a4716fa0e702155c45067c6b711ddcef"}, + {file = "numpy-2.1.3-cp313-cp313-win32.whl", hash = "sha256:50ca6aba6e163363f132b5c101ba078b8cbd3fa92c7865fd7d4d62d9779ac29f"}, + {file = "numpy-2.1.3-cp313-cp313-win_amd64.whl", hash = "sha256:747641635d3d44bcb380d950679462fae44f54b131be347d5ec2bce47d3df9ed"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:996bb9399059c5b82f76b53ff8bb686069c05acc94656bb259b1d63d04a9506f"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:45966d859916ad02b779706bb43b954281db43e185015df6eb3323120188f9e4"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:baed7e8d7481bfe0874b566850cb0b85243e982388b7b23348c6db2ee2b2ae8e"}, + {file = "numpy-2.1.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a9f7f672a3388133335589cfca93ed468509cb7b93ba3105fce780d04a6576a0"}, + {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7aac50327da5d208db2eec22eb11e491e3fe13d22653dce51b0f4109101b408"}, + {file = "numpy-2.1.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4394bc0dbd074b7f9b52024832d16e019decebf86caf909d94f6b3f77a8ee3b6"}, + {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:50d18c4358a0a8a53f12a8ba9d772ab2d460321e6a93d6064fc22443d189853f"}, + {file = "numpy-2.1.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:14e253bd43fc6b37af4921b10f6add6925878a42a0c5fe83daee390bca80bc17"}, + {file = "numpy-2.1.3-cp313-cp313t-win32.whl", hash = "sha256:08788d27a5fd867a663f6fc753fd7c3ad7e92747efc73c53bca2f19f8bc06f48"}, + {file = "numpy-2.1.3-cp313-cp313t-win_amd64.whl", hash = "sha256:2564fbdf2b99b3f815f2107c1bbc93e2de8ee655a69c261363a1172a79a257d4"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:4f2015dfe437dfebbfce7c85c7b53d81ba49e71ba7eadbf1df40c915af75979f"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-macosx_14_0_x86_64.whl", hash = "sha256:3522b0dfe983a575e6a9ab3a4a4dfe156c3e428468ff08ce582b9bb6bd1d71d4"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c006b607a865b07cd981ccb218a04fc86b600411d83d6fc261357f1c0966755d"}, + {file = "numpy-2.1.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e14e26956e6f1696070788252dcdff11b4aca4c3e8bd166e0df1bb8f315a67cb"}, + {file = "numpy-2.1.3.tar.gz", hash = "sha256:aa08e04e08aaf974d4458def539dece0d28146d866a39da5639596f4921fd761"}, +] + [[package]] name = "oauthlib" version = "3.2.2" @@ -2917,4 +2981,4 @@ type = ["pytest-mypy"] [metadata] lock-version = "2.0" python-versions = "^3.12" -content-hash = "8ab5c01c4bfa95c5030e54b9dca2a24e0efc784e4bb5dea7df63d26cc612c626" +content-hash = "19494c699d8c02c3ca4983dc8945a3138313220d3dcdd26124797c23c8dd1c06" diff --git a/pyproject.toml b/pyproject.toml index a56f018bf..16a00a762 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ openpyxl = "^3.0.5" requests = "^2.32.0" redis = "^5.0.3" granian = "^1.6.3" +numpy = "^2.1.3" # Are these packages still needed? django-import-export = "^3.3.07" @@ -72,7 +73,7 @@ pyperclip = "^1.8.0" freezegun = "^1.0.0" isort = "^5.10.1" ruff = "^0.3.4" -mypy = "^1.12.0" +mypy = "^1.13.0" [build-system] requires = ["poetry-core"] @@ -126,3 +127,6 @@ minversion = "7.0" addopts = "--ignore=front_end --ignore=features --ignore=staticfiles --ds=config.settings.ci --reuse-db" python_files = ["test_*.py", "*_test.py", "tests.py"] filterwarnings = ["ignore::DeprecationWarning"] + +[tool.mypy] +enable_incomplete_feature = ["NewGenericSyntax"]