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
+
{% 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"]