From 84209057c7c420799bf7547468aa6401380d8f5b Mon Sep 17 00:00:00 2001 From: Florian Mounier Date: Thu, 15 Feb 2024 17:12:44 +0100 Subject: [PATCH] [IMP] project_workload_timesheet: Link timesheet lines with workload units Compute remaining time for unit Add quick timesheet buttons Handle unit done status Report unfinished unit at sheet validation --- project_workload_timesheet/__manifest__.py | 6 +- project_workload_timesheet/models/__init__.py | 1 + .../models/account_analytic_line.py | 65 +++++++++ .../models/hr_timesheet_sheet.py | 115 ++++++++++++--- .../models/project_workload_unit.py | 73 +++++++++- .../views/hr_timesheet_sheet_views.xml | 136 ++++++++++++++++-- 6 files changed, 356 insertions(+), 40 deletions(-) create mode 100644 project_workload_timesheet/models/account_analytic_line.py diff --git a/project_workload_timesheet/__manifest__.py b/project_workload_timesheet/__manifest__.py index abef4b8b..4b90aa6a 100644 --- a/project_workload_timesheet/__manifest__.py +++ b/project_workload_timesheet/__manifest__.py @@ -14,7 +14,9 @@ "license": "AGPL-3", "depends": [ "project_workload", - "hr_timesheet", + "hr_timesheet_sheet", + ], + "data": [ + "views/hr_timesheet_sheet_views.xml", ], - "data": [], } diff --git a/project_workload_timesheet/models/__init__.py b/project_workload_timesheet/models/__init__.py index ef599bd6..bd28cf78 100644 --- a/project_workload_timesheet/models/__init__.py +++ b/project_workload_timesheet/models/__init__.py @@ -1,2 +1,3 @@ +from . import account_analytic_line from . import hr_timesheet_sheet from . import project_workload_unit diff --git a/project_workload_timesheet/models/account_analytic_line.py b/project_workload_timesheet/models/account_analytic_line.py new file mode 100644 index 00000000..c378f66f --- /dev/null +++ b/project_workload_timesheet/models/account_analytic_line.py @@ -0,0 +1,65 @@ +# Copyright 2024 Akretion (https://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, api, fields, models +from odoo.exceptions import UserError, ValidationError + + +from odoo.addons.project_workload.models.project_task_workload import week_name + + +class AccountAnalyticLine(models.Model): + _inherit = "account.analytic.line" + + workload_unit_id = fields.Many2one( + comodel_name="project.workload.unit", + string="Workload Unit", + readonly=False, + compute="_compute_workload_unit_id", + store=True, + domain="[" + "('week', '=', week), " + "('user_id', '=', user_id), " + "('project_id', '=', project_id), " + "('task_id', '=', task_id)" + "]", + ) + + week = fields.Char( + string="Week", + compute="_compute_week", + help="Week number of the year", + ) + + @api.depends("date") + def _compute_week(self): + for record in self: + record.week = week_name(record.date) + + @api.depends("task_id", "date", "user_id") + def _compute_workload_unit_id(self): + for record in self: + if not record.project_id or not record.task_id: + record.workload_unit_id = False + continue + available_workload_units = self.env["project.workload.unit"].search( + record._get_available_workload_units_domain() + ) + if ( + not record.workload_unit_id + or record.workload_unit_id not in available_workload_units + ): + record.workload_unit_id = ( + available_workload_units[0] + if len(available_workload_units) > 0 + else False + ) + + def _get_available_workload_units_domain(self): + return [ + ("project_id", "=", self.project_id.id), + ("task_id", "=", self.task_id.id), + ("week", "=", self.week), + ("user_id", "=", self.user_id.id), + ] diff --git a/project_workload_timesheet/models/hr_timesheet_sheet.py b/project_workload_timesheet/models/hr_timesheet_sheet.py index 284cebd3..5848cdf0 100644 --- a/project_workload_timesheet/models/hr_timesheet_sheet.py +++ b/project_workload_timesheet/models/hr_timesheet_sheet.py @@ -14,9 +14,9 @@ class Sheet(models.Model): workload_unit_ids = fields.One2many( "project.workload.unit", - "sheet_id", string="Workload Units", compute="_compute_workload_unit_ids", + readonly=False, ) next_week_load = fields.Float( @@ -24,6 +24,17 @@ class Sheet(models.Model): compute="_compute_next_week_load", help="The workload of the next week", ) + next_week_units_count = fields.Integer( + "Next Week Units Count", + compute="_compute_next_week_load", + help="The number of workload units of the next week", + ) + + current = fields.Boolean( + "Current", + compute="_compute_current", + help="Is this the current timesheet", + ) @api.depends("date_start", "date_end", "user_id") def _compute_workload_unit_ids(self): @@ -37,7 +48,14 @@ def _compute_workload_unit_ids(self): ("user_id", "=", record.user_id.id), ], ) - .sorted(lambda p: -int(p.priority or 0)) # Hum + .sorted( + lambda p: ( + p.done, # Put done tasks at the end + -int(p.priority or 0), # Sort by priority + p.remaining_hours, # Sort by remaining hours + -p.id, # Stabilize sort + ) + ) ) @api.depends("date_start", "date_end", "user_id") @@ -50,9 +68,14 @@ def _compute_next_week_load(self): ("user_id", "=", record.user_id.id), ], ) - + record.next_week_units_count = len(next_week_units) record.next_week_load = sum(next_week_units.mapped("hours")) + @api.depends("date_start", "date_end") + def _compute_current(self): + for record in self: + record.current = record.date_start <= fields.Date.today() <= record.date_end + def button_open_next_week(self): self.ensure_one() next_week = self.date_start + timedelta(days=7) @@ -97,28 +120,74 @@ def _add_line_from_unit(self, unit): if existing_unique_ids: self.delete_empty_lines(False) if frozenset(new_line_unique_id.items()) not in existing_unique_ids: - # TODO MAKE this configurable - DAILY_HOURS = 8 task = unit._get_timesheeting_task() + if self.current: + values["date"] = fields.Date.today() - if task.date_start and task.date_end: - task_start = task.date_start.date() - task_end = task.date_end.date() - - today = fields.Date.today() - # If this is the current week - if self.date_start <= today <= self.date_end: - # Take the closest day to today - values["date"] = max(min(today, task_end), task_start) - else: - # If start date is in week, take it - if self.date_start <= task_start <= self.date_end: - values["date"] = task_start - - values["unit_amount"] = min(DAILY_HOURS, unit.hours) + values["unit_amount"] = 0 values["project_id"] = task.project_id.id values["task_id"] = task.id - self.timesheet_ids |= self.env["account.analytic.line"]._sheet_create( - values + values["workload_unit_id"] = unit.id + return self.env["account.analytic.line"]._sheet_create(values) + + @api.model + def _prepare_new_line(self, line): + # We need to check if the new line is similar to a worload unit + # If it is, we need to link it to the workload unit + vals = super()._prepare_new_line(line) + # Yeah, using the same function for 2 different things leads to this :/ + if line._name != "hr_timesheet.sheet.new.analytic.line": + return vals + timesheets = line.sheet_id.timesheet_ids + similar_timesheets = timesheets.filtered( + lambda t: t.project_id == line.project_id and t.task_id == line.task_id + ) + if not similar_timesheets: + return vals + + similar_workload_units = similar_timesheets.mapped("workload_unit_id") + vals["workload_unit_id"] = ( + similar_workload_units.ids[0] if similar_workload_units else False + ) + return vals + + def action_timesheet_done(self): + self.ensure_one() + super().action_timesheet_done() + + next_week = week_name(self.date_start + timedelta(days=7)) + next_week_units = self.env["project.workload.unit"].search( + [ + ("week", "=", next_week), + ("user_id", "=", self.user_id.id), + ], + ) + + unfinished_units = self.workload_unit_ids.filtered(lambda u: not u.done) + for unit in unfinished_units: + next_week_unit = next_week_units.filtered( + lambda u: u.project_id == unit.project_id + and u.task_id == unit.task_id + and u.workload_id == unit.workload_id ) - return True + + # But we report the remaining hours to the next week + # We also report if the remaining hours are negative + # It will decrease the next week workload + # But we don't create a unit in this case + if not next_week_unit and unit.remaining_hours > 0: + # And we create a new unit if it does not exist + # Even if the unit could be after end_date for now + next_week_unit = self.env["project.workload.unit"].create( + { + "task_id": unit.task_id.id, + "workload_id": unit.workload_id.id, + "week": next_week, + "hours": unit.remaining_hours, + } + ) + else: + next_week_unit.hours += unit.remaining_hours + + # The unit are now done so the unit hours are the timesheeted hours + unit.hours = unit.timesheeted_hours diff --git a/project_workload_timesheet/models/project_workload_unit.py b/project_workload_timesheet/models/project_workload_unit.py index ef093c30..020bf702 100644 --- a/project_workload_timesheet/models/project_workload_unit.py +++ b/project_workload_timesheet/models/project_workload_unit.py @@ -2,22 +2,89 @@ # @author Florian Mounier # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). -from odoo import fields, models +from odoo import api, fields, models class ProjectWorkloadUnit(models.Model): _inherit = "project.workload.unit" - sheet_id = fields.Many2one("hr_timesheet.sheet") + timesheet_ids = fields.One2many( + "account.analytic.line", + "workload_unit_id", + "Timesheets", + help="The timesheets (normally one) in which the workload is timesheeted", + ) priority = fields.Selection(related="task_id.priority") - def action_add(self): + timesheeted_hours = fields.Float( + "Timesheeted Hours", + compute="_compute_timesheeted_hours", + help="The hours timesheeted on this workload", + ) + remaining_hours = fields.Float( + "Remaining Hours", + compute="_compute_remaining_hours", + help="The remaining hours to timesheet on this workload (can be negative)", + ) + progress = fields.Float( + "Progress", + compute="_compute_progress", + help="The progress of the task", + ) + done = fields.Boolean( + "Done", + ) + task_stage_id = fields.Many2one( + related="task_id.stage_id", string="Task Stage", readonly=False + ) + + @api.depends("timesheet_ids.unit_amount") + def _compute_timesheeted_hours(self): + for record in self: + record.timesheeted_hours = sum(record.timesheet_ids.mapped("unit_amount")) + + @api.depends("hours", "timesheeted_hours") + def _compute_progress(self): + for record in self: + if record.hours: + record.progress = 100 * record.timesheeted_hours / record.hours + else: + record.progress = 0 + + @api.depends("hours", "timesheeted_hours", "done") + def _compute_remaining_hours(self): + for record in self: + if record.done: + record.remaining_hours = 0 + else: + record.remaining_hours = record.hours - record.timesheeted_hours + + def action_add_to_timesheet(self): sheet_id = self.env.context.get("current_sheet_id") if not sheet_id: return sheet = self.env["hr_timesheet.sheet"].browse(sheet_id) return sheet._add_line_from_unit(self) + def action_timesheet_time(self): + sheet_id = self.env.context.get("current_sheet_id") + if not sheet_id: + return + sheet = self.env["hr_timesheet.sheet"].browse(sheet_id) + if not sheet or not sheet.current: + return + time = self.env.context.get("time", 0) / 60 + + timesheet = self.timesheet_ids.filtered( + lambda t: t.date == fields.Date.today() + ) or sheet._add_line_from_unit(self) + timesheet.unit_amount += time + return True + + def action_timesheet_done(self): + self.done = True + pass + def _get_timesheeting_task(self): # For overrides return self.task_id diff --git a/project_workload_timesheet/views/hr_timesheet_sheet_views.xml b/project_workload_timesheet/views/hr_timesheet_sheet_views.xml index c19b3b84..1dbfa2f1 100644 --- a/project_workload_timesheet/views/hr_timesheet_sheet_views.xml +++ b/project_workload_timesheet/views/hr_timesheet_sheet_views.xml @@ -5,25 +5,137 @@
-
-
+ + + + + + +

Todo

- - - - - -