diff --git a/addons/event_sale/data/event_sale_data.xml b/addons/event_sale/data/event_sale_data.xml index bf8bc78249ead..725bc51f75719 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 7759a547d8428..da142f973c9e4 100644 --- a/addons/event_sale/models/event_ticket.py +++ b/addons/event_sale/models/event_ticket.py @@ -72,8 +72,6 @@ def _init_column(self, column_name): '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 1170a142a0936..a7419871bbecc 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 5133dc6d8a848..5005c238c44ae 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 f881e5a646849..5ce0198e0e4aa 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 8127b83be6f5e..b244ee3e65606 100644 --- a/addons/hr_timesheet/controllers/portal.py +++ b/addons/hr_timesheet/controllers/portal.py @@ -23,32 +23,59 @@ def _prepare_home_portal_values(self, counters): 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 @@ def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, 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 @@ def portal_my_timesheets(self, page=1, sortby=None, filterby=None, search=None, 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 @@ def get_timesheets(): 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 90f2d67992845..4385bbddd6a9a 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 2f77dacf67eb6..c7d4974abc1a5 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 eb640411bdb07..577e5cba62c2e 100644 --- a/addons/sale_timesheet/__manifest__.py +++ b/addons/sale_timesheet/__manifest__.py @@ -25,6 +25,7 @@ '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 e4e04da487867..7d13914dc79d9 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 @@ def _order_get_page_view_values(self, order, access_token, **kwargs): 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 b0042b6acdcec..dbfcea047ccbe 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 f6aae634c6392..3f1832b8a84e8 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 e946c30d18d58..ef89280ab97df 100644 --- a/addons/sale_timesheet/models/account.py +++ b/addons/sale_timesheet/models/account.py @@ -22,7 +22,9 @@ def _default_sale_line_domain(self): ('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 @@ def _onchange_task_id_employee_id(self): 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 @@ def _timesheet_preprocess(self, values): 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 @@ def _timesheet_determine_sale_line(self, task, employee, project): 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 @@ def _timesheet_get_portal_domain(self): @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 a09a31d44d0d6..35d4751731ed3 100644 --- a/addons/sale_timesheet/models/project.py +++ b/addons/sale_timesheet/models/project.py @@ -47,6 +47,7 @@ def _default_timesheet_product_id(self): ('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') @@ -231,6 +232,28 @@ def default_get(self, fields): # 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') @@ -259,17 +282,25 @@ def _compute_analytic_account_active(self): 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): @@ -296,22 +327,6 @@ def write(self, values): 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') @@ -329,6 +344,20 @@ def write(self, values): 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 e6d13265f36ed..af066c44cd115 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): @@ -78,6 +79,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 6a44f41789b9f..1715f3c27354e 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 967ee41896014..b3f34e8db5c80 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 b75a28ed748fa..1fcc110d8e0a6 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 0000000000000..dbf89fc24077a --- /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 9e198ababeef5..d983b28c09590 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 0000000000000..dc5e6b693d19d --- /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 0000000000000..6178dbe013584 --- /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 0000000000000..bf8826f4bc040 --- /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 0000000000000..5ff797ddc34d7 --- /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 0000000000000..b10bafddbcf8a --- /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 0000000000000..bc638b04b3451 --- /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 0000000000000..b8909b4496452 --- /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 0000000000000..f9f7e38e3eecf --- /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} + + + + + + + + +