Files
company_site/company_site/financial/models.py
T
2026-06-20 06:05:50 -05:00

285 lines
12 KiB
Python
Executable File

from django.db import models
from django.contrib.auth.models import User
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:
return f"{self.first_name} {self.last_name}"
if self.username:
return self.username
if self.email:
return self.email
return str(self.id)
User.__str__ = user_str
# Abstract Model Classes
class TimeMixin(models.Model):
created = models.DateTimeField(default=timezone.now)
last_modified = models.DateTimeField(default=timezone.now)
created_by = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='+')
last_modified_BY = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, related_name='+')
class Meta:
abstract = True
class IdMixin(models.Model):
slug = models.SlugField(blank=True, unique=True, null=True)
class Meta:
abstract = True
def save(self, *args, **kwargs):
if not self.slug:
self.slug = slugify(datetime.datetime.now().time())
super(IdMixin, self).save(*args, **kwargs)
# Concrete Classes
class Contract(IdMixin, TimeMixin):
class ContractTypeEnum(models.TextChoices):
FIRM_FIX_PRICED = "FFP", "FIRM_FIX_PRICED"
COST_PLUS_FIXED_FEE = "CPFF", "COST_PLUS_FIXED_FEE"
MAX_NUM_CONRACT_TYPES = "MAX", "MAX_NUM_CONTRACT_TYPES"
contract_type = EnumField(ContractTypeEnum)
name = models.CharField(max_length=100)
proposed_amount = models.FloatField(default=0.0)
baseline_amount = models.FloatField(default=0.0)
funded_amount = models.FloatField(default=0.0)
award_amount = models.FloatField(default=0.0)
budget_hours = models.FloatField(default=0.0)
baseline_start = models.DateField(null=True, blank=True, default=None)
baseline_end = models.DateField(null=True, blank=True, default=None)
linkedContracts = models.ForeignKey("self", on_delete=models.CASCADE, null=True, blank=True)
def __str__(self):
return f"{self.name} - {self.contract_type}"
@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):
LEVEL_OF_EFFORT = "LOE", "LEVEL_OF_EFFORT"
QBD = "QBD", "QBD"
ZERO_ONE_HUNDRED = "0_100", "ZERO_ONE_HUNDRED"
FIFTY_FIFTY = "50_50", "FIFTY_FIFTY"
MAX_NUM_TASK_TYPES = "MAX", "MAX_NUM_TASK_TYPES"
charge_number_type = EnumField(ChargeNumberTypeEnum)
name = models.CharField(max_length=100, blank=True, null=True)
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.name or 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)
city = models.CharField(max_length=64)
state = models.CharField(max_length=2)
zip_code = models.CharField(max_length=5)
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", 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)
def __str__(self):
return str(self.user)
@property
def hourly_salary(self):
return (self.slary or 0.0) / 2040.0
# TODO: roles, jpbTitles
class TimeCard(IdMixin, TimeMixin):
startDate = models.DateField(null=True, default = None)
endDate = models.DateField(null=True, default = None)
employee = models.ForeignKey(Employee, on_delete=models.CASCADE, related_name="employee")
approvedBy = models.ForeignKey(Employee, on_delete=models.CASCADE, null=True, related_name="approved_by")
approvedDate = models.DateField(null=True, default=None)
approvalComment = models.TextField(max_length=256, default="")
# TODO: do the is approved comment
class TimeCardCell(IdMixin, TimeMixin):
timeCard = models.ForeignKey(TimeCard, on_delete=models.CASCADE)
date = models.DateField(null=True, default = None)
start_time = models.TimeField(null=True, blank=True)
end_time = models.TimeField(null=True, blank=True)
hour = models.FloatField(default = 0.0)
charge_number = models.ForeignKey(ChargeNumber, on_delete=models.CASCADE, null=True, blank=True)