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
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -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."))
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue