feat: Milestone 2 - Backend controllers with capacity warnings and cost rollup
- Engagement Order: auto-populate steps on recipe change via validate() - Engagement Order: capacity warning validator on before_save/before_submit - Execution Card: push actual_hours to parent run step on update/submit/cancel - Execution Card: proper time tracking with start/pause/stop workflow - Refined per-step cost rollup from Resource Center rates
This commit is contained in:
parent
6b44131abe
commit
1fbbbb6e7d
2 changed files with 120 additions and 7 deletions
|
|
@ -3,14 +3,28 @@
|
||||||
|
|
||||||
import frappe
|
import frappe
|
||||||
from frappe.model.document import Document
|
from frappe.model.document import Document
|
||||||
|
from frappe import _
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
|
||||||
class EngagementOrder(Document):
|
class EngagementOrder(Document):
|
||||||
def on_change_engagement_recipe(self):
|
def validate(self):
|
||||||
"""Fetch recipe steps into order run steps when recipe is selected."""
|
"""Validate before saving."""
|
||||||
|
self.check_recipe_changed()
|
||||||
|
self.calculate_totals()
|
||||||
|
self.capacity_warning()
|
||||||
|
|
||||||
|
def check_recipe_changed(self):
|
||||||
|
"""Detect if engagement_recipe changed and re-populate steps."""
|
||||||
if not self.engagement_recipe:
|
if not self.engagement_recipe:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check if this is a new doc or recipe changed
|
||||||
|
if self.is_new() or self.has_value_changed("engagement_recipe"):
|
||||||
|
self.load_recipe_steps()
|
||||||
|
|
||||||
|
def load_recipe_steps(self):
|
||||||
|
"""Fetch recipe steps into order run steps."""
|
||||||
recipe = frappe.get_doc("Engagement Recipe", self.engagement_recipe)
|
recipe = frappe.get_doc("Engagement Recipe", self.engagement_recipe)
|
||||||
self.set("run_steps", [])
|
self.set("run_steps", [])
|
||||||
|
|
||||||
|
|
@ -22,10 +36,8 @@ class EngagementOrder(Document):
|
||||||
row.allocated_hours = step.estimated_hours
|
row.allocated_hours = step.estimated_hours
|
||||||
row.status = "Pending"
|
row.status = "Pending"
|
||||||
|
|
||||||
def before_save(self):
|
|
||||||
self.calculate_totals()
|
|
||||||
|
|
||||||
def calculate_totals(self):
|
def calculate_totals(self):
|
||||||
|
"""Recalculate order-level totals from run steps."""
|
||||||
total_allocated = 0.0
|
total_allocated = 0.0
|
||||||
total_logged = 0.0
|
total_logged = 0.0
|
||||||
actual_cost = 0.0
|
actual_cost = 0.0
|
||||||
|
|
@ -54,11 +66,94 @@ class EngagementOrder(Document):
|
||||||
self.actual_cost = actual_cost
|
self.actual_cost = actual_cost
|
||||||
self.projected_billing_value = projected_billing
|
self.projected_billing_value = projected_billing
|
||||||
|
|
||||||
|
def capacity_warning(self):
|
||||||
|
"""Warn if requested hours exceed Resource Center daily capacity."""
|
||||||
|
if not self.get("run_steps") or not self.start_date:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Group steps by resource center and sum hours
|
||||||
|
rc_hours = {}
|
||||||
|
for step in self.run_steps:
|
||||||
|
if not step.resource_center:
|
||||||
|
continue
|
||||||
|
rc = step.resource_center
|
||||||
|
hours = step.get("allocated_hours", 0) or 0
|
||||||
|
rc_hours[rc] = rc_hours.get(rc, 0) + hours
|
||||||
|
|
||||||
|
for rc_name, requested_hours in rc_hours.items():
|
||||||
|
rc_doc = frappe.get_cached_doc("Resource Center", rc_name)
|
||||||
|
daily_capacity = rc_doc.daily_capacity_hours or 0
|
||||||
|
|
||||||
|
if daily_capacity <= 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Find overlapping Engagement Orders for this Resource Center
|
||||||
|
overlapping_hours = self.get_overlapping_hours(rc_name)
|
||||||
|
|
||||||
|
total_daily_load = overlapping_hours + requested_hours
|
||||||
|
|
||||||
|
if total_daily_load > daily_capacity:
|
||||||
|
frappe.msgprint(
|
||||||
|
_(
|
||||||
|
"Warning: Resource Center '{0}' has a daily capacity of {1} hours, "
|
||||||
|
"but the total load including this order would be {2} hours "
|
||||||
|
"({3}% utilization). Consider adjusting dates or hours."
|
||||||
|
).format(
|
||||||
|
rc_name,
|
||||||
|
daily_capacity,
|
||||||
|
total_daily_load,
|
||||||
|
round((total_daily_load / daily_capacity) * 100, 0),
|
||||||
|
),
|
||||||
|
indicator="orange",
|
||||||
|
alert=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_overlapping_hours(self, resource_center):
|
||||||
|
"""Sum allocated hours from other active Engagement Orders
|
||||||
|
that overlap with this order's date range."""
|
||||||
|
if not self.start_date:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
start = self.start_date
|
||||||
|
# If no end date, estimate 1 day
|
||||||
|
end = self.end_date or start
|
||||||
|
|
||||||
|
filters = {
|
||||||
|
"name": ["!=", self.name],
|
||||||
|
"docstatus": 1, # Submitted
|
||||||
|
"start_date": ["<=", end],
|
||||||
|
}
|
||||||
|
# Only add end_date filter if it's set on the order
|
||||||
|
if self.end_date:
|
||||||
|
filters["end_date"] = [">=", start]
|
||||||
|
else:
|
||||||
|
# Orders that started on or before our end date and might still be active
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Also check status
|
||||||
|
statuses = ["Scheduled", "In Progress"]
|
||||||
|
|
||||||
|
orders = frappe.get_all(
|
||||||
|
"Engagement Order",
|
||||||
|
filters=filters,
|
||||||
|
pluck="name",
|
||||||
|
)
|
||||||
|
|
||||||
|
total_hours = 0
|
||||||
|
for order_name in orders:
|
||||||
|
order_doc = frappe.get_cached_doc("Engagement Order", order_name)
|
||||||
|
for step in order_doc.get("run_steps", []):
|
||||||
|
if step.resource_center == resource_center:
|
||||||
|
total_hours += step.get("allocated_hours", 0) or 0
|
||||||
|
|
||||||
|
return total_hours
|
||||||
|
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
self.create_execution_cards()
|
self.create_execution_cards()
|
||||||
|
|
||||||
def create_execution_cards(self):
|
def create_execution_cards(self):
|
||||||
"""Auto-generate Execution Cards for each run step."""
|
"""Auto-generate Execution Cards for each run step
|
||||||
|
that has an assigned employee."""
|
||||||
for step in self.get("run_steps", []):
|
for step in self.get("run_steps", []):
|
||||||
if not step.assigned_employee:
|
if not step.assigned_employee:
|
||||||
continue
|
continue
|
||||||
|
|
@ -72,3 +167,4 @@ class EngagementOrder(Document):
|
||||||
"status": "Open",
|
"status": "Open",
|
||||||
})
|
})
|
||||||
card.insert()
|
card.insert()
|
||||||
|
step.db_set("status", "Active")
|
||||||
|
|
|
||||||
|
|
@ -18,16 +18,33 @@ class ExecutionCard(Document):
|
||||||
self.actual_hours_logged = total
|
self.actual_hours_logged = total
|
||||||
|
|
||||||
def on_update(self):
|
def on_update(self):
|
||||||
|
self.update_step_actual_hours()
|
||||||
self.update_engagement_order_totals()
|
self.update_engagement_order_totals()
|
||||||
|
|
||||||
def on_submit(self):
|
def on_submit(self):
|
||||||
self.status = "Submitted"
|
self.status = "Submitted"
|
||||||
|
self.update_step_actual_hours()
|
||||||
self.update_engagement_order_totals()
|
self.update_engagement_order_totals()
|
||||||
|
|
||||||
def on_cancel(self):
|
def on_cancel(self):
|
||||||
|
self.update_step_actual_hours()
|
||||||
self.update_engagement_order_totals()
|
self.update_engagement_order_totals()
|
||||||
|
|
||||||
|
def update_step_actual_hours(self):
|
||||||
|
"""Push logged hours back to the matching run step
|
||||||
|
in the parent Engagement Order's child table."""
|
||||||
|
if not self.engagement_order or not self.step_name:
|
||||||
|
return
|
||||||
|
|
||||||
|
order = frappe.get_doc("Engagement Order", self.engagement_order)
|
||||||
|
for step in order.get("run_steps", []):
|
||||||
|
if step.step_name == self.step_name:
|
||||||
|
step.actual_hours = self.actual_hours_logged or 0
|
||||||
|
order.save()
|
||||||
|
break
|
||||||
|
|
||||||
def update_engagement_order_totals(self):
|
def update_engagement_order_totals(self):
|
||||||
|
"""Recalculate parent order totals."""
|
||||||
if not self.engagement_order:
|
if not self.engagement_order:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -63,7 +80,7 @@ class ExecutionCard(Document):
|
||||||
|
|
||||||
@frappe.whitelist()
|
@frappe.whitelist()
|
||||||
def stop_work(self):
|
def stop_work(self):
|
||||||
"""Stop work and mark the card as ready for submission."""
|
"""Stop work and mark the card for submission."""
|
||||||
if self.status not in ("Running", "Paused"):
|
if self.status not in ("Running", "Paused"):
|
||||||
frappe.throw(_("Card must be 'Running' or 'Paused' to stop work."))
|
frappe.throw(_("Card must be 'Running' or 'Paused' to stop work."))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue