from odoo import api, fields, models WORK_CATEGORY_SELECTION = [ ("billable_delivery", "Billable Delivery"), ("internal_product", "Internal Product"), ("sales_support", "Sales Support"), ("rework", "Rework"), ("client_support", "Client Support"), ("admin", "Admin"), ("meetings", "Meetings"), ("qa", "QA"), ("research", "Research"), ] class ProjectProject(models.Model): _inherit = "project.project" mcs_initiative_type = fields.Selection( [ ("client", "Client Engagement"), ("internal", "Internal Initiative"), ("product", "Product Development"), ("sales", "Sales Execution"), ("parking_lot", "Idea Parking Lot"), ], string="Initiative Type", tracking=True, ) mcs_classification = fields.Selection( [ ("finish_now", "Finish Now"), ("active", "Active"), ("parked", "Parked"), ("kill", "Kill"), ], string="Operating Classification", default="active", tracking=True, ) mcs_revenue_connected = fields.Boolean(string="Connected to Revenue", tracking=True) mcs_monthly_revenue_target = fields.Monetary(string="Monthly Revenue Target") mcs_contracted_revenue = fields.Monetary(string="Contracted Revenue") mcs_cash_gap = fields.Monetary(string="Cash Gap", compute="_compute_mcs_cash_gap") mcs_planned_hours = fields.Float( string="Planned Hours", compute="_compute_mcs_project_hours", store=True ) mcs_actual_hours = fields.Float( string="Actual Hours", compute="_compute_mcs_project_hours", store=True ) mcs_utilization = fields.Float( string="Planned vs Actual %", compute="_compute_mcs_project_hours", store=True ) mcs_next_action = fields.Char(string="Next Action", tracking=True) mcs_blocker = fields.Text(string="Blocker", tracking=True) mcs_at_risk = fields.Boolean( string="At Risk", compute="_compute_mcs_at_risk", store=True ) mcs_delivery_type = fields.Selection( [ ("recurring", "Recurring"), ("fixed_project", "Fixed Project"), ("internal_product", "Internal Product"), ("internal_r_and_d", "Internal R&D"), ("sales_initiative", "Sales Initiative"), ("internal_operations", "Internal Operations"), ], string="Delivery Type", tracking=True, ) mcs_portfolio_health = fields.Selection( [ ("healthy", "Healthy"), ("watch", "Watch"), ("at_risk", "At Risk"), ("blocked", "Blocked"), ("completed", "Completed"), ], string="Portfolio Health", compute="_compute_mcs_portfolio_health", store=True, readonly=False, tracking=True, ) mcs_risk_score = fields.Integer( string="Risk Score", compute="_compute_mcs_portfolio_health", store=True ) mcs_last_meaningful_progress_date = fields.Date( string="Last Meaningful Progress", compute="_compute_mcs_progress_health", store=True, ) mcs_days_since_meaningful_progress = fields.Integer( string="Days Since Progress", compute="_compute_mcs_progress_health", store=True ) mcs_stale = fields.Boolean( string="Stale", compute="_compute_mcs_progress_health", store=True ) mcs_stale_threshold_days = fields.Integer(string="Stale Threshold Days", default=7) mcs_launch_completion_date = fields.Date(string="Launch / Completion Date") mcs_revenue_impact_score = fields.Integer(default=0) mcs_client_importance_score = fields.Integer(default=0) mcs_urgency_score = fields.Integer(default=0) mcs_completion_leverage_score = fields.Integer(default=0) mcs_resource_cost_score = fields.Integer(default=0) mcs_commercial_priority_score = fields.Integer( string="Commercial Priority Score", compute="_compute_mcs_commercial_priority_score", store=True, ) mcs_internal_product_stage = fields.Selection( [ ("idea", "Idea"), ("validating", "Validating"), ("approved", "Approved"), ("building", "Building"), ("beta", "Beta"), ("launched", "Launched"), ("revenue", "Revenue"), ("parked", "Parked"), ("killed", "Killed"), ], string="Internal Product Stage", default="idea", tracking=True, ) mcs_internal_expected_revenue = fields.Monetary(string="Expected Revenue") mcs_internal_actual_revenue = fields.Monetary(string="Actual Revenue") mcs_next_milestone = fields.Char(string="Next Milestone") mcs_next_decision_date = fields.Date(string="Next Decision Date") @api.depends("mcs_monthly_revenue_target", "mcs_contracted_revenue") def _compute_mcs_cash_gap(self): for project in self: project.mcs_cash_gap = ( project.mcs_monthly_revenue_target - project.mcs_contracted_revenue ) @api.depends("tasks.allocated_hours", "tasks.effective_hours") def _compute_mcs_project_hours(self): for project in self: planned = sum(project.tasks.mapped("allocated_hours")) actual = sum(project.tasks.mapped("effective_hours")) project.mcs_planned_hours = planned project.mcs_actual_hours = actual project.mcs_utilization = actual / planned * 100 if planned else 0 @api.depends("tasks.mcs_at_risk", "mcs_blocker", "mcs_next_action") def _compute_mcs_at_risk(self): for project in self: project.mcs_at_risk = bool( project.mcs_blocker or not project.mcs_next_action or any(project.tasks.mapped("mcs_at_risk")) ) @api.depends( "mcs_revenue_impact_score", "mcs_client_importance_score", "mcs_urgency_score", "mcs_completion_leverage_score", "mcs_resource_cost_score", ) def _compute_mcs_commercial_priority_score(self): for project in self: project.mcs_commercial_priority_score = ( project.mcs_revenue_impact_score + project.mcs_client_importance_score + project.mcs_urgency_score + project.mcs_completion_leverage_score - project.mcs_resource_cost_score ) @api.depends( "write_date", "tasks.write_date", "tasks.stage_id.fold", "timesheet_ids.date", "mcs_stale_threshold_days", ) def _compute_mcs_progress_health(self): today = fields.Date.context_today(self) for project in self: progress_dates = [] if project.write_date: progress_dates.append(fields.Date.to_date(project.write_date)) for task in project.tasks: if task.write_date: progress_dates.append(fields.Date.to_date(task.write_date)) progress_dates.extend(project.timesheet_ids.mapped("date")) last_progress = max(progress_dates) if progress_dates else False project.mcs_last_meaningful_progress_date = last_progress project.mcs_days_since_meaningful_progress = ( (today - last_progress).days if last_progress else 0 ) threshold = project.mcs_stale_threshold_days or 7 project.mcs_stale = bool( project.active and last_progress and project.mcs_days_since_meaningful_progress > threshold ) @api.depends( "active", "mcs_at_risk", "mcs_blocker", "mcs_next_action", "mcs_stale", "mcs_planned_hours", "mcs_actual_hours", "mcs_launch_completion_date", ) def _compute_mcs_portfolio_health(self): for project in self: risk_score = 0 if project.mcs_blocker: risk_score += 4 if not project.mcs_next_action: risk_score += 2 if project.mcs_stale: risk_score += 2 if project.mcs_planned_hours and project.mcs_actual_hours > project.mcs_planned_hours: risk_score += 2 if project.mcs_at_risk: risk_score += 2 project.mcs_risk_score = risk_score if project.mcs_launch_completion_date: project.mcs_portfolio_health = "completed" elif project.mcs_blocker: project.mcs_portfolio_health = "blocked" elif risk_score >= 5: project.mcs_portfolio_health = "at_risk" elif risk_score: project.mcs_portfolio_health = "watch" else: project.mcs_portfolio_health = "healthy" class ProjectTask(models.Model): _inherit = "project.task" mcs_priority = fields.Selection( [ ("p0", "P0 - Critical"), ("p1", "P1 - High"), ("p2", "P2 - Normal"), ("p3", "P3 - Low"), ], string="Operating Priority", default="p2", tracking=True, ) mcs_definition_of_done = fields.Html(string="Definition of Done") mcs_next_action = fields.Char(string="Next Action", tracking=True) mcs_blocker = fields.Text(string="Blocker", tracking=True) mcs_client_internal = fields.Selection( [("client", "Client"), ("internal", "Internal")], string="Client / Internal", default="client", tracking=True, ) mcs_work_category = fields.Selection(WORK_CATEGORY_SELECTION, string="Work Category") mcs_is_blocked = fields.Boolean(string="Blocked", compute="_compute_mcs_flags", store=True) mcs_at_risk = fields.Boolean(string="At Risk", compute="_compute_mcs_flags", store=True) mcs_missing_operating_data = fields.Boolean( string="Missing Operating Data", compute="_compute_mcs_flags", store=True ) @api.depends( "user_ids", "date_deadline", "allocated_hours", "mcs_definition_of_done", "mcs_next_action", "mcs_blocker", "depend_on_ids", "stage_id.fold", ) def _compute_mcs_flags(self): today = fields.Date.context_today(self) for task in self: is_done = bool(task.stage_id and task.stage_id.fold) has_deadline_passed = bool( task.date_deadline and fields.Date.to_date(task.date_deadline) < today ) task.mcs_is_blocked = bool(task.mcs_blocker or task.depend_on_ids) task.mcs_missing_operating_data = bool( not is_done and ( not task.user_ids or not task.date_deadline or not task.allocated_hours or not task.mcs_definition_of_done or not task.mcs_next_action ) ) task.mcs_at_risk = bool( not is_done and (task.mcs_is_blocked or has_deadline_passed or task.mcs_missing_operating_data) )