312 lines
11 KiB
Python
312 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from collections import defaultdict
|
|
from dataclasses import dataclass
|
|
from decimal import Decimal
|
|
|
|
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
|
|
from finance.enums import ApprovalStatus, PayrollPeriodStatus, PayrollRunStatus
|
|
from finance.models import EmployeePayRate, PayrollPeriod, PayrollStatement
|
|
from finance.payroll.config import base_rate_code
|
|
|
|
ZERO = Decimal("0")
|
|
EXACT = "exact"
|
|
PRELIMINARY = "preliminary"
|
|
UNPRICED = "unpriced"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PricedLabor:
|
|
hours: Decimal
|
|
cost: Decimal | None
|
|
cost_status: str
|
|
|
|
|
|
def _period_for(day, periods):
|
|
candidates = [
|
|
period for period in periods if period.date_from <= day <= period.date_to
|
|
]
|
|
if not candidates:
|
|
return None
|
|
return min(
|
|
candidates,
|
|
key=lambda period: (
|
|
(period.date_to - period.date_from).days,
|
|
-period.date_from.toordinal(),
|
|
-period.pk,
|
|
),
|
|
)
|
|
|
|
|
|
def labor_cost_status(exact_hours, preliminary_hours):
|
|
if exact_hours and preliminary_hours:
|
|
return "mixed"
|
|
if preliminary_hours:
|
|
return PRELIMINARY
|
|
if exact_hours:
|
|
return EXACT
|
|
return "unavailable"
|
|
|
|
|
|
def _attendance_hours(periods, employee_ids):
|
|
norm_hours = defaultdict(lambda: ZERO)
|
|
actual_hours = defaultdict(lambda: ZERO)
|
|
if not periods:
|
|
return norm_hours, actual_hours
|
|
attendance = AttendanceRecord.objects.filter(
|
|
employee_id__in=employee_ids,
|
|
date__range=(
|
|
min(period.date_from for period in periods),
|
|
max(period.date_to for period in periods),
|
|
),
|
|
analysis_run__status=AttendanceAnalysisRun.STATUS_SUCCESS,
|
|
).only(
|
|
"employee_id",
|
|
"date",
|
|
"work_hours",
|
|
"expected_hours",
|
|
"effective_is_workday",
|
|
)
|
|
for record in attendance.iterator(chunk_size=1000):
|
|
for period in periods:
|
|
if not period.date_from <= record.date <= period.date_to:
|
|
continue
|
|
key = (period.pk, record.employee_id)
|
|
if record.effective_is_workday and record.expected_hours:
|
|
norm_hours[key] += Decimal(str(record.expected_hours))
|
|
if record.work_hours and record.work_hours > 0:
|
|
actual_hours[key] += Decimal(str(record.work_hours))
|
|
return norm_hours, actual_hours
|
|
|
|
|
|
def _payroll_statements(periods, employee_ids, currency):
|
|
run_ids = {period.current_run_id for period in periods if period.current_run_id}
|
|
return {
|
|
(statement.run_id, statement.employee_id): statement
|
|
for statement in PayrollStatement.objects.filter(
|
|
run_id__in=run_ids,
|
|
employee_id__in=employee_ids,
|
|
currency=currency,
|
|
).only("run_id", "employee_id", "gross_total")
|
|
}
|
|
|
|
|
|
def _approved_rates(employee_ids, currency, end):
|
|
rates = defaultdict(list)
|
|
queryset = (
|
|
EmployeePayRate.objects.filter(
|
|
employee_id__in=employee_ids,
|
|
rate_code=base_rate_code(),
|
|
status=ApprovalStatus.APPROVED,
|
|
currency=currency,
|
|
effective_from__lte=end,
|
|
)
|
|
.only("pk", "employee_id", "amount", "effective_from", "revision")
|
|
.order_by("employee_id", "effective_from", "revision", "pk")
|
|
)
|
|
for rate in queryset:
|
|
rates[rate.employee_id].append(rate)
|
|
return rates
|
|
|
|
|
|
def _preliminary_amount(rates, statement, employee_id, day):
|
|
if statement is not None:
|
|
return statement.gross_total
|
|
applicable = [rate for rate in rates[employee_id] if rate.effective_from <= day]
|
|
if not applicable:
|
|
return None
|
|
return max(
|
|
applicable,
|
|
key=lambda item: (item.effective_from, item.revision, item.pk),
|
|
).amount
|
|
|
|
|
|
def price_labor_allocations(allocations, currency: str) -> dict[int, PricedLabor]:
|
|
"""Price confirmed project hours against the employee payroll period.
|
|
|
|
An open period is always preliminary. Its current payroll calculation is
|
|
used when available, otherwise the approved base rate is used. A period
|
|
becomes exact only after closing; the immutable final gross statement then
|
|
includes bonuses, additional earnings and payroll adjustments. Deductions
|
|
and advance-payment mechanics do not reduce project labour cost.
|
|
"""
|
|
|
|
allocations = list(allocations)
|
|
known = [
|
|
allocation for allocation in allocations if allocation.day.hours is not None
|
|
]
|
|
if not known:
|
|
return {}
|
|
|
|
start = min(allocation.day.date for allocation in known)
|
|
end = max(allocation.day.date for allocation in known)
|
|
employee_ids = {allocation.day.employee_id for allocation in known}
|
|
periods = list(
|
|
PayrollPeriod.objects.filter(
|
|
currency=currency,
|
|
date_from__lte=end,
|
|
date_to__gte=start,
|
|
)
|
|
.select_related("current_run")
|
|
.order_by("date_from", "date_to", "pk")
|
|
)
|
|
period_by_allocation = {
|
|
allocation.pk: _period_for(allocation.day.date, periods) for allocation in known
|
|
}
|
|
used_periods = {
|
|
period.pk: period
|
|
for period in period_by_allocation.values()
|
|
if period is not None
|
|
}
|
|
|
|
period_list = list(used_periods.values())
|
|
norm_hours, actual_hours = _attendance_hours(period_list, employee_ids)
|
|
statements = _payroll_statements(period_list, employee_ids, currency)
|
|
rates = _approved_rates(employee_ids, currency, end)
|
|
|
|
result = {}
|
|
for allocation in known:
|
|
employee_id = allocation.day.employee_id
|
|
hours = allocation.day.hours * allocation.percent / Decimal("100")
|
|
period = period_by_allocation[allocation.pk]
|
|
if period is None:
|
|
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
|
|
continue
|
|
|
|
statement = (
|
|
statements.get((period.current_run_id, employee_id))
|
|
if period.current_run_id
|
|
and period.current_run.status
|
|
not in [PayrollRunStatus.RETURNED, PayrollRunStatus.SUPERSEDED]
|
|
else None
|
|
)
|
|
is_exact = (
|
|
period.status == PayrollPeriodStatus.CLOSED
|
|
and period.current_run is not None
|
|
and period.current_run.status == PayrollRunStatus.PUBLISHED
|
|
and statement is not None
|
|
)
|
|
if is_exact:
|
|
denominator = actual_hours[(period.pk, employee_id)]
|
|
if denominator <= 0:
|
|
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
|
|
else:
|
|
result[allocation.pk] = PricedLabor(
|
|
hours,
|
|
hours * statement.gross_total / denominator,
|
|
EXACT,
|
|
)
|
|
continue
|
|
|
|
# A closed period without its final published statement must remain
|
|
# visibly unpriced. Falling back to a rate would present an estimate as
|
|
# a final amount.
|
|
if period.status == PayrollPeriodStatus.CLOSED:
|
|
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
|
|
continue
|
|
|
|
denominator = norm_hours[(period.pk, employee_id)]
|
|
amount = _preliminary_amount(rates, statement, employee_id, allocation.day.date)
|
|
if amount is None or denominator <= 0:
|
|
result[allocation.pk] = PricedLabor(hours, None, UNPRICED)
|
|
else:
|
|
result[allocation.pk] = PricedLabor(
|
|
hours,
|
|
hours * amount / denominator,
|
|
PRELIMINARY,
|
|
)
|
|
return result
|
|
|
|
|
|
def labor_summary(allocations, currency: str) -> dict:
|
|
allocations = list(allocations)
|
|
priced = price_labor_allocations(allocations, currency)
|
|
employees = defaultdict(
|
|
lambda: {
|
|
"hours": ZERO,
|
|
"cost": ZERO,
|
|
"exact_cost": ZERO,
|
|
"preliminary_cost": ZERO,
|
|
"exact_hours": ZERO,
|
|
"preliminary_hours": ZERO,
|
|
"unpriced_hours": ZERO,
|
|
"unknown_days": 0,
|
|
}
|
|
)
|
|
total_hours = ZERO
|
|
total_cost = ZERO
|
|
exact_cost = ZERO
|
|
preliminary_cost = ZERO
|
|
exact_hours = ZERO
|
|
preliminary_hours = ZERO
|
|
unpriced_hours = ZERO
|
|
unknown_days = set()
|
|
|
|
for allocation in allocations:
|
|
employee = allocation.day.employee
|
|
label = employee.get_full_name().strip() or str(employee)
|
|
row = employees[(employee.pk, label)]
|
|
if allocation.day.hours is None:
|
|
row["unknown_days"] += 1
|
|
unknown_days.add(allocation.day_id)
|
|
continue
|
|
item = priced.get(allocation.pk)
|
|
hours = allocation.day.hours * allocation.percent / Decimal("100")
|
|
total_hours += hours
|
|
row["hours"] += hours
|
|
if item is None or item.cost is None:
|
|
unpriced_hours += hours
|
|
row["unpriced_hours"] += hours
|
|
continue
|
|
total_cost += item.cost
|
|
row["cost"] += item.cost
|
|
if item.cost_status == EXACT:
|
|
exact_cost += item.cost
|
|
exact_hours += hours
|
|
row["exact_cost"] += item.cost
|
|
row["exact_hours"] += hours
|
|
else:
|
|
preliminary_cost += item.cost
|
|
preliminary_hours += hours
|
|
row["preliminary_cost"] += item.cost
|
|
row["preliminary_hours"] += hours
|
|
|
|
complete = not unknown_days and unpriced_hours == 0
|
|
return {
|
|
"labor_hours": total_hours,
|
|
"labor_cost": total_cost,
|
|
"exact_labor_cost": exact_cost,
|
|
"preliminary_labor_cost": preliminary_cost,
|
|
"exact_labor_hours": exact_hours,
|
|
"preliminary_labor_hours": preliminary_hours,
|
|
"labor_cost_status": labor_cost_status(exact_hours, preliminary_hours),
|
|
"labor_hour_cost": (
|
|
total_cost / total_hours if complete and total_hours > 0 else None
|
|
),
|
|
"unpriced_labor_hours": unpriced_hours,
|
|
"unknown_hours_days": len(unknown_days),
|
|
"labor_cost_complete": complete,
|
|
"labor": [
|
|
{
|
|
"employee_id": employee_id,
|
|
"employee": label,
|
|
**values,
|
|
"cost_status": labor_cost_status(
|
|
values["exact_hours"], values["preliminary_hours"]
|
|
),
|
|
"cost_complete": not values["unknown_days"]
|
|
and values["unpriced_hours"] == 0,
|
|
"hour_cost": (
|
|
values["cost"] / values["hours"]
|
|
if not values["unknown_days"]
|
|
and values["unpriced_hours"] == 0
|
|
and values["hours"] > 0
|
|
else None
|
|
),
|
|
}
|
|
for (employee_id, label), values in sorted(
|
|
employees.items(), key=lambda item: item[0][1]
|
|
)
|
|
],
|
|
}
|