EVM complete

This commit is contained in:
2026-03-23 03:31:44 -05:00
parent c88e344198
commit 8cd3aa5f84
32 changed files with 1605 additions and 31 deletions
+158 -5
View File
@@ -4,7 +4,9 @@ from phonenumber_field.modelfields import PhoneNumberField
from django.utils import timezone
from django_enum import EnumField
from django.utils.text import slugify
from django.db.models import Sum
import datetime
from dateutil.relativedelta import relativedelta
def user_str(self):
if self.first_name and self.last_name:
@@ -62,7 +64,124 @@ class Contract(IdMixin, TimeMixin):
def __str__(self):
return f"{self.name} - {self.contract_type}"
# TODO: make calc ev func
@property
def ticket_percent_complete(self):
charge_numbers = self.chargenumber_set.all()
total_hours = sum(cn.budget_hours for cn in charge_numbers)
if total_hours == 0:
return 0.0
weighted_sum = sum(cn.budget_hours * cn.get_percent_complete for cn in charge_numbers)
return weighted_sum / total_hours
def get_evm_data(self):
"""Compute all Earned Value Management metrics for this contract."""
today = timezone.now().date()
# --- Budget at Completion (BAC) ---
bac_hours = self.budget_hours or 0.0
bac_dollars = self.baseline_amount if self.baseline_amount > 0 else (self.funded_amount or 0.0)
# --- Time fraction elapsed ---
if self.baseline_start and self.baseline_end and self.baseline_end > self.baseline_start:
total_days = (self.baseline_end - self.baseline_start).days
elapsed_days = max(0, (min(today, self.baseline_end) - self.baseline_start).days)
time_fraction = min(1.0, elapsed_days / total_days) if total_days > 0 else 0.0
else:
total_days = 0
elapsed_days = 0
time_fraction = 0.0
# --- Planned Value (PV) ---
pv_hours = bac_hours * time_fraction
pv_dollars = bac_dollars * time_fraction
# --- Earned Value (EV) ---
percent_complete = self.ticket_percent_complete # 0-100
ev_hours = bac_hours * (percent_complete / 100.0)
ev_dollars = bac_dollars * (percent_complete / 100.0)
# --- Actual Cost (AC) ---
from financial.models import TimeCardCell # avoid circular at module level
cells = TimeCardCell.objects.filter(
charge_number__contract=self
).select_related('timeCard__employee')
ac_hours = cells.aggregate(total=Sum('hour'))['total'] or 0.0
ac_dollars = sum(
(cell.hour or 0.0) * (cell.timeCard.employee.hourly_salary if cell.timeCard and cell.timeCard.employee else 0.0)
for cell in cells
)
# --- Variances ---
sv_hours = ev_hours - pv_hours
sv_dollars = ev_dollars - pv_dollars
cv_hours = ev_hours - ac_hours
cv_dollars = ev_dollars - ac_dollars
# --- Performance Indices ---
spi = (ev_dollars / pv_dollars) if pv_dollars > 0 else 0.0
cpi = (ev_dollars / ac_dollars) if ac_dollars > 0 else 0.0
spi_hours = (ev_hours / pv_hours) if pv_hours > 0 else 0.0
cpi_hours = (ev_hours / ac_hours) if ac_hours > 0 else 0.0
# --- Monthly Time-Series for S-Curve ---
time_series = []
if self.baseline_start and self.baseline_end and total_days > 0:
# Build cumulative AC by month
ac_by_month = {}
for cell in cells:
if cell.date:
key = cell.date.strftime('%Y-%m')
cost = (cell.hour or 0.0) * (
cell.timeCard.employee.hourly_salary if cell.timeCard and cell.timeCard.employee else 0.0
)
ac_by_month[key] = ac_by_month.get(key, 0.0) + cost
cursor = self.baseline_start.replace(day=1)
end_limit = max(self.baseline_end, today)
cumulative_ac = 0.0
while cursor <= end_limit:
month_key = cursor.strftime('%Y-%m')
# PV at this point in time
days_into = max(0, (cursor - self.baseline_start).days)
pv_at = bac_dollars * min(1.0, days_into / total_days)
# Cumulative AC
cumulative_ac += ac_by_month.get(month_key, 0.0)
# EV: proportional to current % complete, scaled by time
if cursor <= today:
ev_at = ev_dollars * min(1.0, days_into / max(1, (today - self.baseline_start).days)) if (today - self.baseline_start).days > 0 else 0.0
else:
ev_at = ev_dollars # flat after today
time_series.append({
'month': month_key,
'pv': round(pv_at, 2),
'ev': round(ev_at, 2),
'ac': round(cumulative_ac, 2),
})
cursor += relativedelta(months=1)
return {
'bac_hours': round(bac_hours, 2),
'bac_dollars': round(bac_dollars, 2),
'pv_hours': round(pv_hours, 2),
'pv_dollars': round(pv_dollars, 2),
'ev_hours': round(ev_hours, 2),
'ev_dollars': round(ev_dollars, 2),
'ac_hours': round(ac_hours, 2),
'ac_dollars': round(ac_dollars, 2),
'sv_hours': round(sv_hours, 2),
'sv_dollars': round(sv_dollars, 2),
'cv_hours': round(cv_hours, 2),
'cv_dollars': round(cv_dollars, 2),
'spi': round(spi, 2),
'cpi': round(cpi, 2),
'spi_hours': round(spi_hours, 2),
'cpi_hours': round(cpi_hours, 2),
'percent_complete': round(percent_complete, 2),
'time_series': time_series,
}
class ChargeNumber(IdMixin, TimeMixin):
class ChargeNumberTypeEnum(models.TextChoices):
@@ -76,11 +195,45 @@ class ChargeNumber(IdMixin, TimeMixin):
contract = models.ForeignKey(Contract, on_delete=models.CASCADE)
amount = models.FloatField(default=0.0)
budget_hours = models.FloatField(default=0.0)
percent_complete = models.FloatField(default=0.0)
# TODO: add validator to make sure the range is 0.0 - 100
start_date = models.DateField(null=True, blank=True, default = None)
end_date = models.DateField(null=True, blank=True, default = None)
def __str__(self):
return self.slug or f"CN-{self.id}"
@property
def get_percent_complete(self):
if self.charge_number_type == self.ChargeNumberTypeEnum.QBD:
total = self.tickets.count()
if total == 0:
return 0.0
done = self.tickets.filter(status='DONE').count()
return (done / total) * 100.0
return self.percent_complete
def clean(self):
from django.core.exceptions import ValidationError
super().clean()
if self.charge_number_type == "0_100":
if self.percent_complete not in [0.0, 100.0]:
raise ValidationError({"percent_complete": "For ZERO_ONE_HUNDRED, percent complete must be 0 or 100."})
if self.start_date and self.end_date:
delta = self.end_date - self.start_date
if delta.days > 31:
raise ValidationError({"end_date": "Duration cannot be more than a month for ZERO_ONE_HUNDRED."})
if self.charge_number_type == "50_50":
if self.percent_complete not in [0.0, 50.0, 100.0]:
raise ValidationError({"percent_complete": "For FIFTY_FIFTY, percent complete must be 0, 50, or 100."})
if self.start_date and self.end_date:
delta = self.end_date - self.start_date
if delta.days > 62:
raise ValidationError({"end_date": "Duration cannot be more than two months for FIFTY_FIFTY."})
class AddressModel(models.Model):
address_1 = models.CharField(max_length=128)
address_2 = models.CharField(max_length=128, blank=True)
@@ -92,8 +245,8 @@ class AddressModel(models.Model):
class Employee(IdMixin, TimeMixin):
manager = models.ForeignKey("self", on_delete=models.CASCADE, related_name="manager_employee", null=True, blank=True)
user = models.OneToOneField(User, on_delete=models.CASCADE)
primaryAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="primary_address_employee")
workAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="work_address_employee")
primaryAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="primary_address_employee", null=True, blank=True)
workAddress = models.ForeignKey(AddressModel, on_delete=models.CASCADE, related_name="work_address_employee", null=True, blank=True)
phoneNumber = PhoneNumberField(null=True, blank=True, unique=True)
slary= models.FloatField(default=0.0)
@@ -102,7 +255,7 @@ class Employee(IdMixin, TimeMixin):
@property
def hourly_salary(self):
return (self.slary or 0.0) / 2080.0
return (self.slary or 0.0) / 2040.0
# TODO: roles, jpbTitles
@@ -122,7 +275,7 @@ class TimeCardCell(IdMixin, TimeMixin):
start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True)
hour = models.FloatField(default = 0.0)
contract = models.ForeignKey(Contract, on_delete=models.CASCADE, null=True, blank=True)
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)