EVM complete
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user