diff --git a/cor_custom/__init__.py b/cor_custom/__init__.py index a41b57f..1099cf8 100755 --- a/cor_custom/__init__.py +++ b/cor_custom/__init__.py @@ -3,4 +3,4 @@ from . import controllers from . import models from . import wizard -#from . import report \ No newline at end of file +from . import report \ No newline at end of file diff --git a/cor_custom/__manifest__.py b/cor_custom/__manifest__.py index 5a94307..976fbf7 100755 --- a/cor_custom/__manifest__.py +++ b/cor_custom/__manifest__.py @@ -20,23 +20,28 @@ 'version': '0.1', # any module necessary for this one to work correctly - 'depends': ['base', 'crm', 'sale_timesheet', 'analytic', 'hr'], + 'depends': ['base', 'crm', 'sale_timesheet', 'analytic', 'hr', 'hr_timesheet', 'web'], # always loaded 'data': [ - #'security/ir.model.access.csv', - #'security/cor_custom_security.xml', + 'security/ir.model.access.csv', + 'security/cor_custom_security.xml', 'views/crm_view.xml', 'views/sale_views.xml', 'views/project_view.xml', + #'views/project_hours_view.xml', 'views/hr_employee_views.xml', 'views/hr_timesheet_templates.xml', 'views/analytic_view.xml', + 'report/project_hours_report_view.xml', 'report/project_profitability_report_analysis_views.xml', 'views/views.xml', 'views/templates.xml', + 'views/assets.xml', + 'data/mail_data.xml', #'views/menu_show_view.xml', 'wizard/project_create_sale_order_views.xml', + 'wizard/project_multi_budget_assign_view.xml', ], # only loaded in demonstration mode 'demo': [ diff --git a/cor_custom/data/mail_data.xml b/cor_custom/data/mail_data.xml new file mode 100755 index 0000000..0984d44 --- /dev/null +++ b/cor_custom/data/mail_data.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + diff --git a/cor_custom/models/__init__.py b/cor_custom/models/__init__.py index 1bcbe44..899e0e4 100755 --- a/cor_custom/models/__init__.py +++ b/cor_custom/models/__init__.py @@ -3,6 +3,7 @@ from . import crm_lead from . import models from . import project +from . import project_hours from . import project_overview from . import analytic from . import product diff --git a/cor_custom/models/analytic.py b/cor_custom/models/analytic.py index efb1be5..dc79a27 100755 --- a/cor_custom/models/analytic.py +++ b/cor_custom/models/analytic.py @@ -1,17 +1,95 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details -from odoo import api, exceptions, fields, models, _ +from odoo import api, exceptions, fields, models, tools, _ from odoo.exceptions import UserError, AccessError, ValidationError from odoo.osv import expression +import math +from datetime import datetime, time, timedelta +from odoo.tools.float_utils import float_round +from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, ustr +import dateutil.parser + class AccountAnalyticLine(models.Model): _inherit = 'account.analytic.line' start_time = fields.Float(string='Start Time', digits=(16, 2)) end_time = fields.Float(string='End Time', digits=(16, 2)) + unit_amount = fields.Float('Duration', default=0.0) + parent_project = fields.Many2one('project.project', related='project_id.parent_project', string='Parent Project') + def _default_start_datetime(self): + return fields.Datetime.to_string(datetime.combine(fields.Datetime.now(), datetime.min.time())) + + def _default_end_datetime(self): + return fields.Datetime.to_string(datetime.combine(fields.Datetime.now(), datetime.max.time())) + + start_datetime = fields.Datetime("Start Time", required=True) + end_datetime = fields.Datetime("End Time", required=True) + + + # @api.onchange('project_id') + # def _onchange_parent_project_id(self): + # if self.project_id: + # parent_project = self.env['project.project'].search([('sub_project', '=', self.project_id.id)], limit=1) + # if parent_project: + # self.parent_project = parent_project.id + # else: + # self.parent_project = False + # else: + # self.parent_project = False + + + @api.onchange('employee_id') + def _onchange_employee_id(self): + domain = [] + if self.employee_id and self.employee_id.user_id: + manager_id = self.env['project.project'].search( + [('user_id', '=', self.employee_id.user_id.id), ('allow_timesheets', '=', True)]).ids + emp_project_ids = self.env['project.project'].search( + [('privacy_visibility', 'in', ('employees', 'portal')), ('allow_timesheets', '=', True)]).ids + project_ids = self.env['project.project'].search( + [('privacy_visibility', '=', 'followers'), ('allow_timesheets', '=', True), + ('allowed_internal_user_ids', 'in', self.employee_id.user_id.id)]).ids + consul_ids = self.env['project.sale.line.employee.map'].search([('employee_id', '=', self.employee_id.id)]) + consul_project_ids = [val.project_id.id for val in consul_ids] + emp_all_project_ids = manager_id + emp_project_ids + project_ids + consul_project_ids + domain = [('id', 'in', list(set(emp_all_project_ids)))] + result = { + 'domain': {'project_id': domain}, + } + return result + + @api.onchange('project_id') + def _onchange_project_id(self): + domain = [] if not self.project_id else [('project_id', '=', self.project_id.id)] + cuser = [] + manager_id = [] + user_ids = [] + consul_project_ids = [] + if not self.project_id: + cuser = self.env['hr.employee'].search([('user_id', '=', self.env.user.id)]).ids + if self.project_id: + consul_ids = self.env['project.sale.line.employee.map'].search([('project_id', '=', self.project_id.id)]) + consul_project_ids = [val.employee_id.id for val in consul_ids] + if self.project_id and self.project_id.user_id: + manager_id = self.env['hr.employee'].search([('user_id', '=', self.project_id.user_id.id)]).ids + if self.project_id and self.project_id.privacy_visibility in ('employees', 'portal'): + user_ids = self.env['hr.employee'].search([('user_id', '!=', False)]).ids + if self.project_id and self.project_id.privacy_visibility == 'followers': + user_ids = self.env['hr.employee'].search( + [('user_id', 'in', self.project_id.allowed_internal_user_ids.ids)]).ids + project_all_emp_ids = cuser + manager_id + user_ids + consul_project_ids + result = { + 'domain': {'task_id': domain, 'employee_id': [('id', 'in', project_all_emp_ids)]}, + } + if self.project_id != self.task_id.project_id: + # reset task when changing project + self.task_id = False + return result + _sql_constraints = [ ('check_start_time_lower_than_24', 'CHECK(start_time <= 24)', 'You cannot have a start hour greater than 24'), ('check_start_time_positive', 'CHECK(start_time >= 0)', 'Start hour must be a positive number'), @@ -34,25 +112,83 @@ class AccountAnalyticLine(models.Model): raise ValidationError(_("End time cannot be earlier than Start time")) self.unit_amount = res + @api.onchange('start_datetime', 'end_datetime') + def _onchange_start_end_date_time(self): + if self.start_datetime and self.end_datetime: + total_hours = (self.end_datetime - self.start_datetime).total_seconds() / 3600 + self.unit_amount = total_hours + + # @api.onchange('start_time', 'end_time') + # def _onchange_start_end_time(self): + # if self.start_time > 0: + # dt = datetime.strptime('26 Sep 2012', '%d %b %Y') + # print('HHHHHHHH', dt) + # newdatetime = dt.replace(hour=11, minute=59) + # print('GGGGGGGGGGGGG', newdatetime) + + @api.model + def export_data(self, fields): + index = range(len(fields)) + fields_name = dict(zip(fields, index)) + res = super(AccountAnalyticLine, self).export_data(fields) + for index, val in enumerate(res['datas']): + #print("task_id", fields_name) + if fields_name.get('date') and fields_name.get('date') >= 0: + tdateindex = fields_name.get('date') + tdate = res['datas'][index][tdateindex] + if tdate: + res['datas'][index][tdateindex] = datetime.strftime(tdate, "%d/%m/%Y") + if fields_name.get('start_datetime') and fields_name.get('start_datetime') >= 0: + start_datetime_index = fields_name.get('start_datetime') + start_datetime = res['datas'][index][start_datetime_index] + if start_datetime: + res['datas'][index][start_datetime_index] = datetime.strftime(start_datetime, "%d/%m/%Y %H:%M") + if fields_name.get('end_datetime') and fields_name.get('end_datetime') >= 0: + end_datetime_index = fields_name.get('end_datetime') + end_datetime= res['datas'][index][end_datetime_index] + if end_datetime: + res['datas'][index][end_datetime_index] = datetime.strftime(end_datetime, "%d/%m/%Y %H:%M") + if fields_name.get('task_id') and fields_name.get('task_id') >= 0: + taskindex = fields_name.get('task_id') + ttask = res['datas'][index][taskindex] + if type(ttask) == bool: + res['datas'][index][taskindex] = '' + if fields_name.get('start_time') and fields_name.get('start_time') >= 0: + starttimeindex = fields_name.get('start_time') + starttime = float(res['datas'][index][starttimeindex]) + if starttime: + start_time = tools.format_duration(starttime) + res['datas'][index][starttimeindex] = start_time + if fields_name.get('end_time') and fields_name.get('end_time') >= 0: + endtimeindex = fields_name.get('end_time') + endtime = float(res['datas'][index][endtimeindex]) + if endtime: + end_time = tools.format_duration(endtime) + res['datas'][index][endtimeindex] = end_time + return res @api.model def create(self, vals): if vals.get('unit_amount') == 0.0: raise ValidationError(_("Your can not fill 0.0 hour entry")) - """if vals.get('employee_id') and vals.get('project_id'): - project = self.env['project.project'].search([('id', '=', vals.get('project_id'))]) - if project: - for rec in project.sale_line_employee_ids: - if rec.employee_id.id == vals.get('employee_id'): - remain_hour = rec.budgeted_qty - rec.timesheet_hour - if vals.get('unit_amount') > remain_hour: - raise ValidationError(_("Your can not fill entry more than Budgeted hours"))""" + if vals.get('unit_amount') >= 24.0: + raise ValidationError(_("Your can not fill more than 24.0 hour entry")) value = super(AccountAnalyticLine, self).create(vals) + if value and value.project_id: + value.project_id._onchange_calculate_timesheet_hours() + value.project_id._compute_calc() return value - def write(self, vals): - if vals.get('unit_amount') == 0.0: + if vals.get('unit_amount') == 0.0: raise ValidationError(_("Your can not fill 0.0 hour entry")) - return super().write(vals) \ No newline at end of file + res = super(AccountAnalyticLine, self).write(vals) + if self.project_id: + self.project_id._onchange_calculate_timesheet_hours() + return res + + @api.onchange('start_datetime', 'end_datetime') + def _onchange_start_end_date(self): + if self.start_datetime: + self.date = self.start_datetime.date() diff --git a/cor_custom/models/project.py b/cor_custom/models/project.py index 6fe9cbd..fb661ff 100755 --- a/cor_custom/models/project.py +++ b/cor_custom/models/project.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- from odoo import api, fields, models, _ +from odoo.exceptions import UserError, AccessError, ValidationError class Project(models.Model): @@ -13,7 +14,7 @@ class Project(models.Model): help='When billing tasks individually, a Sales Order will be created from each task. It is perfect if you would like to bill different services to different clients at different rates. \n When billing the whole project, a Sales Order will be created from the project instead. This option is better if you would like to bill all the tasks of a given project to a specific client either at a fixed rate, or at an employee rate.') pricing_type = fields.Selection([ - ('fixed_rate', 'Fixed rate'), + ('fixed_rate', 'Project that have no time limit'), ('employee_rate', 'Consultant rate') ], string="Pricing", default="fixed_rate", help='The fixed rate is perfect if you bill a service at a fixed rate per hour or day worked regardless of the consultant who performed it. The consultant rate is preferable if your employees deliver the same service at a different rate. For instance, junior and senior consultants would deliver the same service (= consultancy), but at a different rate because of their level of seniority.') @@ -38,16 +39,118 @@ class Project(models.Model): " them or by someone of their company.") allow_billable = fields.Boolean("Billable", default=True, help="Invoice your time and material from tasks.") - budgeted_hours = fields.Float(string='Total Budgeted Hours', digits=(16, 2)) + date_start = fields.Date(string='Start Date') + date = fields.Date(string='End Date', index=True, tracking=True) + timesheet_hour = fields.Float(string='Total Timesheet Hours', compute='_compute_calc', store=True) + budgeted_hours = fields.Float(string='Total Budgeted Hours', compute='_compute_calc') + budgeted_hours2 = fields.Float(string='Total Budgeted Hours') budgeted_revenue = fields.Float(string='Budgeted Revenue', digits=(16, 2)) expenses_per = fields.Float(string='Expenses (%)', digits=(16, 2)) expenses_amt = fields.Float(string='Expenses Amount', digits=(16, 2)) - cost = fields.Float("Sum of Cost", compute='onchange_compute_values', store=True) - hourly_rate = fields.Float("Sum of Hourly rate", compute='onchange_compute_values', store=True) - budgeted_hour_week = fields.Float("Budgeted Hours(per week)", compute='onchange_compute_values', store=True) + cost = fields.Float("Total Revenue", compute='onchange_compute_values', store=True) + consultant_cost = fields.Float("Actual Cost", compute='_compute_calc') + actual_revenue = fields.Float("Actual Revenue", compute='_compute_calc') + other_expenses = fields.Float(string='Other Expenses', related='expenses_amt') total_expenses = fields.Float(string='Total Expenses', digits=(16, 2), compute='_compute_calc', store=True) + hourly_rate = fields.Float("Hourly Rate", default=0.0) + hourly_rate2 = fields.Float("Hourly Ratee") + budgeted_hour_week = fields.Float("Budgeted Hours(per week)", compute='onchange_compute_values', store=True) profit_amt = fields.Float(string='Profit Amount', digits=(16, 2), compute='_compute_calc', store=True) profit_per = fields.Float(string='Porfit Percentage', digits=(16, 2), compute='_compute_calc', store=True) + hour_distribution = fields.Selection([ + ('Manual', 'Manual'), + ('Percentage', 'Percentage'), + ], string="Hours Distribution", default="Manual") + manager_per = fields.Float(string='Manager %', digits=(16, 2)) + employee_per = fields.Float(string='Employee %', digits=(16, 2), compute='compute_percentage_hours', store=True) + + manager_hour = fields.Float(string='Manager Hour') + employee_hour = fields.Float(string='Employee Hour') + + consultant_timesheet_hrs = fields.One2many('consultant.timesheet.hrs', 'project_id', "Timesheet Hrs", + copy=False, help="Consultant timesheet hours") + project_cons_hrs = fields.One2many('project.consultant.hrs', 'project_id', 'Consultant Allocation', copy=False) + comment = fields.Text(string='Comment') + tag_ids = fields.Many2many('custom.project.tags', string='Tags') + + @api.onchange('allowed_internal_user_ids') + def onchange_add_allowed_internal_users(self): + user_list = [] + consultant_list = [] + employee_obj = [] + if self.allowed_internal_user_ids: + user_list = self.allowed_internal_user_ids.ids + if self.sale_line_employee_ids: + for consultant in self.sale_line_employee_ids: + consultant_list.append(consultant.employee_id.user_id.id) + users = (set(user_list)) - (set(consultant_list)) + for record in list(users): + emp_obj = self.env['hr.employee'].search([('user_id', '=', record)]).id + if emp_obj: + employee_obj.append(emp_obj) + if employee_obj: + for employee in employee_obj: + if self._origin.id: + self.sale_line_employee_ids.create({'project_id': self._origin.id, + 'employee_id': employee}) + + def _onchange_calculate_timesheet_hours(self): + self.consultant_timesheet_hrs = [(6, 0, False)] + if self._origin.id: + self._cr.execute('''SELECT project_id, employee_id, SUM(unit_amount) FROM account_analytic_line + where project_id = %(project_id)s + GROUP BY project_id, employee_id''', + {'project_id': self._origin.id}) + res = self._cr.fetchall() + if res: + for rec in res: + self.consultant_timesheet_hrs.create({'project_id': rec[0], + 'employee_id': rec[1], + 'timesheet_hour': rec[2]}) + + @api.depends('cost', 'expenses_amt', 'budgeted_revenue') + def _compute_calc(self): + for record in self: + consultant_cost = 0.0 + actual_revenue = 0.0 + hour = 0.0 + timesheet_hour = 0.0 + for rec in record.sale_line_employee_ids: + consultant_cost = consultant_cost + rec.consultant_cost + actual_revenue = actual_revenue + rec.actual_revenue + hour = hour + rec.budgeted_qty + timesheet_hour = timesheet_hour + rec.timesheet_hour + if record.pricing_type == 'fixed_rate': + for rec in record.consultant_timesheet_hrs: + consultant_cost = consultant_cost + rec.consultant_cost + timesheet_hour = timesheet_hour + rec.timesheet_hour + record.consultant_cost = consultant_cost + if record.project_type == 'hours_in_consultant': + record.actual_revenue = actual_revenue + else: + record.actual_revenue = record.hourly_rate * timesheet_hour + record.budgeted_hours = hour + record.timesheet_hour = timesheet_hour + total_exp = record.consultant_cost + record.expenses_amt + record.total_expenses = total_exp + profit_amt = record.budgeted_revenue - total_exp + record.profit_amt = profit_amt + if record.profit_amt > 0 and record.budgeted_revenue > 0: + record.profit_per = (record.profit_amt / record.budgeted_revenue) * 100 + if record.project_type == 'hours_in_consultant' and record.budgeted_hours > 0.0: + record.hourly_rate = (record.budgeted_revenue / record.budgeted_hours) + # if record.project_type == 'hours_no_limit' and record.budgeted_hours2 > 0.0: + # record.hourly_rate = (record.budgeted_revenue / record.budgeted_hours2) + + @api.depends('manager_per', 'hour_distribution') + def compute_percentage_hours(self): + for record in self: + if record.manager_per > 100: + raise ValidationError(_("Percentage should be less than or equal to 100")) + if record.manager_per > 0.0: + record.employee_per = 100 - record.manager_per + # if self.manager_per > 100: + # raise ValidationError(_("Percentage should be less than or equal to 100")) @api.onchange('budgeted_revenue', 'expenses_per') def onchange_expenses_per(self): @@ -55,52 +158,112 @@ class Project(models.Model): expense_amount = self.budgeted_revenue * (self.expenses_per / 100) self.expenses_amt = expense_amount - @api.depends('cost', 'expenses_amt', 'budgeted_revenue') - def _compute_calc(self): - for record in self: - total_exp = record.cost + record.expenses_amt - record.total_expenses = total_exp - profit_amt = record.budgeted_revenue - total_exp - record.profit_amt = profit_amt - if record.profit_amt > 0 and record.budgeted_revenue > 0: - record.profit_per = (record.profit_amt / record.budgeted_revenue) * 100 + @api.onchange('budgeted_revenue', 'sale_line_employee_ids') + def onchange_budgeted_hour(self): + self.sale_line_employee_ids._compute_total_cost() + hour = 0.0 + for rec in self.sale_line_employee_ids: + hour = hour + rec.budgeted_qty + self.budgeted_hours = hour + if self.project_type == 'hours_in_consultant' and self.budgeted_hours > 0.0: + self.hourly_rate = (self.budgeted_revenue / self.budgeted_hours) @api.depends('sale_line_employee_ids') def onchange_compute_values(self): for record in self: + cost = 0.0 + budgeted_hour_week = 0.0 + for rec in record.sale_line_employee_ids: + cost = cost + rec.cost + budgeted_hour_week = budgeted_hour_week + rec.budgeted_hour_week if record.project_type == 'hours_in_consultant': - val = 0.0 - cost = 0.0 - budgeted_hour_week = 0.0 - for rec in record.sale_line_employee_ids: - val = val + rec.budgeted_qty - cost = cost + rec.cost - budgeted_hour_week = budgeted_hour_week + rec.budgeted_hour_week - record.budgeted_hours = val record.cost = cost - if val > 0.0: - record.hourly_rate = (cost/val) - record.budgeted_hour_week = budgeted_hour_week + record.budgeted_hour_week = budgeted_hour_week + + # @api.depends('pricing_type') + def _compute_consultant_timesheet_hour(self): + for val in self: + val.consultant_timesheet_hrs = False + if val._origin.id: + self._cr.execute('''SELECT project_id, employee_id, SUM(unit_amount) FROM account_analytic_line where project_id = 149 + GROUP BY project_id, employee_id''') + res = self._cr.fetchone() + if res: + create_timesheet = val.consultant_timesheet_hrs.create({'project_id': res[0], + 'employee_id': res[1], + 'timesheet_hour': res[2]}) + else: + val.consultant_timesheet_hrs = False + # val.consultant_timesheet_hrs = False + + def action_view_custom_project_consultant_hrs_report(self): + action = self.env["ir.actions.actions"]._for_xml_id("cor_custom.action_project_consultant_hrs_report") + action['context'] = {'search_default_project_id': self.id, 'search_default_project': 1, + 'search_default_consultant': 1, 'search_default_group_by_hours_type': 1, + 'default_res_model': 'project.consultant.hrs.report'} + return action + +class ProjectConsultantTimesheetHrs(models.Model): + _name = 'consultant.timesheet.hrs' + _description = 'Project Consultant Timesheet Hrs' + + project_id = fields.Many2one('project.project', "Project") + employee_id = fields.Many2one('hr.employee', string="Consultant") + employee_price = fields.Float(string="Consultant Price", default=0.0) + timesheet_hour = fields.Float("Timesheet Hour", default=0.0) + consultant_cost = fields.Float("Actual Cost", compute='_compute_consultant_timesheet_cost', default=0.0) + + @api.onchange('employee_id') + def onchange_employee_price(self): + if self.employee_id: + self.employee_price = self.employee_id.timesheet_cost + else: + self.employee_price = 0.0 + + def _compute_consultant_timesheet_cost(self): + for val in self: + # if val.employee_id.timesheet_cost: + # val.employee_price = val.employee_id.timesheet_cost + # else: + # val.employee_price = 0.0 + if val.timesheet_hour and val.employee_id.timesheet_cost: + val.consultant_cost = val.timesheet_hour * val.employee_id.timesheet_cost + else: + val.consultant_cost = 0.0 class InheritProjectProductEmployeeMap(models.Model): _inherit = 'project.sale.line.employee.map' - employee_price = fields.Monetary(string="Consultant Price", related="employee_id.timesheet_cost", readonly=True) - budgeted_qty = fields.Float(string='Budgeted Hours', store=True) + budgeted_qty = fields.Float(string='Budgeted Hours', digits=(16, 2)) budgeted_uom = fields.Many2one('uom.uom', string='Budgeted UOM', related='sale_line_id.product_uom', readonly=True) # budgeted_uom = fields.Many2one('uom.uom', string='Budgeted UOM', related='timesheet_product_id.uom_id', readonly=True) timesheet_hour = fields.Float("Timesheet Hour", compute='_compute_timesheet_hour', default=0.0) + employee_price = fields.Monetary(string="Consultant Price") budgeted_hour_week = fields.Float("Budgeted Hours per week", compute='_compute_budgeted_hour_week') - price_unit = fields.Float("Hourly rate") + price_unit = fields.Float("Hourly Rate") currency_id = fields.Many2one('res.currency', string="Currency", compute='_compute_price_unit', store=True, readonly=False) sale_line_id = fields.Many2one('sale.order.line', "Service", domain=[('is_service', '=', True)]) - cost = fields.Float("Cost", compute='_compute_total_cost') + cost = fields.Float("Cost", default=0.0, store=True) + consultant_cost = fields.Float("Actual Cost", compute='_compute_timesheet_hour', default=0.0) + actual_revenue = fields.Float("Actual Revenue", compute='_compute_timesheet_hour', default=0.0) + + hour_distribution = fields.Selection(related='project_id.hour_distribution') + role = fields.Selection([('Manager', 'Manager'), + ('Employee', 'Employee'), ], string="Role", default="Employee") + distribution_per = fields.Float("%") + + @api.onchange('employee_id') + def onchange_employee_price(self): + if self.employee_id: + self.employee_price = self.employee_id.timesheet_cost + else: + self.employee_price = 0.0 def _compute_timesheet_hour(self): for val in self: - self._cr.execute('''SELECT project_id, employee_id, SUM(unit_amount) FROM account_analytic_line + self._cr.execute('''SELECT project_id, employee_id, SUM(unit_amount) FROM account_analytic_line where project_id = %(project_id)s and employee_id = %(employee_id)s GROUP BY project_id, employee_id''', {'project_id': val.project_id._origin.id, 'employee_id': val.employee_id.id, }) @@ -109,6 +272,8 @@ class InheritProjectProductEmployeeMap(models.Model): val.timesheet_hour = res[2] else: val.timesheet_hour = 0.0 + val.consultant_cost = val.timesheet_hour * val.employee_price + val.actual_revenue = val.timesheet_hour * val.price_unit def _compute_budgeted_hour_week(self): for val in self: @@ -117,9 +282,28 @@ class InheritProjectProductEmployeeMap(models.Model): else: val.budgeted_hour_week = 0 + @api.onchange('project_id.budgeted_revenue', 'price_unit', 'distribution_per', 'employee_id', 'role') def _compute_total_cost(self): for val in self: - val.cost = val.budgeted_qty * val.price_unit + if val.project_id.project_type == 'hours_in_consultant': + if val.hour_distribution == 'Percentage': + if val.role == 'Manager': + val.cost = val.project_id.budgeted_revenue * (val.project_id.manager_per / 100) * ( + val.distribution_per / 100) + else: + val.cost = val.project_id.budgeted_revenue * (val.project_id.employee_per / 100) * ( + val.distribution_per / 100) + if val.price_unit > 0.0: + val.budgeted_qty = val.cost / val.price_unit + # val.cost = val.budgeted_qty * val.price_unit + # val.consultant_cost = val.timesheet_hour * val.employee_price + # self.project_id.onchange_budgeted_hour() + + @api.onchange('price_unit', 'budgeted_qty') + def _calculate_total_cost(self): + if self.project_id.project_type == 'hours_in_consultant': + if self.hour_distribution == 'Manual': + self.cost = self.price_unit * self.budgeted_qty @api.depends('sale_line_id', 'sale_line_id.price_unit', 'timesheet_product_id') def _compute_price_unit(self): @@ -131,5 +315,30 @@ class InheritProjectProductEmployeeMap(models.Model): line.price_unit = line.timesheet_product_id.lst_price line.currency_id = line.timesheet_product_id.currency_id else: - #line.price_unit = 0 + # line.price_unit = 0 line.currency_id = False + + +class CustomProjectTags(models.Model): + """ Tags of project's tasks """ + _name = "custom.project.tags" + _description = "Project Tag" + + name = fields.Char('Name', required=True) + + _sql_constraints = [ + ('name_uniq', 'unique (name)', "Tag name already exists!"), + ] + + +class InheritProjectTask(models.Model): + _inherit = 'project.task' + + start_date = fields.Date(string='Start Date') + + def action_done_task(self): + stage_id = self.env['project.task.type'].search([('is_closed', '=', True)], limit=1) + for stage in self: + stage.write({'stage_id': stage_id.id, + 'active': False}) + diff --git a/cor_custom/models/project_hours.py b/cor_custom/models/project_hours.py new file mode 100755 index 0000000..62ea634 --- /dev/null +++ b/cor_custom/models/project_hours.py @@ -0,0 +1,136 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError, AccessError, ValidationError +from dateutil.relativedelta import relativedelta + +#class Project(models.Model): + #_inherit = 'project.project' + + #project_cons_hrs = fields.One2many('project.consultant.hrs', 'project_id', 'Project Hours') + +class ProjectConsultantHrs(models.Model): + _name = 'project.consultant.hrs' + _description = 'Project Consultant Hours' + _order = 'employee_id, end_date desc' + + project_id = fields.Many2one('project.project', string="Project", required=True) + employee_id = fields.Many2one('hr.employee', string="Consultant", required=True) + start_date = fields.Date('Start Date', required=True) + end_date = fields.Date('End Date', required=True) + percentage = fields.Float("Budgeted Percentage (%)") + budgeted_hours = fields.Float("Budgeted Hours for period", compute='_compute_budgeted_hours', store=True) + actual_percentage = fields.Float("Actual Percentage (%)", compute='_compute_actual_calc', store=True) + actual_hours = fields.Float("Actual Hours for period", compute='_compute_actual_calc', store=True) + #hours_type = fields.Selection([('Budgeted Hours', 'Budgeted Hours'), ('Actual Hours', 'Actual Hours')], default="Budgeted Hours", string="Type") + + @api.constrains('start_date', 'end_date') + def _check_dates(self): + for rec in self: + if rec.end_date < rec.start_date: + raise ValidationError(_('Start date must be earlier than end date.')) + + @api.constrains('employee_id', 'percentage') + def _check_percent(self): + for val in self: + if val.employee_id and val.percentage: + rec = val.search([('employee_id','=',val.employee_id.id),('project_id','=',val.project_id.id)]) + per = [r.percentage for r in rec] + if sum(per) > 100: + raise ValidationError(_('Consultant total percentage should not be greater than 100')) + if (val.percentage <= 0.0 or val.percentage > 100.0): + raise ValidationError(_('Percentage must be between 1 and 100.')) + + @api.constrains('start_date', 'end_date') + def _check_date(self): + for val in self: + #if 'project_id' in self.env.context: + if not val.project_id.date_start or not val.project_id.date: + raise UserError(_('Project start date and end date should not be blank')) + if val.project_id.date_start and val.start_date: + if not (val.project_id.date_start <= val.start_date <= val.project_id.date): + raise ValidationError("Start date should be between project start date and End Date") + if val.end_date: + if not (val.project_id.date_start <= val.end_date <= val.project_id.date): + raise ValidationError("End date should be between project start date and End Date") + domain = [ + ('start_date', '<=', val.end_date), + ('end_date', '>=', val.start_date), + ('project_id', '=', val.project_id.id), + ('employee_id', '=', val.employee_id.id), + ('id', '!=', val.id) + ] + res = self.search_count(domain) + if res > 0: + raise ValidationError(_('Same Consultant can not have 2 same date that overlaps on same day!')) + + @api.depends('project_id', 'employee_id', 'project_id.sale_line_employee_ids', 'percentage') + def _compute_budgeted_hours(self): + for val in self: + if val.project_id and val.employee_id and val.percentage > 0: + budgeted = 0 + for emp in val.project_id.sale_line_employee_ids: + if emp.employee_id.id == val.employee_id.id: + budgeted = emp.budgeted_qty + val.budgeted_hours = (budgeted * val.percentage / 100) + else: + val.budgeted_hours = 0 + + @api.depends('project_id', 'start_date', 'end_date', 'project_id.timesheet_ids', 'project_id.timesheet_ids.date', 'project_id.timesheet_ids.project_id', + 'project_id.timesheet_ids.employee_id', 'project_id.timesheet_ids.start_time', 'project_id.timesheet_ids.end_time', 'project_id.timesheet_ids.unit_amount') + def _compute_actual_calc(self): + Timesheet = self.env['account.analytic.line'] + for val in self: + if val.project_id and val.employee_id and val.start_date and val.end_date: + domain = [ + ('project_id','=',val.project_id.id), + ('employee_id', '=', val.employee_id.id), + ('start_datetime', '>=', val.start_date), + ('end_datetime', '<=', val.end_date) + ] + timesheets = Timesheet.search(domain) + val.actual_hours = sum(timesheet.unit_amount for timesheet in timesheets) + #budgeted = 0 + #for emp in val.project_id.sale_line_employee_ids: + # if emp.employee_id.id == val.employee_id.id: + # budgeted = emp.budgeted_qty + res = sum(timesheet.unit_amount for timesheet in timesheets) + val.actual_hours = res + #budgeted_period = (budgeted * val.percentage / 100) + if val.budgeted_hours > 0: + val.actual_percentage = (res/val.budgeted_hours) * 100 + else: + val.actual_percentage = 0 + else: + val.actual_hours = 0 + val.actual_percentage = 0 + + @api.onchange('start_date') + def onchange_start_date(self): + if self.start_date: + self.end_date = self.start_date + relativedelta(days=6) + + @api.onchange('employee_id') + def onchange_employee_id(self): + res = {} + emp_ids = [emp.employee_id.id for emp in self.project_id.sale_line_employee_ids] + res['domain'] = {'employee_id': [('id', 'in', emp_ids)]} + if 'project_id' in self.env.context: + #if not self.env.context['project_id']: + #raise UserError(_('Please save record before add a line')) + if not emp_ids: + raise UserError(_('Please add consultant at Invoicing tab before add a line')) + res['domain'] = {'employee_id': [('id','in',emp_ids)]} + return res + + """@api.onchange('employee_id', 'percentage') + def onchange_employee_id(self): + if self.employee_id and self.percentage > 0: + cons = [emp.budgeted_qty if emp.employee_id.id == self.employee_id.id else 0 for emp in self.project_id.sale_line_employee_ids] + budgeted = cons and cons[0] or 0 + if budgeted > 0: + #record.employee_per = 100 - record.manager_per + self.budgeted_hours = (budgeted * self.percentage / 100) + else: + self.budgeted_hours = 0""" + diff --git a/cor_custom/report/__init__.py b/cor_custom/report/__init__.py index 22d6147..be2239a 100755 --- a/cor_custom/report/__init__.py +++ b/cor_custom/report/__init__.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from . import project_profitability_report_analysis +#from . import project_profitability_report_analysis +from . import project_hours_report diff --git a/cor_custom/report/project_hours_report.py b/cor_custom/report/project_hours_report.py new file mode 100755 index 0000000..ca280e1 --- /dev/null +++ b/cor_custom/report/project_hours_report.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, tools + + +class BudgetHrsAnalysis(models.Model): + + _name = "project.consultant.hrs.report" + _description = "Project consultant hours analysis report" + _rec_name = "project_id" + #_order = 'project_id desc, hours_type asc, end_date desc, employee_id' + _auto = False + + project_id = fields.Many2one('project.project', string='Project', readonly=True) + employee_id = fields.Many2one('hr.employee', string='Consultant', readonly=True) + start_date = fields.Date(string='Start Date', readonly=True) + end_date = fields.Date(string='End Date', readonly=True) + hours_type = fields.Char(string="Hours Type", readonly=True) + hours = fields.Float("Hours", digits=(16, 2), readonly=True, group_operator="sum") + percentage = fields.Float("Percentage (%)") + #roworder = fields.Integer("Row Order") + #ptime = fields.Char(string="Time", readonly=True) + + @api.depends('project_id', 'employee_id') + def name_get(self): + res = [] + for record in self: + name = record.project_id and record.project_id.name or '' + if record.employee_id.name: + name = record.employee_id.name + '/' + name + '/' + record.hours_type + res.append((record.id, name)) + return res + + """def _generate_order_by(self, order_spec, query): + order_by = super(BudgetHrsAnalysis, self)._generate_order_by(order_spec, query) + print("order by>>>>>>>>>>>>>>", order_by) + return order_by""" + + def init(self): + '''Create the view''' + tools.drop_view_if_exists(self._cr, self._table) + self._cr.execute(""" + CREATE OR REPLACE VIEW %s AS ( + SELECT ROW_NUMBER() OVER() as id, project_id, start_date, end_date, employee_id, hours_type, hours, percentage + from ( + select project_id as project_id, start_date as start_date, end_date as end_date, + employee_id as employee_id, 'Budgeted Hours for period' as hours_type, budgeted_hours as hours, + percentage as percentage + from project_consultant_hrs ph + union + select project_id as project_id, start_date as start_date, end_date as end_date, + employee_id as employee_id, 'Actual Hours for period' as hours_type, actual_hours as hours, + actual_percentage as percentage + from project_consultant_hrs ph + ) as res order by + project_id desc, + hours_type, + employee_id, end_date, start_date + )""" % (self._table,)) + + diff --git a/cor_custom/report/project_hours_report_view.xml b/cor_custom/report/project_hours_report_view.xml new file mode 100755 index 0000000..8aeb24a --- /dev/null +++ b/cor_custom/report/project_hours_report_view.xml @@ -0,0 +1,121 @@ + + + + + project.consultant.hrs.report.form + project.consultant.hrs.report + +
+ + + + + + + + + +
+
+
+ + + project.consultant.hrs.report.tree + project.consultant.hrs.report + + + + + + + + + + + + + + + project.consultant.hrs.report.search + project.consultant.hrs.report + + + + + + + + + + + + + + + + + + project.consultant.hrs.report.graph + project.consultant.hrs.report + + + + + + + + + + project.consultant.hrs.report.calendar + project.consultant.hrs.report + + + + + + + + + + + + + + + Consultant Allocation + ir.actions.act_window + project.consultant.hrs.report + tree,calendar,graph + + { + 'search_default_project': 1, + 'search_default_consultant': 1, + 'search_default_group_by_hours_type': 1, + 'default_res_model': 'project.consultant.hrs.report' + } + + + + + + + Project Consul Report Hours + project.project + + +
+ +
+
+
+ +
diff --git a/cor_custom/security/cor_custom_security.xml b/cor_custom/security/cor_custom_security.xml new file mode 100755 index 0000000..8ecd9a9 --- /dev/null +++ b/cor_custom/security/cor_custom_security.xml @@ -0,0 +1,148 @@ + + + + + + + + + + + + + + + + + + + + + + account.analytic.line.timesheet.user + + [ + ('user_id', '=', user.id), + ('project_id', '!=', False), + '|', '|','|', + ('project_id.privacy_visibility', '!=', 'followers'), + ('project_id.allowed_internal_user_ids', 'in', user.ids), + ('task_id.allowed_user_ids', 'in', user.ids), + ('project_id.sale_line_employee_ids.employee_id.user_id', 'in', user.ids), + + ] + + + + + + + + + + + + + account.analytic.line.timesheet.approver + + [ + ('project_id', '!=', False), + '|','|', + ('project_id.privacy_visibility', '!=', 'followers'), + ('project_id.allowed_internal_user_ids', 'in', user.ids), + ('project_id.sale_line_employee_ids.employee_id.user_id', 'in', user.ids), + ] + + + + + + + + + Project: view Consultant: Own User + + [('employee_id.user_id', '=', user.id)] + + + + + Project: view Consultant: Manager User + + [(1,'=',1)] + + + + + + diff --git a/cor_custom/security/ir.model.access.csv b/cor_custom/security/ir.model.access.csv index fdc1832..24fb527 100755 --- a/cor_custom/security/ir.model.access.csv +++ b/cor_custom/security/ir.model.access.csv @@ -1,2 +1,13 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_cor_custom_cor_custom,cor_custom.cor_custom,model_cor_custom_cor_custom,base.group_user,1,1,1,1 \ No newline at end of file +access_consultant_timesheet_hrs_puser,consultant.timesheet.hrs,model_consultant_timesheet_hrs,project.group_project_user,1,1,1,0 +access_consultant_timesheet_hrs_pmanager,consultant.timesheet.hrs,model_consultant_timesheet_hrs,project.group_project_manager,1,1,1,1 +access_project_consultant_hrs_puser,project.consultant.hrs,model_project_consultant_hrs,project.group_project_user,1,0,0,0 +access_project_consultant_hrs_pmanager,project.consultant.hrs,model_project_consultant_hrs,project.group_project_manager,1,1,1,1 +access_model_project_multi_budget_assign_puser,project.multi.budget.assign,model_project_multi_budget_assign,project.group_project_user,1,0,0,0 +access_model_project_multi_budget_assign_pmanager,project.multi.budget.assign,model_project_multi_budget_assign,project.group_project_manager,1,1,1,1 +access_model_project_multi_budget_assign_line_puser,project.multi.budget.assign.line,model_project_multi_budget_assign_line,project.group_project_user,1,0,0,0 +access_model_project_multi_budget_assign_line_pmanager,project.multi.budget.assign.line,model_project_multi_budget_assign_line,project.group_project_manager,1,1,1,1 +access_project_consultant_hrs_report_puser,project.consultant.hrs.report,model_project_consultant_hrs_report,project.group_project_user,1,0,0,0 +access_project_consultant_hrs_report_pmanager,project.consultant.hrs.report,model_project_consultant_hrs_report,project.group_project_manager,1,1,1,1 +access_custom_project_tags_puser,custom.project.tags,model_custom_project_tags,project.group_project_user,1,1,1,1 + diff --git a/cor_custom/static/src/css/rtl_css_direction.css b/cor_custom/static/src/css/rtl_css_direction.css new file mode 100755 index 0000000..9a2b13a --- /dev/null +++ b/cor_custom/static/src/css/rtl_css_direction.css @@ -0,0 +1,17 @@ +[lang="he_IL"] .o_main_navbar{ +ul.o_menu_apps { + float:right; +} +.o_main_navbar > .o_menu_brand { + float: right; +} +ul.o_menu_sections { + float:right; +} +ul.o_menu_systray { + float:right; + margin-right: 540px; +} +.o_main_navbar > ul > li { + float: right;} + } diff --git a/cor_custom/views/analytic_view.xml b/cor_custom/views/analytic_view.xml index a03a447..8493301 100755 --- a/cor_custom/views/analytic_view.xml +++ b/cor_custom/views/analytic_view.xml @@ -15,28 +15,194 @@ --> - + + Start and End time task timesheets + project.task + + + + + + + + + + + + + + Analytic line + account.analytic.line + + + + + + + + + + + account.analytic.line.tree.hr_timesheet + account.analytic.line + + + + + + + + + + + + + + + + + + + + + + All Timesheets + account.analytic.line + tree,form,pivot,kanban,calendar + + [('project_id', '!=', False)] + { + 'search_default_week':1, + } + + +

+ No activities found. Let's start a new one! +

+

+ Track your working hours by projects every day and invoice this time to your customers. +

+
+
+ + + account.analytic.line.calendar + account.analytic.line + + + + + + + + + + + calendar + + + + + + + + My Timesheets + account.analytic.line + calendar,tree,form,kanban + [('project_id', '!=', False), ('user_id', '=', uid)] + { + "search_default_week":1, + } + + + +

+ No activities found. Let's start a new one! +

+

+ Track your working hours by projects every day and invoice this time to your customers. +

+
+
+ + + account.analytic.line.form + account.analytic.line + 1 + + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + + account.analytic.line.calendar + account.analytic.line + + + + + + + + + + + + + calendar + + + + diff --git a/cor_custom/views/assets.xml b/cor_custom/views/assets.xml new file mode 100755 index 0000000..41db65e --- /dev/null +++ b/cor_custom/views/assets.xml @@ -0,0 +1,9 @@ + + + + + \ No newline at end of file diff --git a/cor_custom/views/hr_employee_views.xml b/cor_custom/views/hr_employee_views.xml index 0c8f6e8..b20ad60 100755 --- a/cor_custom/views/hr_employee_views.xml +++ b/cor_custom/views/hr_employee_views.xml @@ -9,6 +9,9 @@ + + +
diff --git a/cor_custom/views/project_hours_view.xml b/cor_custom/views/project_hours_view.xml new file mode 100755 index 0000000..ff44311 --- /dev/null +++ b/cor_custom/views/project_hours_view.xml @@ -0,0 +1,83 @@ + + + + + project.consultant.hrs.form + project.consultant.hrs + +
+ + + + + + + + + + +
+
+
+ + + project.consultant.hrs.tree + project.consultant.hrs + + + + + + + + + + + + + + + + project.consultant.hrs.search + project.consultant.hrs + + + + + + + + + + + + + + + + Consultant Allocation + ir.actions.act_window + project.consultant.hrs + tree,form + + { + 'search_default_project': 1, + 'search_default_consultant': 1, + } + + + + + +
diff --git a/cor_custom/views/project_view.xml b/cor_custom/views/project_view.xml index 5835b26..c3a621f 100755 --- a/cor_custom/views/project_view.xml +++ b/cor_custom/views/project_view.xml @@ -6,17 +6,39 @@ project.project -
+ + + + + + + + + + + + + + + + + + + + + + @@ -27,6 +49,10 @@ project.project + +
diff --git a/project_maintenance/i18n/he_IL.po b/project_maintenance/i18n/he_IL.po old mode 100644 new mode 100755 diff --git a/project_report/__manifest__.py b/project_report/__manifest__.py index 544e76a..549e4b3 100755 --- a/project_report/__manifest__.py +++ b/project_report/__manifest__.py @@ -10,14 +10,19 @@ 'author': "SunArc Technologies", 'website': "http://www.sunarctechnologies.com", 'depends': [ - 'cor_custom' + 'cor_custom','sub_project' ], 'data': [ 'security/ir.model.access.csv', 'wizard/project_create_expenses_views.xml', - 'views/project_view.xml', + 'views/assets.xml', + #'views/project_view.xml', 'report/project_budget_hrs_analysis_views.xml', 'report/project_budget_amt_analysis_views.xml', + 'report/project_timeline_report_views.xml', + ], + 'qweb': [ + "static/src/xml/base.xml", ], 'auto_install': False, 'installable': True, diff --git a/project_report/report/__init__.py b/project_report/report/__init__.py index bda40bb..643d4d2 100755 --- a/project_report/report/__init__.py +++ b/project_report/report/__init__.py @@ -3,3 +3,4 @@ from . import project_budget_hrs_analysis from . import project_budget_amt_analysis +from . import project_timeline_report diff --git a/project_report/report/project_budget_amt_analysis.py b/project_report/report/project_budget_amt_analysis.py index 078e7f1..f5b2b4a 100755 --- a/project_report/report/project_budget_amt_analysis.py +++ b/project_report/report/project_budget_amt_analysis.py @@ -13,10 +13,24 @@ class BudgetAmtAnalysis(models.Model): #analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', readonly=True) project_id = fields.Many2one('project.project', string='Project', readonly=True) + parent_project = fields.Many2one('project.project', string='Parent Project', readonly=True) + #is_sub_project = fields.Boolean("Is Sub Project", readonly=True) + #sub_project = fields.Many2one('project.project', string='Sub Project', readonly=True) partner_id = fields.Many2one('res.partner', string='Client', readonly=True) amount_type = fields.Char(string="Amount Type") revenue = fields.Float("Revenue", digits=(16, 2), readonly=True, group_operator="sum") - #employee_id = fields.Many2one('hr.employee', string='Employee', readonly=True) + pricing_type = fields.Selection([ + ('fixed_rate', 'Fixed rate'), + ('employee_rate', 'Consultant rate') + ], string="Pricing", readonly=True) + project_type = fields.Selection([ + ('hours_in_consultant', 'Hours are budgeted according to a consultant'), + ('hours_no_limit', 'Total hours are budgeted without division to consultant'), + ], string="Project Type", readonly=True) + employee_id = fields.Many2one('hr.employee', string='Consultant', readonly=True) + start_date = fields.Date(string='Start Date', readonly=True) + end_date = fields.Date(string='End Date', readonly=True) + timesheet_date = fields.Date(string='Timesheet Date', readonly=True) #budgeted_revenue = fields.Float("Budgeted Revenue", digits=(16, 2), readonly=True, group_operator="sum") #actual_revenue = fields.Float("Actual Revenue", digits=(16, 2), readonly=True, group_operator="sum") @@ -25,35 +39,125 @@ class BudgetAmtAnalysis(models.Model): tools.drop_view_if_exists(self._cr, self._table) self._cr.execute(""" CREATE OR REPLACE VIEW %s AS ( + SELECT ROW_NUMBER() OVER() as id, project_id, parentproject as parent_project, start_date, end_date, timesheet_date, partner_id, employee_id, amount_type, pricing_type, project_type, revenue from ( + SELECT + pro.id AS project_id, + (select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject, + pro.date_start AS start_date, + pro.date AS end_date, + pro.partner_id AS partner_id, + pro_emp.employee_id AS employee_id, + 'Budgeted Revenue' as amount_type, + pro.pricing_type as pricing_type, + pro.project_type as project_type, + null::date AS timesheet_date, + pro_emp.cost AS revenue + FROM project_project pro + Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id + WHERE PRO.active = 't' and pro.pricing_type = 'employee_rate' and pro.project_type = 'hours_in_consultant' + union SELECT - row_number() OVER() AS id, - PRO.id AS project_id, - PRO.create_date AS create_date, - PRO.partner_id AS partner_id, - 'Budgeted Revenue' as amount_type, - PRO.budgeted_revenue AS revenue - --SO.amount_total AS actual_revenue - FROM project_project PRO - LEFT JOIN sale_order SO ON PRO.sale_order_id = SO.id - LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id - LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id - WHERE PRO.active = 't' and PRO.pricing_type!='fixed_rate' - group by Pro.id, PRO.partner_id, Pro.budgeted_revenue, so.amount_total - union - SELECT - row_number() OVER() AS id, - PRO.id AS project_id, - PRO.create_date AS create_date, - PRO.partner_id AS partner_id, - 'Actual Revenue' as amount_type, - SO.amount_total AS revenue - FROM project_project PRO - LEFT JOIN sale_order SO ON PRO.sale_order_id = SO.id - LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id - LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id - WHERE PRO.active = 't' and PRO.pricing_type!='fixed_rate' - group by Pro.id, PRO.partner_id, Pro.budgeted_revenue, so.amount_total - order by create_date desc, project_id, amount_type desc + pro.id AS project_id, + (select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject, + pro.date_start AS start_date, + pro.date AS end_date, + pro.partner_id AS partner_id, + AAL.employee_id AS employee_id, + 'Actual Revenue' as amount_type, + pro.pricing_type as pricing_type, + pro.project_type as project_type, + null::date AS timesheet_date, + (AAL.unit_amount * pro_emp.employee_price) AS revenue + --(AAL.unit_amount * pro_emp.price_unit) AS revenue + FROM project_project PRO + Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id + LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id + LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id and AAL.employee_id = pro_emp.employee_id + WHERE PRO.active = 't' and PRO.pricing_type = 'employee_rate' and PRO.project_type = 'hours_in_consultant' + union + SELECT + pro.id AS project_id, + (select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject, + pro.date_start AS start_date, + pro.date AS end_date, + pro.partner_id AS partner_id, + null::int AS employee_id, + 'Budgeted Revenue' as amount_type, + pro.pricing_type as pricing_type, + pro.project_type as project_type, + null::date AS timesheet_date, + pro.budgeted_revenue AS revenue + FROM project_project pro + --Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id + WHERE PRO.active = 't' and pro.pricing_type = 'employee_rate' and pro.project_type = 'hours_no_limit' + union + SELECT + pro.id AS project_id, + (select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject, + pro.date_start AS start_date, + pro.date AS end_date, + pro.partner_id AS partner_id, + null::int AS employee_id, + 'Actual Revenue' as amount_type, + pro.pricing_type as pricing_type, + pro.project_type as project_type, + null::date AS timesheet_date, + (pro.hourly_rate * pro.timesheet_hour) AS revenue + --(pro.hourly_rate * sum(AAL.unit_amount)) AS revenue + --(AAL.unit_amount) AS revenue + --(AAL.unit_amount * pro_emp.price_unit) AS revenue + FROM project_project PRO + --Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id + --LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id + --LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id + --and AAL.employee_id = pro_emp.employee_id + WHERE PRO.active = 't' and PRO.pricing_type = 'employee_rate' and PRO.project_type = 'hours_no_limit' + --group by pro.id + union + SELECT + pro.id AS project_id, + (select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject, + pro.date_start AS start_date, + pro.date AS end_date, + pro.partner_id AS partner_id, + AAL.employee_id AS employee_id, + 'Actual Cost' as amount_type, + pro.pricing_type as pricing_type, + pro.project_type as project_type, + AAL.date AS timesheet_date, + (AAL.unit_amount * pro_emp.price_unit) AS revenue + --(AAL.amount * -1) AS revenue + --(AAL.unit_amount * pro_emp.employee_price) AS revenue + FROM project_project PRO + Left JOIN project_sale_line_employee_map pro_emp ON pro_emp.project_id = pro.id + LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id + LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id and AAL.employee_id = pro_emp.employee_id + WHERE PRO.active = 't' and PRO.pricing_type = 'employee_rate' + --and PRO.project_type = 'hours_in_consultant' + union + SELECT + pro.id AS project_id, + (select project_id from project_subproject_rel as par where pro.id=par.id limit 1) as parentproject, + pro.date_start AS start_date, + pro.date AS end_date, + pro.partner_id AS partner_id, + AAL.employee_id AS employee_id, + 'Actual Cost' as amount_type, + pro.pricing_type as pricing_type, + pro.project_type as project_type, + AAL.date AS timesheet_date, + (AAL.amount * -1) AS revenue + --(AAL.unit_amount * pro_emp.employee_price) AS revenue + FROM project_project PRO + LEFT JOIN account_analytic_account AA ON PRO.analytic_account_id = AA.id + LEFT JOIN account_analytic_line AAL ON AAL.account_id = AA.id and AAL.project_id = PRO.id + --and AAL.employee_id = pro_emp.employee_id + WHERE PRO.active = 't' and PRO.pricing_type = 'fixed_rate') + as res + order by + project_id, + amount_type desc + --group by Pro.id, PRO.partner_id, Pro.budgeted_revenue, AAL.amount )""" % (self._table,)) diff --git a/project_report/report/project_budget_amt_analysis_views.xml b/project_report/report/project_budget_amt_analysis_views.xml index c5bde16..885ccf3 100755 --- a/project_report/report/project_budget_amt_analysis_views.xml +++ b/project_report/report/project_budget_amt_analysis_views.xml @@ -26,6 +26,24 @@
+ + project.budget.amt.report.tree + project.budget.amt.report + + + + + + + + + + + + + + + project.budget.amt.report.search @@ -34,12 +52,22 @@ - + + + + + + + + - + + + + + -
@@ -48,9 +76,9 @@ Projects Revenue Acutal Vs Budget project.budget.amt.report - pivot,graph + graph,tree,pivot - {'search_default_group_by_project': 1,'search_default_group_by_amount_type': 1} + {'search_default_group_by_project': 1,'search_default_group_by_amount_type': 1, 'default_res_model': 'project.budget.amt.report'} + + project.budget.hrs.report.tree + project.budget.hrs.report + + + + + + + + + + + + + + + project.budget.hrs.report.search @@ -34,10 +52,20 @@ + + + + + + + - + + + + @@ -48,9 +76,9 @@ Projects Hours Acutal Vs Budget project.budget.hrs.report - pivot,graph + graph,tree,pivot - {'search_default_group_by_project': 1,'search_default_group_by_hours_type': 1} + {'search_default_group_by_project': 1,'search_default_sdate': 1,'search_default_edate': 1,'search_default_group_by_hours_type': 1, 'default_res_model': 'project.budget.hrs.report'} + + + + project.timeline.report.pivot + project.timeline.report + + + + + + + + + + + project.timeline.report.graph + project.timeline.report + + + + + + + + + + + project.timeline.report.tree + project.timeline.report + + + + + + + + + + + + + + + + + + project.timeline.report.search + project.timeline.report + + + + + + + + + + + + + + + + + + + + + + + + + + + Projects Timeline Acutal Vs Budget + project.timeline.report + graph,tree,pivot + + {'search_default_group_by_project': 1,'search_default_sdate': 1,'search_default_edate': 1,'search_default_group_by_timeline_type': 1, 'default_res_model':'project.timeline.report'} + + + + + diff --git a/project_report/rng/graph_view.rng b/project_report/rng/graph_view.rng new file mode 100755 index 0000000..250ae43 --- /dev/null +++ b/project_report/rng/graph_view.rng @@ -0,0 +1,40 @@ + + + + + + + + + + + bar + horizontalBar + pie + line + pivot + + + + + + + + + + + + + + + + + + + + + diff --git a/project_report/security/ir.model.access.csv b/project_report/security/ir.model.access.csv index a8965bb..f653d5a 100755 --- a/project_report/security/ir.model.access.csv +++ b/project_report/security/ir.model.access.csv @@ -5,3 +5,5 @@ access_project_create_expense_manager,access_project_create_expense_project_mana access_project_budget_hrs_report_user,project.budget.hrs.report.user,model_project_budget_hrs_report,project.group_project_user,1,0,0,0 access_project_budget_amt_report_user,project.budget.amt.report.user,model_project_budget_amt_report,project.group_project_user,1,0,0,0 access_project_create_expense_user,access_project_create_expense_project_user,model_project_create_expense,project.group_project_user,1,0,0,0 +access_project_timeline_report_manager,project.timeline.report,model_project_timeline_report,project.group_project_manager,1,1,1,1 +access_project_timeline_report_user,project.timeline.report,model_project_timeline_report,project.group_project_user,1,0,0,0 \ No newline at end of file diff --git a/project_report/static/src/js/graph_controller.js b/project_report/static/src/js/graph_controller.js new file mode 100755 index 0000000..0ce9dee --- /dev/null +++ b/project_report/static/src/js/graph_controller.js @@ -0,0 +1,67 @@ +odoo.define("project_report.GraphController", function(require) { + var GraphController = require("web.GraphController"); + + GraphController.include({ + + init: function(parent, model, renderer, params) { + this._super.apply(this, arguments); + }, + + /** + * Makes sure that the buttons in the control panel matches the current + * state (so, correct active buttons and stuff like that). + * + * @override + */ + updateButtons: function () { + if (!this.$buttons) { + return; + } + var state = this.model.get(); + this.$buttons.find('.o_graph_button').removeClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="' + state.mode + '"]') + .addClass('active'); + this.$buttons + .find('.o_graph_button[data-mode="stack"]') + .data('stacked', state.stacked) + .toggleClass('active', state.stacked) + .toggleClass('o_hidden', state.mode !== 'bar' && state.mode !== 'horizontalBar'); + this.$buttons + .find('.o_graph_button[data-order]') + .toggleClass('o_hidden', state.mode === 'pie' || !!Object.keys(state.timeRanges).length) + .filter('.o_graph_button[data-order="' + state.orderBy + '"]') + .toggleClass('active', !!state.orderBy); + + if (this.withButtons) { + this._attachDropdownComponents(); + } + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * Do what need to be done when a button from the control panel is clicked. + * + * @private + * @param {MouseEvent} ev + */ + _onButtonClick: function (ev) { + var $target = $(ev.target); + if ($target.hasClass('o_graph_button')) { + if (_.contains(['bar', 'horizontalBar', 'line', 'pie'], $target.data('mode'))) { + this.update({ mode: $target.data('mode') }); + } else if ($target.data('mode') === 'stack') { + this.update({ stacked: !$target.data('stacked') }); + } else if (['asc', 'desc'].includes($target.data('order'))) { + const order = $target.data('order'); + const state = this.model.get(); + this.update({ orderBy: state.orderBy === order ? false : order }); + } + } + }, + + }); +}); diff --git a/project_report/static/src/js/graph_renderer.js b/project_report/static/src/js/graph_renderer.js new file mode 100755 index 0000000..b363d65 --- /dev/null +++ b/project_report/static/src/js/graph_renderer.js @@ -0,0 +1,869 @@ +odoo.define("project_report.GraphRenderer", function(require) { +var GraphRenderer = require("web.GraphRenderer"); +var AbstractRenderer = require('web.AbstractRenderer'); +var config = require('web.config'); +var core = require('web.core'); +var dataComparisonUtils = require('web.dataComparisonUtils'); +var fieldUtils = require('web.field_utils'); + +var _t = core._t; +var DateClasses = dataComparisonUtils.DateClasses; +var qweb = core.qweb; + +var CHART_TYPES = ['pie', 'bar', 'horizontalBar', 'line']; +// #d62728 into #794dd6 +var COLORS = ["#1f77b4", "#ff7f0e", "#aec7e8", "#ffbb78", "#2ca02c", "#98df8a", "#794dd6", + "#ff9896", "#9467bd", "#c5b0d5", "#8c564b", "#c49c94", "#e377c2", "#f7b6d2", + "#7f7f7f", "#c7c7c7", "#bcbd22", "#dbdb8d", "#17becf", "#9edae5"]; +var COLOR_NB = COLORS.length; + +function hexToRGBA(hex, opacity) { + var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); + var rgb = result.slice(1, 4).map(function (n) { + return parseInt(n, 16); + }).join(','); + return 'rgba(' + rgb + ',' + opacity + ')'; +} + +// used to format values in tooltips and yAxes. +var FORMAT_OPTIONS = { + // allow to decide if utils.human_number should be used + humanReadable: function (value) { + return Math.abs(value) >= 1000; + }, + // with the choices below, 1236 is represented by 1.24k + minDigits: 1, + decimals: 2, + // avoid comma separators for thousands in numbers when human_number is used + formatterCallback: function (str) { + return str; + }, +}; + +var NO_DATA = [_t('No data')]; +NO_DATA.isNoData = true; + +// Hide top legend when too many items for device size +var MAX_LEGEND_LENGTH = 25 * (1 + config.device.size_class); + + + GraphRenderer.include({ + init: function(parent, state, params) { + this._super.apply(this, arguments); + this.context = state.context; + this.resModel = this.context && this.context.default_res_model; + //if (this.resModel == 'project.budget.hrs.report') { + // this.title = 'Time Line'; + // } + }, + + _formatValue: function (value) { + //this._super.apply(this, arguments); + var measureField = this.fields[this.state.measure]; + var formatter = fieldUtils.format.float; + var formatedValue = formatter(value, measureField, FORMAT_OPTIONS); + return formatedValue; + }, + /** + * Used any time we need a new color in our charts. + * + * @private + * @param {number} index + * @returns {string} a color in HEX format + */ + _getColor: function (index) { + return COLORS[index % COLOR_NB]; + }, + /** + * Determines the initial section of the labels array + * over a dataset has to be completed. The section only depends + * on the datasets origins. + * + * @private + * @param {number} originIndex + * @param {number} defaultLength + * @returns {number} + */ + _getDatasetDataLength: function (originIndex, defaultLength) { + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode) && this.state.comparisonFieldIndex === 0) { + return this.dateClasses.dateSets[originIndex].length; + } + return defaultLength; + }, + + /** + * Determines over which label is the data point + * + * @private + * @param {Object} dataPt + * @returns {Array} + */ + _getLabel: function (dataPt) { + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) { + if (i === 0) { + return [this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i])]; + } else { + return dataPt.labels.slice(0, 1); + } + } else if (i === 0) { + return Array.prototype.concat.apply([], [ + this.dateClasses.dateClass(dataPt.originIndex, dataPt.labels[i]), + dataPt.labels.slice(i+1) + ]); + } else { + return dataPt.labels; + } + }, + + /** + * Returns an object used to style chart elements independently from the datasets. + * + * @private + * @returns {Object} + */ + _getElementOptions: function () { + var elementOptions = {}; + if (this.state.mode === 'bar' || this.state.mode === 'horizontalBar') { + elementOptions.rectangle = {borderWidth: 1}; + } else if (this.state.mode === 'line') { + elementOptions.line = { + tension: 0, + fill: false, + }; + } + return elementOptions; + }, + + /** + * Determines to which dataset belong the data point + * + * @private + * @param {Object} dataPt + * @returns {string} + */ + _getDatasetLabel: function (dataPt) { + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) { + // ([origin] + second to last groupBys) or measure + var datasetLabel = dataPt.labels.slice(1).join("/"); + if (this.state.origins.length > 1) { + datasetLabel = this.state.origins[dataPt.originIndex] + + (datasetLabel ? ('/' + datasetLabel) : ''); + } + datasetLabel = datasetLabel || this.fields[this.state.measure].string; + return datasetLabel; + } + return this.state.origins[dataPt.originIndex]; + }, + + + _filterDataPoints: function () { + var dataPoints = []; + if (_.contains(['bar', 'horizontalBar', 'pie'], this.state.mode)) { + dataPoints = this.state.dataPoints.filter(function (dataPt) { + return dataPt.count > 0; + }); + } else if (this.state.mode === 'line') { + var counts = 0; + this.state.dataPoints.forEach(function (dataPt) { + if (dataPt.labels[0] !== _t("Undefined")) { + dataPoints.push(dataPt); + } + counts += dataPt.count; + }); + // data points with zero count might have been created on purpose + // we only remove them if there are no data point with positive count + if (counts === 0) { + dataPoints = []; + } + } + return dataPoints; + }, + + + /** + * Returns the options used to generate the chart legend. + * + * @private + * @param {Number} datasetsCount + * @returns {Object} + */ + _getLegendOptions: function (datasetsCount) { + var legendOptions = { + display: datasetsCount <= MAX_LEGEND_LENGTH, + // position: this.state.mode === 'pie' ? 'right' : 'top', + position: 'top', + onHover: this._onlegendTooltipHover.bind(this), + onLeave: this._onLegendTootipLeave.bind(this), + }; + var self = this; + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode)) { + var referenceColor; + if (this.state.mode === 'bar' || this.state.mode === 'horizontalBar') { + referenceColor = 'backgroundColor'; + } + else { + referenceColor = 'borderColor'; + } + legendOptions.labels = { + generateLabels: function (chart) { + var data = chart.data; + return data.datasets.map(function (dataset, i) { + var r = dataset[referenceColor]; + if ((dataset[referenceColor] instanceof Array) && (dataset[referenceColor].length>=1)) { + //var res = dataset[referenceColor].filter(item => item != 'red'); + var res = r.filter(item => item != 'red'); + if (res.length>=1) { + r = res[0] + } + /* else{ + dataset[referenceColor] = dataset[referenceColor][0] + } */ + } + var label_res = dataset.label; + if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) { + var measure_value = self.fields[self.state.measure].string.split(" ").splice(-1); + label_res = label_res.replace("Actual", "Actual " + measure_value).replace("Budgeted", "Budgeted " + measure_value); + } + return { + text: self._shortenLabel(label_res), + fullText: label_res, + fillStyle: r, + hidden: !chart.isDatasetVisible(i), + lineCap: dataset.borderCapStyle, + lineDash: dataset.borderDash, + lineDashOffset: dataset.borderDashOffset, + lineJoin: dataset.borderJoinStyle, + lineWidth: dataset.borderWidth, + strokeStyle: r, + pointStyle: dataset.pointStyle, + datasetIndex: i, + }; + }); + }, + }; + } else { + legendOptions.labels = { + generateLabels: function (chart) { + var data = chart.data; + var metaData = data.datasets.map(function (dataset, index) { + return chart.getDatasetMeta(index).data; + }); + return data.labels.map(function (label, i) { + var hidden = metaData.reduce( + function (hidden, data) { + if (data[i]) { + hidden = hidden || data[i].hidden; + } + return hidden; + }, + false + ); + var fullText = self._relabelling(label); + var text = self._shortenLabel(fullText); + return { + text: text, + fullText: fullText, + fillStyle: label.isNoData ? '#d3d3d3' : self._getColor(i), + hidden: hidden, + index: i, + }; + }); + }, + }; + } + return legendOptions; + }, + + /** + * Extracts the important information from a tooltipItem generated by Charts.js + * (a tooltip item corresponds to a line (different from measure name) of a tooltip) + * + * @private + * @param {Object} item + * @param {Object} data + * @returns {Object} + */ + _getTooltipItemContent: function (item, data) { + var dataset = data.datasets[item.datasetIndex]; + var label = data.labels[item.index]; + var value; + var boxColor; + if (this.state.mode === 'bar') { + label = this._relabelling(label, dataset.originIndex); + if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + value = this._formatValue(item.yLabel); + boxColor = dataset.backgroundColor; + var r = dataset.backgroundColor; + if ((dataset.backgroundColor instanceof Array) && (dataset.backgroundColor.length>=1)) { + boxColor = dataset.backgroundColor[item.index] + } + } else if (this.state.mode === 'horizontalBar') { + label = this._relabelling(label, dataset.originIndex); + if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + value = this._formatValue(item.xLabel); + boxColor = dataset.backgroundColor; + if ((dataset.backgroundColor instanceof Array) && (dataset.backgroundColor.length>=1)) { + boxColor = dataset.backgroundColor[item.index] + } + } + else if (this.state.mode === 'line') { + label = this._relabelling(label, dataset.originIndex); + if (this.state.processedGroupBy.length > 1 || this.state.origins.length > 1) { + label = label + "/" + dataset.label; + } + value = this._formatValue(item.yLabel); + boxColor = dataset.borderColor; + } else { + if (label.isNoData) { + value = this._formatValue(0); + } else { + value = this._formatValue(dataset.data[item.index]); + } + label = this._relabelling(label, dataset.originIndex); + if (this.state.origins.length > 1) { + label = dataset.label + "/" + label; + } + boxColor = dataset.backgroundColor[item.index]; + } + return { + label: label, + value: value, + boxColor: boxColor, + }; + }, + /** + * This function extracts the information from the data points in tooltipModel.dataPoints + * (corresponding to datapoints over a given label determined by the mouse position) + * that will be displayed in a custom tooltip. + * + * @private + * @param {Object} tooltipModel see chartjs documentation + * @return {Object[]} + */ + _getTooltipItems: function (tooltipModel) { + var self = this; + var data = this.chart.config.data; + var orderedItems = tooltipModel.dataPoints.sort(function (dPt1, dPt2) { + return dPt2.yLabel - dPt1.yLabel; + }); + return orderedItems.reduce( + function (acc, item) { + if (item.value > 0) { + acc.push(self._getTooltipItemContent(item, data)); + } + return acc; + }, + [] + ); + }, + + _isRedirectionEnabled: function () { + return !this.disableLinking && + (this.state.mode === 'bar' || this.state.mode === 'horizontalBar' || this.state.mode === 'pie'); + }, + + /** + * Determine how to relabel a label according to a given origin. + * The idea is that the getLabel function is in general not invertible but + * it is when restricted to the set of dataPoints coming from a same origin. + + * @private + * @param {Array} label + * @param {Array} originIndex + * @returns {string} + */ + _relabelling: function (label, originIndex) { + if (label.isNoData) { + return label[0]; + } + var i = this.state.comparisonFieldIndex; + if (_.contains(['bar', 'horizontalBar', 'line'], this.state.mode) && i === 0) { + // here label is an array of length 1 and contains a number + return this.dateClasses.representative(label, originIndex) || ''; + } else if (this.state.mode === 'pie' && i === 0) { + // here label is an array of length at least one containing string or numbers + var labelCopy = label.slice(0); + if (originIndex !== undefined) { + labelCopy.splice(i, 1, this.dateClasses.representative(label[i], originIndex)); + } else { + labelCopy.splice(i, 1, this.dateClasses.dateClassMembers(label[i])); + } + return labelCopy.join('/'); + } + // here label is an array containing strings or numbers. + return label.join('/') || _t('Total'); + }, + + async _renderView() { + if (this.chart) { + this.chart.destroy(); + } + this.$el.empty(); + if (!_.contains(CHART_TYPES, this.state.mode)) { + this.trigger_up('warning', { + title: _t('Invalid mode for chart'), + message: _t('Cannot render chart with mode : ') + this.state.mode + }); + } + var dataPoints = this._filterDataPoints(); + dataPoints = this._sortDataPoints(dataPoints); + if (this.isInDOM) { + this._renderTitle(); + + // detect if some pathologies are still present after the filtering + if (this.state.mode === 'pie') { + const someNegative = dataPoints.some(dataPt => dataPt.value < 0); + const somePositive = dataPoints.some(dataPt => dataPt.value > 0); + if (someNegative && somePositive) { + const context = { + title: _t("Invalid data"), + description: [ + _t("Pie chart cannot mix positive and negative numbers. "), + _t("Try to change your domain to only display positive results") + ].join("") + }; + this._renderNoContentHelper(context); + return; + } + } + + if (this.state.isSample && !this.isEmbedded) { + this._renderNoContentHelper(); + } + + // only render the graph if the widget is already in the DOM (this + // happens typically after an update), otherwise, it will be + // rendered when the widget will be attached to the DOM (see + // 'on_attach_callback') + var $canvasContainer = $('
', {class: 'o_graph_canvas_container'}); + var $canvas = $('').attr('id', this.chartId); + $canvasContainer.append($canvas); + this.$el.append($canvasContainer); + + var i = this.state.comparisonFieldIndex; + if (i === 0) { + this.dateClasses = this._getDateClasses(dataPoints); + } + if (this.state.mode === 'bar') { + this._renderBarChart(dataPoints); + } else if (this.state.mode === 'horizontalBar') { + this._renderhorizontalBarChart(dataPoints); + } + else if (this.state.mode === 'line') { + this._renderLineChart(dataPoints); + } else if (this.state.mode === 'pie') { + this._renderPieChart(dataPoints); + } + } + }, + + _getScaleHBarOptions: function () { + var self = this; + if (_.contains(['horizontalBar'], this.state.mode)) { + return { + xAxes: [{ + type: 'linear', + scaleLabel: { + display: !this.isEmbedded, + labelString: this.fields[this.state.measure].string, + }, + ticks: { + display: false, + callback: this._formatValue.bind(this), + suggestedMax: 0, + suggestedMin: 0, + } + }], + yAxes: [{ + type: 'category', + scaleLabel: { + display: this.state.processedGroupBy.length && !this.isEmbedded, + labelString: this.state.processedGroupBy.length ? + this.fields[this.state.processedGroupBy[0].split(':')[0]].string : '', + }, + ticks: { + // don't use bind: callback is called with 'index' as second parameter + // with value labels.indexOf(label)! + callback: function (label) { + return self._relabelling(label); + }, + }, + }], + }; + } + return {}; + }, + + _animationOptions: function () { + var animationOptions = {}; + var GraphVal = this; + if (this.state.mode === 'bar') { // && this.resModel == 'project.budget.hrs.report' + var animationOptions = { + duration: "1", + "onComplete": function() { + var chartInstance = this.chart, + ctx = chartInstance.ctx; + ctx.font = Chart.helpers.fontString(12, 'bold', "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"); + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + this.data.datasets.forEach(function(dataset, i) { + var meta = chartInstance.controller.getDatasetMeta(i); + meta.data.forEach(function(bar, index) { + var data = dataset.data[index]; + var value; + if(!!data && chartInstance.isDatasetVisible(i)){ + value = GraphVal._formatValue(data); + ctx.fillText(value, bar._model.x, bar._model.y - 5); + } + }); + }); + } + }; + } + return animationOptions; + }, + + _animation_HBar_Options: function () { + var animationHBarOptions = {}; + var GraphVal = this; + if (this.state.mode === 'horizontalBar') { // && this.resModel == 'project.budget.hrs.report' + var animationHBarOptions = { + duration: "1", + "onComplete": function() { + var chartInstance = this.chart, + ctx = chartInstance.ctx; + ctx.font = Chart.helpers.fontString(12, 'bold', "'Helvetica Neue', 'Helvetica', 'Arial', sans-serif"); + ctx.textAlign = 'left'; + ctx.textBaseline = 'bottom'; + this.data.datasets.forEach(function(dataset, i) { + var meta = chartInstance.controller.getDatasetMeta(i); + meta.data.forEach(function(bar, index) { + var data = dataset.data[index]; + //var barWidth = bar._model.x - bar._model.base; + //var centerX = bar._model.base + barWidth / 2; + //console.log("data", JSON.stringify(data), chartInstance.isDatasetVisible(i), !chartInstance.isDatasetVisible(i)) + var value; + if(!!data && chartInstance.isDatasetVisible(i)){ + value = GraphVal._formatValue(data); + //ctx.fillStyle = '#FFF'; + ctx.fillText(value, bar._model.x, bar._model.y + 5); + //ctx.fillText(value, centerX, bar._model.y + 4); + } + }); + }); + } + }; + } + return animationHBarOptions; + }, + + _prepareOptions: function (datasetsCount) { + const options = { + maintainAspectRatio: false, + scales: this._getScaleOptions(), + legend: this._getLegendOptions(datasetsCount), + tooltips: this._getTooltipOptions(), + elements: this._getElementOptions(), + animation: this._animationOptions(), + }; + if (this._isRedirectionEnabled()) { + options.onClick = this._onGraphClicked.bind(this); + } + return options; + }, + + _renderBarChart: function (dataPoints) { + var self = this; + + // prepare data + //console.log("datappppppppppp", dataPoints); + var data = this._prepareData(dataPoints); + + // this.title = 'Time Line'; + if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) { + var groupedData = _.groupBy(data.datasets, x => x.label.replace('Actual', '').replace('Budgeted', '').split("/")[0]); + //var groupedData = _.groupBy(data.datasets, x => x.label.replace('Actual Hours', '').replace('Budgeted Hours', '')); + _.map(groupedData, (y) => { + if (y.length > 1) { + var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]); + var maxA = zipped_1.map((a) => { + if ((!!a[0] && !!a[1]) && a[0] > a[1]) { + return 'red'; + } else if ((!!a[0] && !!a[1]) && a[0] < a[1]) { + return self._getColor(0); + } + else { + return self._getColor(0); + } + }); + y[0].backgroundColor = maxA; + y[1].backgroundColor = self._getColor(1); + //var zipped_2 = _.zip.apply(null, [y[1].data, y[0].data]); + /*var maxB = zipped_2.map((a) => { + if ((!!a[0] && !!a[1]) && a[0] > a[1]) { + return self._getColor(1); + } else if ((!!a[0] && !!a[1]) && a[0] < a[1]) { + return 'red'; + } + else { + return self._getColor(1); + } + }); + y[1].backgroundColor = maxB;*/ + } + }); + } + if (self.resModel == 'project.budget.amt.report') { + var groupedData2 = _.groupBy(data.datasets, x => x.label.replace('Actual Cost', '').replace('Budgeted Revenue', '')); + _.map(groupedData2, (y) => { + if (y.length > 1) { + var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]); + var maxA = zipped_1.map((a) => { + if ((!!a[0] && !!a[1]) && a[0] > a[1]) { + return 'red'; + } else if ((!!a[0] && !!a[1]) && a[0] < a[1]) { + return self._getColor(0); + } + else { + return self._getColor(0); + } + }); + y[0].backgroundColor = maxA; + y[1].backgroundColor = self._getColor(1); + } + }); + } + if (self.resModel == 'project.consultant.hrs.report') { + var groupedData3 = _.groupBy(data.datasets, x => x.label.replace('Actual Hours for period', '').replace('Budgeted Hours for period', '')); + _.map(groupedData3, (y) => { + if (y.length > 1) { + var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]); + var maxA = zipped_1.map((a) => { + if ((!!a[0] && !!a[1]) && a[0] > a[1]) { + return 'red'; + } else if ((!!a[0] && !!a[1]) && a[0] < a[1]) { + return self._getColor(0); + } + else { + return self._getColor(0); + } + }); + y[0].backgroundColor = maxA; + y[1].backgroundColor = self._getColor(1); + } + }); + } + data.datasets.forEach(function (dataset, index) { + // used when stacked + dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; + // set dataset color + /* if (self.state.stacked && dataset.label.includes("Actual")) { + dataset.stack = 1; + }*/ + if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) { + //if (dataset.label.indexOf("Actual Hours") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Hours") === -1) { + if (dataset.label.indexOf("Actual") === -1 && dataset.label.toLowerCase().indexOf("Budgeted") === -1) { + var color = self._getColor(index); + dataset.backgroundColor = color; + } + } + else if (self.resModel == 'project.budget.amt.report') { + if (dataset.label.indexOf("Actual Cost") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Revenue") === -1) { + var color = self._getColor(index); + dataset.backgroundColor = color; + } + } + else if (self.resModel == 'project.consultant.hrs.report') { + if (dataset.label.indexOf("Actual Hours for period") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Hours for period") === -1) { + var color = self._getColor(index); + dataset.backgroundColor = color; + } + } + else { + var color = self._getColor(index); + dataset.backgroundColor = color; + } + }); + // prepare options + var options = this._prepareOptions(data.datasets.length); + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'bar', + data: data, + options: options, + }); + }, + + _prepare_HBar_Options: function (datasetsCount) { + const options = { + maintainAspectRatio: false, + scales: this._getScaleHBarOptions(), + legend: this._getLegendOptions(datasetsCount), + tooltips: this._getTooltipOptions(), + elements: this._getElementOptions(), + animation: this._animation_HBar_Options(), + }; + if (this._isRedirectionEnabled()) { + options.onClick = this._onGraphClicked.bind(this); + } + return options; + }, + + + _renderhorizontalBarChart: function (dataPoints) { + var self = this; + + // prepare data + var data = this._prepareData(dataPoints); + + //console.log("datappppppppppp", dataPoints); + + if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) { + //var groupedData = _.groupBy(data.datasets, x => x.label.replace('Actual Hours', '').replace('Budgeted Hours', '').split("/")[0]); + var groupedData = _.groupBy(data.datasets, x => x.label.replace('Actual', '').replace('Budgeted', '').split("/")[0]); + _.map(groupedData, (y) => { + if (y.length > 1) { + var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]); + var maxA = zipped_1.map((a) => { + if ((!!a[0] && !!a[1]) && a[0] > a[1]) { + return 'red'; + } else if ((!!a[0] && !!a[1]) && a[0] < a[1]) { + return self._getColor(0); + } + else { + return self._getColor(0); + } + }); + y[0].backgroundColor = maxA; + y[1].backgroundColor = self._getColor(1); + var zipped_2 = _.zip.apply(null, [y[1].data, y[0].data]); + } + }); + } + + if (self.resModel == 'project.budget.amt.report') { + var groupedData2 = _.groupBy(data.datasets, x => x.label.replace('Actual Cost', '').replace('Budgeted Revenue', '')); + _.map(groupedData2, (y) => { + if (y.length > 1) { + var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]); + var maxA = zipped_1.map((a) => { + if ((!!a[0] && !!a[1]) && a[0] > a[1]) { + return 'red'; + } else if ((!!a[0] && !!a[1]) && a[0] < a[1]) { + return self._getColor(0); + } + else { + return self._getColor(0); + } + }); + y[0].backgroundColor = maxA; + y[1].backgroundColor = self._getColor(1); + //var zipped_2 = _.zip.apply(null, [y[1].data, y[0].data]); + } + }); + } + + if (self.resModel == 'project.consultant.hrs.report') { + var groupedData3 = _.groupBy(data.datasets, x => x.label.replace('Actual Hours for period', '').replace('Budgeted Hours for period', '')); + _.map(groupedData3, (y) => { + if (y.length > 1) { + var zipped_1 = _.zip.apply(null, [y[0].data, y[1].data]); + var maxA = zipped_1.map((a) => { + if ((!!a[0] && !!a[1]) && a[0] > a[1]) { + return 'red'; + } else if ((!!a[0] && !!a[1]) && a[0] < a[1]) { + return self._getColor(0); + } + else { + return self._getColor(0); + } + }); + y[0].backgroundColor = maxA; + y[1].backgroundColor = self._getColor(1); + } + }); + } + + data.datasets.forEach(function (dataset, index) { + // used when stacked + dataset.stack = self.state.stacked ? self.state.origins[dataset.originIndex] : undefined; + // set dataset color + /*if (self.state.stacked && dataset.label.includes("Actual")) { + dataset.stack = 1; + }*/ + if ((self.resModel == 'project.budget.hrs.report') || (self.resModel == 'project.timeline.report')) { + if (dataset.label.indexOf("Actual") === -1 && dataset.label.toLowerCase().indexOf("Budgeted") === -1) { + var color = self._getColor(index); + dataset.backgroundColor = color; + } + } + else if (self.resModel == 'project.budget.amt.report') { + if (dataset.label.indexOf("Actual Cost") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Revenue") === -1) { + var color = self._getColor(index); + dataset.backgroundColor = color; + } + } + else if (self.resModel == 'project.consultant.hrs.report') { + if (dataset.label.indexOf("Actual Hours for period") === -1 && dataset.label.toLowerCase().indexOf("Budgeted Hours for period") === -1) { + var color = self._getColor(index); + dataset.backgroundColor = color; + } + } + else { + var color = self._getColor(index); + dataset.backgroundColor = color; + } + }); + + // prepare options + var options = this._prepare_HBar_Options(data.datasets.length); + // create chart + var ctx = document.getElementById(this.chartId); + this.chart = new Chart(ctx, { + type: 'horizontalBar', + data: data, + options: options, + }); + }, + /** + * Sort datapoints according to the current order (ASC or DESC). + * + * Note: this should be moved to the model at some point. + * + * @private + * @param {Object[]} dataPoints + * @returns {Object[]} sorted dataPoints if orderby set on state + */ + _sortDataPoints(dataPoints) { + if (!Object.keys(this.state.timeRanges).length && this.state.orderBy && + ['bar', 'horizontalBar', 'line'].includes(this.state.mode) && this.state.groupBy.length) { + // group data by their x-axis value, and then sort datapoints + // based on the sum of values by group in ascending/descending order + const groupByFieldName = this.state.groupBy[0].split(':')[0]; + const groupedByMany2One = this.fields[groupByFieldName].type === 'many2one'; + const groupedDataPoints = {}; + dataPoints.forEach(function (dataPoint) { + const key = groupedByMany2One ? dataPoint.resId : dataPoint.labels[0]; + groupedDataPoints[key] = groupedDataPoints[key] || []; + groupedDataPoints[key].push(dataPoint); + }); + dataPoints = _.sortBy(groupedDataPoints, function (group) { + return group.reduce((sum, dataPoint) => sum + dataPoint.value, 0); + }); + dataPoints = dataPoints.flat(); + if (this.state.orderBy === 'desc') { + dataPoints = dataPoints.reverse('value'); + } + } + return dataPoints; + }, + + + }); +}); diff --git a/project_report/static/src/xml/base.xml b/project_report/static/src/xml/base.xml new file mode 100755 index 0000000..3c9a7fa --- /dev/null +++ b/project_report/static/src/xml/base.xml @@ -0,0 +1,21 @@ + + + + +