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