From 1fbbbb6e7dabc334601e633d18dedf7df4b15cca Mon Sep 17 00:00:00 2001 From: cclohmar Date: Wed, 15 Jul 2026 19:57:27 +0000 Subject: [PATCH] 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 --- .../engagement_order/engagement_order.py | 108 +++++++++++++++++- .../doctype/execution_card/execution_card.py | 19 ++- 2 files changed, 120 insertions(+), 7 deletions(-) diff --git a/service_factory/service_factory/doctype/engagement_order/engagement_order.py b/service_factory/service_factory/doctype/engagement_order/engagement_order.py index f8091f5..101f6ba 100644 --- a/service_factory/service_factory/doctype/engagement_order/engagement_order.py +++ b/service_factory/service_factory/doctype/engagement_order/engagement_order.py @@ -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") diff --git a/service_factory/service_factory/doctype/execution_card/execution_card.py b/service_factory/service_factory/doctype/execution_card/execution_card.py index 9c678fd..27cdc83 100644 --- a/service_factory/service_factory/doctype/execution_card/execution_card.py +++ b/service_factory/service_factory/doctype/execution_card/execution_card.py @@ -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."))