2026-09-07 19:43:48 +03:00

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]
)
],
}