From 0a8f4b6cda06806f18781d173a53da4fd57ff12f Mon Sep 17 00:00:00 2001 From: fw-bot Date: Mon, 21 Dec 2020 18:14:47 +0000 Subject: [PATCH] Purpose ======= This PR is in the continuity of odoo#62360. - Display remaining hours in task sales_line_id (name_get + task form view) - Remove internal reference from services demo data - Display sol in project timesheets list if project is billable - Remove use of non_allow_billable in timesheets as it has been removed from project and task in previous PR (see above) - Only recompute planned_hours for service product - Determine the correct SOL for timesheet - Set the last SOL of customer on timesheet if none is set on task or project - Allow edition of so_line in timesheet - Restrict SOL on project to sale lines with a service product - Use same widget on partner_id many2One than in sale.order (using ranking) - Remove timesheets table in SO and invoice portal.They were added in previous PR (see above). This introduces the use of links to /my/timesheets/. - Review portal timesheets (my/timesheets/ and link from orders and invoices) - Activate group_uom "Units of Measure" on sale_timesheet install - Hide partner phone number in task form view - Only determine SOL of task and timesheet if allow_billable=True - Filter SOL in task so that it matches the SOL of the project SO - Display red label if remaining hours is negative - Change SO compute behavior - Only open SO for salesman on project overview task-2409761 -- I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr closes odoo/odoo#63695 Forward-port-of: #62900 X-original-commit: b1985773f3a67e28049f9e2bb635bcab059ad9c0 Related: odoo/enterprise#15427 Signed-off-by: Yannick Tivisse (yti) --- addons/event_sale/data/event_sale_data.xml | 1 - addons/event_sale/models/event_ticket.py | 2 - .../tests/tours/event_configurator_ui.js | 6 +- addons/hr_expense/data/hr_expense_data.xml | 1 - addons/hr_expense/data/hr_expense_demo.xml | 2 - addons/hr_timesheet/controllers/portal.py | 79 ++++--- .../views/hr_timesheet_portal_templates.xml | 100 ++++++--- addons/portal/views/portal_templates.xml | 2 +- addons/product/data/product_demo.xml | 2 - addons/project/views/project_views.xml | 6 +- addons/sale/data/sale_demo.xml | 1 - addons/sale_project/models/project.py | 8 +- addons/sale_project/models/sale_order.py | 2 +- .../sale_project/views/project_task_views.xml | 20 +- addons/sale_timesheet/__manifest__.py | 1 + addons/sale_timesheet/controllers/portal.py | 43 ++++ .../sale_timesheet/data/sale_service_data.xml | 7 + .../sale_timesheet/data/sale_service_demo.xml | 5 +- addons/sale_timesheet/models/account.py | 47 ++-- addons/sale_timesheet/models/project.py | 77 +++++-- addons/sale_timesheet/models/sale_order.py | 63 +++++- .../views/hr_timesheet_templates.xml | 9 +- .../views/hr_timesheet_views.xml | 2 +- .../views/project_task_views.xml | 49 +++- .../sale_timesheet/views/report_invoice.xml | 10 + .../views/sale_timesheet_portal_templates.xml | 211 +++++++++++------- addons/sale_timesheet_edit/__init__.py | 4 + addons/sale_timesheet_edit/__manifest__.py | 25 +++ addons/sale_timesheet_edit/models/__init__.py | 5 + .../models/account_analytic_line.py | 20 ++ addons/sale_timesheet_edit/models/project.py | 15 ++ .../static/src/js/so_line_one2many.js | 28 +++ addons/sale_timesheet_edit/views/assets.xml | 10 + .../views/project_task.xml | 41 ++++ 34 files changed, 678 insertions(+), 226 deletions(-) create mode 100644 addons/sale_timesheet/views/report_invoice.xml create mode 100644 addons/sale_timesheet_edit/__init__.py create mode 100644 addons/sale_timesheet_edit/__manifest__.py create mode 100644 addons/sale_timesheet_edit/models/__init__.py create mode 100644 addons/sale_timesheet_edit/models/account_analytic_line.py create mode 100644 addons/sale_timesheet_edit/models/project.py create mode 100644 addons/sale_timesheet_edit/static/src/js/so_line_one2many.js create mode 100644 addons/sale_timesheet_edit/views/assets.xml create mode 100644 addons/sale_timesheet_edit/views/project_task.xml diff --git a/addons/event_sale/data/event_sale_data.xml b/addons/event_sale/data/event_sale_data.xml index bf8bc78249e..725bc51f757 100644 --- a/addons/event_sale/data/event_sale_data.xml +++ b/addons/event_sale/data/event_sale_data.xml @@ -14,7 +14,6 @@ Event Registration - EVENT_REG service diff --git a/addons/event_sale/models/event_ticket.py b/addons/event_sale/models/event_ticket.py index 7759a547d84..da142f973c9 100644 --- a/addons/event_sale/models/event_ticket.py +++ b/addons/event_sale/models/event_ticket.py @@ -72,8 +72,6 @@ class EventTemplateTicket(models.Model): 'list_price': 0, 'standard_price': 0, 'type': 'service', - 'default_code': 'EVENT_REG', - 'type': 'service', }).id self.env['ir.model.data'].create({ 'name': 'product_product_event', diff --git a/addons/event_sale/static/tests/tours/event_configurator_ui.js b/addons/event_sale/static/tests/tours/event_configurator_ui.js index 1170a142a09..a7419871bbe 100644 --- a/addons/event_sale/static/tests/tours/event_configurator_ui.js +++ b/addons/event_sale/static/tests/tours/event_configurator_ui.js @@ -20,10 +20,10 @@ tour.register('event_configurator_tour', { }, { trigger: 'div[name="product_id"] input, div[name="product_template_id"] input', run: function (actions) { - actions.text('EVENT'); + actions.text('Event'); } }, { - trigger: 'ul.ui-autocomplete a:contains("EVENT")', + trigger: 'ul.ui-autocomplete a:contains("Event")', run: 'click' }, { trigger: 'div[name="event_id"] input', @@ -56,7 +56,7 @@ tour.register('event_configurator_tour', { trigger: 'ul.nav a:contains("Order Lines")', run: 'click' }, { - trigger: 'td:contains("EVENT")', + trigger: 'td:contains("Event")', run: 'click' }, { trigger: '.o_edit_product_configuration' diff --git a/addons/hr_expense/data/hr_expense_data.xml b/addons/hr_expense/data/hr_expense_data.xml index 5133dc6d8a8..5005c238c44 100644 --- a/addons/hr_expense/data/hr_expense_data.xml +++ b/addons/hr_expense/data/hr_expense_data.xml @@ -6,7 +6,6 @@ 0.0 1.0 service - EXP_GEN diff --git a/addons/hr_expense/data/hr_expense_demo.xml b/addons/hr_expense/data/hr_expense_demo.xml index f881e5a6468..5ce0198e0e4 100644 --- a/addons/hr_expense/data/hr_expense_demo.xml +++ b/addons/hr_expense/data/hr_expense_demo.xml @@ -37,7 +37,6 @@ 0.32 service Car Travel Expenses - EXP_CT @@ -50,7 +49,6 @@ 700.0 service Air Flight - EXP_AF diff --git a/addons/hr_timesheet/controllers/portal.py b/addons/hr_timesheet/controllers/portal.py index 8127b83be6f..b244ee3e656 100644 --- a/addons/hr_timesheet/controllers/portal.py +++ b/addons/hr_timesheet/controllers/portal.py @@ -23,32 +23,59 @@ class TimesheetCustomerPortal(CustomerPortal): values['timesheet_count'] = request.env['account.analytic.line'].sudo().search_count(domain) return values + def _get_searchbar_inputs(self): + return { + 'all': {'input': 'all', 'label': _('Search in All')}, + 'project': {'input': 'project', 'label': _('Search in Project')}, + 'name': {'input': 'name', 'label': _('Search in Description')}, + 'employee': {'input': 'employee', 'label': _('Search in Employee')}, + 'task': {'input': 'task', 'label': _('Search in Task')} + } + + def _get_searchbar_groupby(self): + return { + 'none': {'input': 'none', 'label': _('None')}, + 'project': {'input': 'project', 'label': _('Project')}, + 'task': {'input': 'task', 'label': _('Task')}, + 'date': {'input': 'date', 'label': _('Date')}, + 'employee': {'input': 'employee', 'label': _('Employee')} + } + + def _get_search_domain(self, search_in, search): + search_domain = [] + if search_in in ('project', 'all'): + search_domain = OR([search_domain, [('project_id', 'ilike', search)]]) + if search_in in ('name', 'all'): + search_domain = OR([search_domain, [('name', 'ilike', search)]]) + if search_in in ('employee', 'all'): + search_domain = OR([search_domain, [('employee_id', 'ilike', search)]]) + if search_in in ('task', 'all'): + search_domain = OR([search_domain, [('task_id', 'ilike', search)]]) + return search_domain + + def _get_groupby_mapping(self): + return { + 'project': 'project_id', + 'task': 'task_id', + 'employee': 'employee_id', + 'date': 'date' + } + @http.route(['/my/timesheets', '/my/timesheets/page/'], type='http', auth="user", website=True) def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, search_in='all', groupby='none', **kw): Timesheet_sudo = request.env['account.analytic.line'].sudo() values = self._prepare_portal_layout_values() domain = request.env['account.analytic.line']._timesheet_get_portal_domain() + _items_per_page = 100 searchbar_sortings = { 'date': {'label': _('Newest'), 'order': 'date desc'}, 'name': {'label': _('Description'), 'order': 'name'}, } - searchbar_inputs = { - 'all': {'input': 'all', 'label': _('Search in All')}, - 'project': {'input': 'project', 'label': _('Search in Project')}, - 'name': {'input': 'name', 'label': _('Search in Description')}, - 'employee': {'input': 'employee', 'label': _('Search in Employee')}, - 'task': {'input': 'task', 'label': _('Search in Task')}, - } + searchbar_inputs = self._get_searchbar_inputs() - searchbar_groupby = { - 'none': {'input': 'none', 'label': _('None')}, - 'project': {'input': 'project', 'label': _('Project')}, - 'task': {'input': 'task', 'label': _('Task')}, - 'date': {'input': 'date', 'label': _('Date')}, - 'employee': {'input': 'employee', 'label': _('Employee')}, - } + searchbar_groupby = self._get_searchbar_groupby() today = fields.Date.today() quarter_start, quarter_end = date_utils.get_quarter(today) @@ -77,16 +104,7 @@ class TimesheetCustomerPortal(CustomerPortal): domain = AND([domain, searchbar_filters[filterby]['domain']]) if search and search_in: - search_domain = [] - if search_in in ('project', 'all'): - search_domain = OR([search_domain, [('project_id', 'ilike', search)]]) - if search_in in ('name', 'all'): - search_domain = OR([search_domain, [('name', 'ilike', search)]]) - if search_in in ('employee', 'all'): - search_domain = OR([search_domain, [('employee_id', 'ilike', search)]]) - if search_in in ('task', 'all'): - search_domain = OR([search_domain, [('task_id', 'ilike', search)]]) - domain += search_domain + domain += self._get_search_domain(search_in, search) timesheet_count = Timesheet_sudo.search_count(domain) # pager @@ -95,19 +113,14 @@ class TimesheetCustomerPortal(CustomerPortal): url_args={'sortby': sortby, 'search_in': search_in, 'search': search, 'filterby': filterby, 'groupby': groupby}, total=timesheet_count, page=page, - step=self._items_per_page + step=_items_per_page ) def get_timesheets(): - groupby_mapping = { - 'project': 'project_id', - 'task': 'task_id', - 'employee': 'employee_id', - 'date': 'date', - } + groupby_mapping = self._get_groupby_mapping() field = groupby_mapping.get(groupby, None) orderby = '%s, %s' % (field, order) if field else order - timesheets = Timesheet_sudo.search(domain, order=orderby, limit=self._items_per_page, offset=pager['offset']) + timesheets = Timesheet_sudo.search(domain, order=orderby, limit=_items_per_page, offset=pager['offset']) if field: if groupby == 'date': time_data = Timesheet_sudo.read_group(domain, ['date', 'unit_amount:sum'], ['date:day']) @@ -115,7 +128,7 @@ class TimesheetCustomerPortal(CustomerPortal): grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k]) for k, g in groupbyelem(timesheets, itemgetter('date'))] else: time_data = time_data = Timesheet_sudo.read_group(domain, [field, 'unit_amount:sum'], [field]) - mapped_time = dict([(m[field][0], m['unit_amount']) for m in time_data]) + mapped_time = dict([(m[field][0] if m[field] else False, m['unit_amount']) for m in time_data]) grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k.id]) for k, g in groupbyelem(timesheets, itemgetter(field))] return timesheets, grouped_timesheets diff --git a/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml b/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml index 90f2d679928..4385bbddd6a 100644 --- a/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml +++ b/addons/hr_timesheet/views/hr_timesheet_portal_templates.xml @@ -36,24 +36,74 @@ - + - - Timesheets for project: - - - - Timesheets for task: - - - - Timesheets on - - - - Timesheets for employee: - - + + + Timesheets for project: + + + + + Total: + + + Total: + + + + + + Timesheets for task: + + + + + Total: + + + Total: + + + + + + Timesheets on + + + + + Total: + + + Total: + + + + + + Timesheets for employee: + + + + + Total: + + + Total: + + + + + +
+ + Total: + + + Total: + +
Date @@ -61,21 +111,21 @@ Project Task Description - Days Spent (Total: ) - Hours Spent (Total: ) + Days Spent + Hours Spent - + - - - - + + + + - + diff --git a/addons/portal/views/portal_templates.xml b/addons/portal/views/portal_templates.xml index 2003cb51f96..282236550fe 100644 --- a/addons/portal/views/portal_templates.xml +++ b/addons/portal/views/portal_templates.xml @@ -299,7 +299,7 @@ -
+
+ + {'always_reload': True} + {'res_partner_search_mode': 'customer'} + - - + + diff --git a/addons/sale_timesheet/__manifest__.py b/addons/sale_timesheet/__manifest__.py index eb640411bdb..577e5cba62c 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -25,6 +25,7 @@ have real delivered quantities in sales orders. 'views/hr_timesheet_views.xml', 'views/res_config_settings_views.xml', 'views/hr_timesheet_templates.xml', + 'views/report_invoice.xml', 'views/sale_timesheet_portal_templates.xml', 'report/project_profitability_report_analysis_views.xml', 'data/sale_timesheet_filters.xml', diff --git a/addons/sale_timesheet/controllers/portal.py b/addons/sale_timesheet/controllers/portal.py index e4e04da4878..7d13914dc79 100644 --- a/addons/sale_timesheet/controllers/portal.py +++ b/addons/sale_timesheet/controllers/portal.py @@ -1,10 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. +from odoo import http, _ from odoo.http import request from odoo.osv import expression from odoo.addons.account.controllers import portal +from odoo.addons.hr_timesheet.controllers.portal import TimesheetCustomerPortal class PortalAccount(portal.PortalAccount): @@ -38,3 +40,44 @@ class CustomerPortal(portal.CustomerPortal): values['timesheets'] = request.env['account.analytic.line'].sudo().search(domain) values['is_uom_day'] = request.env['account.analytic.line'].sudo()._is_timesheet_encode_uom_day() return values + + +class SaleTimesheetCustomerPortal(TimesheetCustomerPortal): + + def _get_searchbar_inputs(self): + searchbar_inputs = super()._get_searchbar_inputs() + searchbar_inputs.update( + sol={'input': 'sol', 'label': _('Search in Sales Order Item')}, + sol_id={'input': 'sol_id', 'label': _('Search in Sales Order Item ID')}, + invoice={'input': 'invoice_id', 'label': _('Search in Invoice ID')}) + return searchbar_inputs + + def _get_searchbar_groupby(self): + searchbar_groupby = super()._get_searchbar_groupby() + searchbar_groupby.update(sol={'input': 'sol', 'label': _('Sales Order Item')}) + return searchbar_groupby + + def _get_search_domain(self, search_in, search): + search_domain = super()._get_search_domain(search_in, search) + if search_in in ('sol', 'all'): + search_domain = expression.OR([search_domain, [('so_line', 'ilike', search)]]) + if search_in in ('sol_id', 'invoice_id'): + search = int(search) if search.isdigit() else 0 + if search_in == 'sol_id': + search_domain = expression.OR([search_domain, [('so_line.id', '=', search)]]) + if search_in == 'invoice_id': + invoice = request.env['account.move'].browse(search) + domain = request.env['account.analytic.line']._timesheet_get_sale_domain(invoice.mapped('invoice_line_ids.sale_line_ids'), invoice) + search_domain = expression.OR([search_domain, domain]) + return search_domain + + def _get_groupby_mapping(self): + groupby_mapping = super()._get_groupby_mapping() + groupby_mapping.update(sol='so_line') + return groupby_mapping + + @http.route(['/my/timesheets', '/my/timesheets/page/'], type='http', auth="user", website=True) + def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, search_in='all', groupby='sol', **kw): + if search and search_in and search_in in ('sol_id', 'invoice_id') and not search.isdigit(): + search = '0' + return super().portal_my_timesheets(page, sortby, filterby, search, search_in, groupby, **kw) diff --git a/addons/sale_timesheet/data/sale_service_data.xml b/addons/sale_timesheet/data/sale_service_data.xml index b0042b6acdc..dbfcea047cc 100644 --- a/addons/sale_timesheet/data/sale_service_data.xml +++ b/addons/sale_timesheet/data/sale_service_data.xml @@ -5,8 +5,15 @@ Service on Timesheet service 40 + + delivered_timesheet + + + + + diff --git a/addons/sale_timesheet/data/sale_service_demo.xml b/addons/sale_timesheet/data/sale_service_demo.xml index f6aae634c63..3f1832b8a84 100644 --- a/addons/sale_timesheet/data/sale_service_demo.xml +++ b/addons/sale_timesheet/data/sale_service_demo.xml @@ -22,6 +22,7 @@ After-Sales Services + @@ -45,7 +46,6 @@ Customer Care (Prepaid Hours) - SERV_585189 service 250.00 @@ -59,7 +59,6 @@ Senior Architect (Invoice on Timesheets) - SERV_89744 200.00 150.00 @@ -72,7 +71,6 @@ Junior Architect (Invoice on Timesheets) - SERV_89665 100.00 85.00 @@ -85,7 +83,6 @@ Kitchen Assembly (Milestones) - SERV_32289 500 420.00 diff --git a/addons/sale_timesheet/models/account.py b/addons/sale_timesheet/models/account.py index da3c43df7e6..5dbe9c56da6 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -22,7 +22,9 @@ class AccountAnalyticLine(models.Model): ('non_billable_project', 'No task found')], string="Billable Type", compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True) timesheet_invoice_id = fields.Many2one('account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet") non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheet will not be billed.") + so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False) + # TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to bill_type and pricing_type of project (See in master) @api.depends('so_line.product_id', 'project_id', 'task_id', 'non_allow_billable', 'task_id.bill_type', 'task_id.pricing_type', 'task_id.non_allow_billable') def _compute_timesheet_invoice_type(self): non_allowed_billable = self.filtered('non_allow_billable') @@ -55,12 +57,21 @@ class AccountAnalyticLine(models.Model): else: self.so_line = False + @api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'employee_id', 'project_id.allow_billable') + def _compute_so_line(self): + for timesheet in self._get_not_billed(): # Get only the timesheets are not yet invoiced + timesheet.so_line = timesheet.project_id.allow_billable and timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id, timesheet.project_id) + + def _get_not_billed(self): + return self.filtered(lambda t: not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') + + def _check_timesheet_can_be_billed(self): + return self.so_line in self.project_id.mapped('sale_line_employee_ids.sale_line_id') | self.task_id.sale_line_id | self.project_id.sale_line_id + @api.constrains('so_line', 'project_id') def _check_sale_line_in_project_map(self): - for timesheet in self: - if timesheet.project_id and timesheet.so_line: # billed timesheet - if timesheet.so_line not in timesheet.project_id.mapped('sale_line_employee_ids.sale_line_id') | timesheet.task_id.sale_line_id | timesheet.project_id.sale_line_id: - raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line.")) + if not all(t._check_timesheet_can_be_billed() for t in self._get_not_billed().filtered(lambda t: t.project_id and t.so_line)): + raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line.")) def write(self, values): # prevent to update invoiced timesheets if one line is of type delivery @@ -81,26 +92,8 @@ class AccountAnalyticLine(models.Model): values['account_id'] = task.analytic_account_id.id values['company_id'] = task.analytic_account_id.company_id.id values = super(AccountAnalyticLine, self)._timesheet_preprocess(values) - # task implies so line (at create) - if any([field_name in values for field_name in ['task_id', 'project_id']]) and not values.get('so_line') and (values.get('employee_id') or self.mapped('employee_id')): - if not values.get('employee_id') and len(self.mapped('employee_id')) > 1: - raise UserError(_('You can not modify timesheets from different employees')) - task = self.env['project.task'].sudo().browse(values['task_id']) if values.get('task_id') else self.env['project.task'] - employee = self.env['hr.employee'].sudo().browse(values['employee_id']) if values.get('employee_id') else self.mapped('employee_id') - project = self.env['project.project'].sudo().browse(values['project_id']) if values.get('project_id') else task.project_id - values['so_line'] = self._timesheet_determine_sale_line(task, employee, project).id return values - def _timesheet_postprocess_values(self, values): - result = super(AccountAnalyticLine, self)._timesheet_postprocess_values(values) - # (re)compute the sale line - if any(field_name in values for field_name in ['task_id', 'employee_id', 'project_id']): - for timesheet in self: - result[timesheet.id].update({ - 'so_line': timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id, timesheet.project_id).id, - }) - return result - @api.model def _timesheet_determine_sale_line(self, task, employee, project): """ Deduce the SO line associated to the timesheet line: @@ -108,19 +101,20 @@ class AccountAnalyticLine(models.Model): 2/ timesheet on employee rate task: find the SO line in the map of the project (even for subtask), or fallback on the SO line of the task, or fallback on the one on the project """ - if project.sale_line_id and not task: + if not task: if project.bill_type == 'customer_project' and project.pricing_type == 'employee_rate': map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', project.id), ('employee_id', '=', employee.id)]) if map_entry: return map_entry.sale_line_id - return project.sale_line_id + if project.sale_line_id: + return project.sale_line_id if task.allow_billable: if task.bill_type == 'customer_task': return task.sale_line_id if task.pricing_type == 'fixed_rate': return task.sale_line_id elif task.pricing_type == 'employee_rate' and not task.non_allow_billable: - map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', task.project_id.id), ('employee_id', '=', employee.id)]) + map_entry = project.sale_line_employee_ids.filtered(lambda map_entry: map_entry.employee_id == employee) if map_entry: return map_entry.sale_line_id if task.sale_line_id or project.sale_line_id: @@ -137,6 +131,9 @@ class AccountAnalyticLine(models.Model): @api.model def _timesheet_get_sale_domain(self, order_lines_ids, invoice_ids): + if not invoice_ids: + return [('so_line', 'in', order_lines_ids.ids)] + return [ '|', '&', diff --git a/addons/sale_timesheet/models/project.py b/addons/sale_timesheet/models/project.py index 6d1b81f5606..ab2345c0fd8 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -47,6 +47,7 @@ class Project(models.Model): ('service_type', '=', 'timesheet'), '|', ('company_id', '=', False), ('company_id', '=', company_id)]""", help='Select a Service product with which you would like to bill your time spent on tasks.', + compute="_compute_timesheet_product_id", store=True, readonly=False, default=_default_timesheet_product_id) warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate') @@ -237,6 +238,28 @@ class ProjectTask(models.Model): # TODO: [XBO] remove me in master non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheets linked to this task will not be billed.") + remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so') + remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available") + + @api.depends('sale_line_id', 'timesheet_ids', 'timesheet_ids.unit_amount') + def _compute_remaining_hours_so(self): + # TODO This is not yet perfectly working as timesheet.so_line stick to its old value although changed + # in the task From View. + timesheets = self.timesheet_ids.filtered(lambda t: t.task_id.sale_line_id in (t.so_line, t._origin.so_line) and t.so_line.remaining_hours_available) + + mapped_remaining_hours = {task._origin.id: task.sale_line_id and task.sale_line_id.remaining_hours or 0.0 for task in self} + uom_hour = self.env.ref('uom.product_uom_hour') + for timesheet in timesheets: + delta = 0 + if timesheet._origin.so_line == timesheet.task_id.sale_line_id: + delta += timesheet._origin.unit_amount + if timesheet.so_line == timesheet.task_id.sale_line_id: + delta -= timesheet.unit_amount + if delta: + mapped_remaining_hours[timesheet.task_id._origin.id] += timesheet.so_line.product_uom._compute_quantity(delta, uom_hour) + + for task in self: + task.remaining_hours_so = mapped_remaining_hours[task._origin.id] @api.depends( 'allow_billable', 'allow_timesheets', 'sale_order_id') @@ -265,17 +288,25 @@ class ProjectTask(models.Model): for task in self: task.analytic_account_active = task.analytic_account_active or task.analytic_account_id.active - @api.depends('sale_line_id', 'project_id', 'allow_billable', 'bill_type', 'pricing_type', 'non_allow_billable') + @api.depends('sale_line_id', 'project_id', 'allow_billable', 'non_allow_billable') def _compute_sale_order_id(self): for task in self: - if task.allow_billable and task.bill_type == 'customer_project' and task.pricing_type == 'employee_rate' and task.non_allow_billable: - task.sale_order_id = False - elif task.allow_billable and task.bill_type == 'customer_project': - task.sale_order_id = task.project_id.sale_order_id - elif task.allow_billable and task.bill_type == 'customer_task': - task.sale_order_id = task.sale_line_id.sudo().order_id - elif not task.sale_order_id: + if not task.allow_billable or task.non_allow_billable: task.sale_order_id = False + elif task.allow_billable: + if task.sale_line_id: + task.sale_order_id = task.sale_line_id.sudo().order_id + elif task.project_id.sale_order_id: + task.sale_order_id = task.project_id.sale_order_id + if task.sale_order_id and not task.partner_id: + task.partner_id = task.sale_order_id.partner_id + + @api.depends('commercial_partner_id', 'sale_line_id.order_partner_id.commercial_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'allow_billable') + def _compute_sale_line(self): + billable_tasks = self.filtered('allow_billable') + super(ProjectTask, billable_tasks)._compute_sale_line() + for task in billable_tasks.filtered(lambda t: not t.sale_line_id): + task.sale_line_id = task._get_last_sol_of_customer() @api.depends('project_id.sale_line_employee_ids') def _compute_is_project_map_empty(self): @@ -302,22 +333,6 @@ class ProjectTask(models.Model): project_dest = self.env['project.project'].browse(values['project_id']) if project_dest.bill_type == 'customer_project' and project_dest.pricing_type == 'employee_rate': self.write({'sale_line_id': False}) - if 'sale_line_id' in values and self.filtered('allow_timesheets').sudo().timesheet_ids: - so = self.env['sale.order.line'].browse(values['sale_line_id']).order_id - if so and not so.analytic_account_id: - so.analytic_account_id = self.project_id.analytic_account_id - timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered( - lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') - ) - timesheet_ids.write({'so_line': values['sale_line_id']}) - if 'project_id' in values: - - # Special case when we edit SOL an project in same time, as we edit SOL of - # timesheet lines, function '_get_timesheet' won't find the right timesheet - # to edit so we must edit those here. - project = self.env['project.project'].browse(values.get('project_id')) - if project.allow_timesheets: - timesheet_ids.write({'project_id': values.get('project_id')}) if 'non_allow_billable' in values and self.filtered('allow_timesheets').sudo().timesheet_ids: timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered( lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel') @@ -335,6 +350,20 @@ class ProjectTask(models.Model): return res + def _get_last_sol_of_customer(self): + # Get the last SOL made for the customer in the current task where we need to compute + self.ensure_one() + if not self.commercial_partner_id or not self.allow_billable: + return False + domain = [('is_service', '=', True), ('order_partner_id', 'child_of', self.commercial_partner_id.id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])] + if self.project_id.bill_type == 'customer_type' and self.project_sale_order_id: + domain.append(('order_id', '=?', self.project_sale_order_id.id)) + sale_lines = self.env['sale.order.line'].search(domain) + for line in sale_lines: + if line.remaining_hours_available and line.remaining_hours > 0: + return line + return False + def action_make_billable(self): return { "name": _("Create Sales Order"), diff --git a/addons/sale_timesheet/models/sale_order.py b/addons/sale_timesheet/models/sale_order.py index 63b695577df..8d8492919f7 100644 --- a/addons/sale_timesheet/models/sale_order.py +++ b/addons/sale_timesheet/models/sale_order.py @@ -1,8 +1,9 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models +from odoo import api, fields, models, _ from odoo.osv import expression +import math class SaleOrder(models.Model): @@ -77,6 +78,66 @@ class SaleOrderLine(models.Model): qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')]) analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense) + remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available') + remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours') + + def name_get(self): + res = super(SaleOrderLine, self).name_get() + if self.env.context.get('with_remaining_hours'): + names = dict(res) + result = [] + uom_hour = self.env.ref('uom.product_uom_hour') + uom_day = self.env.ref('uom.product_uom_day') + for line in self: + name = names.get(line.id) + if line.remaining_hours_available: + company = self.env.company + encoding_uom = company.timesheet_encode_uom_id + remaining_time = '' + if encoding_uom == uom_hour: + hours, minutes = divmod(abs(line.remaining_hours) * 60, 60) + round_minutes = minutes / 30 + minutes = math.ceil(round_minutes) if line.remaining_hours >= 0 else math.floor(round_minutes) + if minutes > 1: + minutes = 0 + hours += 1 + else: + minutes = minutes * 30 + remaining_time =' ({sign}{hours:02.0f}:{minutes:02.0f})'.format( + sign='-' if line.remaining_hours < 0 else '', + hours=hours, + minutes=minutes) + elif encoding_uom == uom_day: + remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False) + remaining_time = ' ({qty:.02f} {unit})'.format( + qty=remaining_days, + unit=_('days') if abs(remaining_days) > 1 else _('day') + ) + name = '{name}{remaining_time}'.format( + name=name, + remaining_time=remaining_time + ) + result.append((line.id, name)) + return result + return res + + @api.depends('product_id.service_policy') + def _compute_remaining_hours_available(self): + uom_hour = self.env.ref('uom.product_uom_hour') + for line in self: + is_ordered_timesheet = line.product_id.service_policy == 'ordered_timesheet' + is_time_product = line.product_uom.category_id == uom_hour.category_id + line.remaining_hours_available = is_ordered_timesheet and is_time_product + + @api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids') + def _compute_remaining_hours(self): + uom_hour = self.env.ref('uom.product_uom_hour') + for line in self: + remaining_hours = None + if line.remaining_hours_available: + qty_left = line.product_uom_qty - line.qty_delivered + remaining_hours = line.product_uom._compute_quantity(qty_left, uom_hour) + line.remaining_hours = remaining_hours @api.depends('product_id') def _compute_qty_delivered_method(self): diff --git a/addons/sale_timesheet/views/hr_timesheet_templates.xml b/addons/sale_timesheet/views/hr_timesheet_templates.xml index 6a44f41789b..1715f3c2735 100644 --- a/addons/sale_timesheet/views/hr_timesheet_templates.xml +++ b/addons/sale_timesheet/views/hr_timesheet_templates.xml @@ -404,9 +404,14 @@ - + + + + + + - + Cancelled diff --git a/addons/sale_timesheet/views/hr_timesheet_views.xml b/addons/sale_timesheet/views/hr_timesheet_views.xml index 967ee418960..b3f34e8db5c 100644 --- a/addons/sale_timesheet/views/hr_timesheet_views.xml +++ b/addons/sale_timesheet/views/hr_timesheet_views.xml @@ -42,7 +42,7 @@ - + diff --git a/addons/sale_timesheet/views/project_task_views.xml b/addons/sale_timesheet/views/project_task_views.xml index b75a28ed748..1fcc110d8e0 100644 --- a/addons/sale_timesheet/views/project_task_views.xml +++ b/addons/sale_timesheet/views/project_task_views.xml @@ -25,8 +25,8 @@ - - + + @@ -60,6 +60,21 @@ + + project.project.form.inherit.salesman + project.project + + + + + {'no_create': True, 'no_edit': True, 'delete': False} + + + {'no_create': True, 'no_edit': True, 'delete': False} + + + + project.project.view.form.simplified.inherit project.project @@ -134,10 +149,12 @@ - + - - + + + + @@ -145,13 +162,31 @@ view.task.form2.inherit project.task - + + {'with_remaining_hours': True} - {'invisible': ['|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), '&', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)]} + {'invisible': ['|', ('allow_billable', '=', False), ('partner_id', '=', False)]} + + + + + + + + 1 + diff --git a/addons/sale_timesheet/views/report_invoice.xml b/addons/sale_timesheet/views/report_invoice.xml new file mode 100644 index 00000000000..dbf89fc2407 --- /dev/null +++ b/addons/sale_timesheet/views/report_invoice.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml b/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml index 9e198ababee..d983b28c095 100644 --- a/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml +++ b/addons/sale_timesheet/views/sale_timesheet_portal_templates.xml @@ -7,107 +7,154 @@ + + + + + + diff --git a/addons/sale_timesheet_edit/__init__.py b/addons/sale_timesheet_edit/__init__.py new file mode 100644 index 00000000000..dc5e6b693d1 --- /dev/null +++ b/addons/sale_timesheet_edit/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import models diff --git a/addons/sale_timesheet_edit/__manifest__.py b/addons/sale_timesheet_edit/__manifest__.py new file mode 100644 index 00000000000..6178dbe0135 --- /dev/null +++ b/addons/sale_timesheet_edit/__manifest__.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +# TODO: [XBO] merge with sale_timesheet module in master +{ + 'name': 'Sales Timesheet Edit', + 'category': 'Hidden', + 'summary': 'Edit the sale order line linked in the timesheets', + 'description': """ +Allow to edit sale order line in the timesheets +=============================================== + +This module adds the edition of the sale order line +set in the timesheets. This allows adds more flexibility +to the user to easily change the sale order line on a +timesheet in task form view when it is needed. +""", + 'depends': ['sale_timesheet'], + 'data': [ + 'views/assets.xml', + 'views/project_task.xml', + ], + 'demo': [], + 'auto_install': True, +} diff --git a/addons/sale_timesheet_edit/models/__init__.py b/addons/sale_timesheet_edit/models/__init__.py new file mode 100644 index 00000000000..bf8826f4bc0 --- /dev/null +++ b/addons/sale_timesheet_edit/models/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from . import account_analytic_line +from . import project diff --git a/addons/sale_timesheet_edit/models/account_analytic_line.py b/addons/sale_timesheet_edit/models/account_analytic_line.py new file mode 100644 index 00000000000..5ff797ddc34 --- /dev/null +++ b/addons/sale_timesheet_edit/models/account_analytic_line.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +# TODO: [XBO] merge with account.analytic.line in the sale_timesheet module in master. +class AccountAnalyticLine(models.Model): + _inherit = 'account.analytic.line' + + is_so_line_edited = fields.Boolean() + + @api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'project_id.allow_billable', 'employee_id') + def _compute_so_line(self): + super(AccountAnalyticLine, self.filtered(lambda t: not t.is_so_line_edited))._compute_so_line() + + def _check_sale_line_in_project_map(self): + # TODO: [XBO] remove me in master, now we authorize to manually edit the so_line, then this so_line can be different of the one in task/project/map_entry + # !!! Override of the method in sale_timesheet !!! + return diff --git a/addons/sale_timesheet_edit/models/project.py b/addons/sale_timesheet_edit/models/project.py new file mode 100644 index 00000000000..b10bafddbcf --- /dev/null +++ b/addons/sale_timesheet_edit/models/project.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models + + +class Project(models.Model): + _inherit = 'project.project' + + def _get_not_billed_timesheets(self): + """ Get the timesheets not invoiced and the SOL has not manually been edited + FIXME: [XBO] this change must be done in the _update_timesheets_sale_line_id + rather than this method in master to keep the initial behaviour of this method. + """ + return super(Project, self)._get_not_billed_timesheets() - self.mapped('timesheet_ids').filtered('is_so_line_edited') diff --git a/addons/sale_timesheet_edit/static/src/js/so_line_one2many.js b/addons/sale_timesheet_edit/static/src/js/so_line_one2many.js new file mode 100644 index 00000000000..bc638b04b34 --- /dev/null +++ b/addons/sale_timesheet_edit/static/src/js/so_line_one2many.js @@ -0,0 +1,28 @@ +odoo.define('sale_timesheet_edit.so_line_many2one', function (require) { +"use strict"; + +const fieldRegistry = require('web.field_registry'); +const FieldOne2Many = require('web.relational_fields').FieldOne2Many; + +const SoLineOne2Many = FieldOne2Many.extend({ + _onFieldChanged: function (ev) { + if ( + ev.data.changes && + ev.data.changes.hasOwnProperty('timesheet_ids') && + ev.data.changes.timesheet_ids.operation === 'UPDATE' && + ev.data.changes.timesheet_ids.data.hasOwnProperty('so_line')) { + const line = this.value.data.find(line => { + return line.id === ev.data.changes.timesheet_ids.id; + }); + if (!line.is_so_line_edited) { + ev.data.changes.timesheet_ids.data.is_so_line_edited = true; + } + } + this._super.apply(this, arguments); + } +}); + + +fieldRegistry.add('so_line_one2many', SoLineOne2Many); + +}); diff --git a/addons/sale_timesheet_edit/views/assets.xml b/addons/sale_timesheet_edit/views/assets.xml new file mode 100644 index 00000000000..b8909b44964 --- /dev/null +++ b/addons/sale_timesheet_edit/views/assets.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/addons/sale_timesheet_edit/views/project_task.xml b/addons/sale_timesheet_edit/views/project_task.xml new file mode 100644 index 00000000000..f9f7e38e3ee --- /dev/null +++ b/addons/sale_timesheet_edit/views/project_task.xml @@ -0,0 +1,41 @@ + + + + + + project.task.form.view.form.inherit.sale.timesheet.edit + project.task + + + + so_line_one2many + + + 0 + [('is_service', '=', True), ('order_partner_id', 'child_of', parent.commercial_partner_id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('order_id', '=?', parent.project_sale_order_id)] + {'no_create': True, 'no_open': True} + + + + + + + project.task.form.view.form.inherit.sale.timesheet.editable + project.task + + + + {'no_create': True} + + + + + + + + +