303 lines
11 KiB
Python
303 lines
11 KiB
Python
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(
|
|
date for date in project.timesheet_ids.mapped("date") if 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)
|
|
)
|