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:
Claus Lohmar 2026-07-15 19:57:27 +00:00
parent 6b44131abe
commit 1fbbbb6e7d
2 changed files with 120 additions and 7 deletions

View file

@ -3,14 +3,28 @@
import frappe
from frappe.model.document import Document
from frappe import _
from datetime import datetime, timedelta
class EngagementOrder(Document):
def on_change_engagement_recipe(self):
"""Fetch recipe steps into order run steps when recipe is selected."""
def validate(self):
"""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:
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)
self.set("run_steps", [])
@ -22,10 +36,8 @@ class EngagementOrder(Document):
row.allocated_hours = step.estimated_hours
row.status = "Pending"
def before_save(self):
self.calculate_totals()
def calculate_totals(self):
"""Recalculate order-level totals from run steps."""
total_allocated = 0.0
total_logged = 0.0
actual_cost = 0.0
@ -54,11 +66,94 @@ class EngagementOrder(Document):
self.actual_cost = actual_cost
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):
self.create_execution_cards()
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", []):
if not step.assigned_employee:
continue
@ -72,3 +167,4 @@ class EngagementOrder(Document):
"status": "Open",
})
card.insert()
step.db_set("status", "Active")

View file

@ -18,16 +18,33 @@ class ExecutionCard(Document):
self.actual_hours_logged = total
def on_update(self):
self.update_step_actual_hours()
self.update_engagement_order_totals()
def on_submit(self):
self.status = "Submitted"
self.update_step_actual_hours()
self.update_engagement_order_totals()
def on_cancel(self):
self.update_step_actual_hours()
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):
"""Recalculate parent order totals."""
if not self.engagement_order:
return
@ -63,7 +80,7 @@ class ExecutionCard(Document):
@frappe.whitelist()
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"):
frappe.throw(_("Card must be 'Running' or 'Paused' to stop work."))