[MERGE] *_project, *_timesheet: Improvements in Services Apps

Purpose
=======

This PR is in continuity of #62360 and #62900 done in 14.0.
The goal of this PR essentially updates and improves the changes done in 14.0.

## Details

- Remove the context key added in 14.0 to keep the 'Create Sale Order' button in task and helpdesk.ticket. Because at the end we remove this button in these models.
- Remove non_allow_billable field in tasks and timesheets.
- Remove the partner_email from task form view.
- Remove timesheet_product_id from task form view
- Merge sale_timesheet_edit module with sale_timesheet module.
- Remove timesheet table in portal sales order and invoice (deprecated templates in #62900).
- Remove override of project kanban controller (deprecated file from #62360).
- Store remaining_hours field in sale.order.line model (this field has been introduced in the following PR #62900).
- Remove _onchange_task_id_employee_id, the process in the method is done in _compute_so_line.
- Merge bill_type and pricing_type in project.project.
- Move partner_id field in project form view.
- Remove value selected in SO and SOL in project if the customer does not fit with the SO selected.
- Changes SOL in mapping when the pricing type of the current project is "employee rate" if the customer changes.
- Hide the timesheet_product_id field if the project is not a fsm project.
- Move sql constraint for timesheet_product_id in industry_fsm_sale, because this field is no longer mandatory in project which is not fsm project.
- Changes the search of timesheets in portal > timesheets.
    - we allows the user to search with name of SO and invoice to find the timesheets about the wanted SO or invoice rathan than the id of SOL and invoice.
- Move View Timesheets button in portal > invoice
- Remove deprecated codes.
- Miscellaneous fixes.

Task-2424382

closes odoo/odoo#64474

Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
Co-authored-by: Laurent Stukkens (LTU) <ltu@odoo.com>
This commit is contained in:
Odoo's Mergebot
2021-01-20 12:04:32 +01:00
committed by GitHub
co-authored by Laurent Stukkens
31 changed files with 145 additions and 600 deletions
+7 -4
View File
@@ -19,8 +19,9 @@ class TimesheetCustomerPortal(CustomerPortal):
def _prepare_home_portal_values(self, counters):
values = super()._prepare_home_portal_values(counters)
if 'timesheet_count' in counters:
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
values['timesheet_count'] = request.env['account.analytic.line'].sudo().search_count(domain)
Timesheet = request.env['account.analytic.line']
domain = Timesheet._timesheet_get_portal_domain()
values['timesheet_count'] = Timesheet.sudo().search_count(domain)
return values
def _get_searchbar_inputs(self):
@@ -63,9 +64,11 @@ class TimesheetCustomerPortal(CustomerPortal):
@http.route(['/my/timesheets', '/my/timesheets/page/<int: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()
Timesheet = request.env['account.analytic.line']
domain = Timesheet._timesheet_get_portal_domain()
Timesheet_sudo = Timesheet.sudo()
values = self._prepare_portal_layout_values()
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
_items_per_page = 100
searchbar_sortings = {
@@ -135,6 +135,9 @@ class AccountAnalyticLine(models.Model):
return etree.tostring(doc, encoding='unicode')
def _timesheet_get_portal_domain(self):
if self.env.user.has_group('hr_timesheet.group_hr_timesheet_user'):
# Then, he is internal user, and we take the domain for this current user
return self.env['ir.rule']._compute_domain(self._name)
return ['&',
'|', '|', '|',
('task_id.project_id.message_partner_ids', 'child_of', [self.env.user.partner_id.commercial_partner_id.id]),
+7 -13
View File
@@ -269,20 +269,20 @@
Customer Satisfaction
</span>
</div>
</button>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="bg-danger" attrs="{'invisible': [('active', '=', True)]}"/>
<div class="oe_title">
<h1>
<field name="name" class="o_text_overflow" placeholder="Project Name"/>
</h1>
<div name="options_active">
<div>
<label for="label_tasks" class="oe_inline" string="Name of the tasks:"/>
<field name="label_tasks" class="oe_inline oe_input_align"/>
</div>
</div>
</div>
<group>
<group>
<field name="label_tasks" string="Name of the tasks"/>
<field name="partner_id" widget="res_partner_many2one"/>
</group>
</group>
<notebook>
<page name="description_page" string="Description">
<field name="description" nolabel="1" placeholder="Describe your project..." type="html"/>
@@ -293,11 +293,6 @@
<group>
<field name="active" invisible="1"/>
<field name="user_id" string="Project Manager" widget="many2one_avatar_user" attrs="{'readonly':[('active','=',False)]}" domain="[('share', '=', False)]"/>
<field name="partner_id" string="Customer" widget="res_partner_many2one"/>
<field name="partner_phone" widget="phone"
attrs="{'invisible': [('partner_id', '=', False)]}"/>
<field name="partner_email" widget="email"
attrs="{'invisible': [('partner_id', '=', False)]}"/>
<div name="alias_def" colspan="2" attrs="{'invisible': [('alias_domain', '=', False)]}">
<!-- Always display the whole alias in edit mode. It depends in read only -->
<label for="alias_enabled" class="font-weight-bold" string="Create tasks by sending an email"/>
@@ -710,7 +705,6 @@
<group>
<field name="active" invisible="1"/>
<field name="partner_id" widget="res_partner_many2one" class="o_task_customer_field"/>
<field name="partner_email" widget="email" invisible="1"/>
<field name="partner_phone" widget="phone" attrs="{'invisible': True}"/>
<field name="legend_blocked" invisible="1"/>
<field name="legend_normal" invisible="1"/>
+10
View File
@@ -12,10 +12,12 @@ class Project(models.Model):
sale_line_id = fields.Many2one(
'sale.order.line', 'Sales Order Item', copy=False,
compute="_compute_sale_line_id", store=True, readonly=False,
domain="[('is_service', '=', True), ('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
help="Sales order item to which the project is linked. Link the timesheet entry to the sales order item defined on the project. "
"Only applies on tasks without sale order item defined, and if the employee is not in the 'Employee/Sales Order Item Mapping' of the project.")
sale_order_id = fields.Many2one('sale.order', 'Sales Order',
compute="_compute_sale_order_id", store=True, readonly=False,
domain="[('order_line.product_id.type', '=', 'service'), ('partner_id', '=', partner_id)]",
copy=False, help="Sales order to which the project is linked.")
project_overview = fields.Boolean('Show Project Overview', compute='_compute_project_overview')
@@ -30,6 +32,14 @@ class Project(models.Model):
defaults['sale_line_id'] = False
return defaults
@api.depends('sale_order_id')
def _compute_sale_line_id(self):
self.filtered(lambda p: p.sale_line_id and (not p.sale_order_id or p.sale_order_id != p.sale_line_id.order_id)).update({'sale_line_id': False})
@api.depends('partner_id')
def _compute_sale_order_id(self):
self.filtered(lambda p: p.sale_order_id and (not p.partner_id or p.sale_order_id.partner_id != p.partner_id)).update({'sale_order_id': False})
@api.depends('analytic_account_id')
def _compute_project_overview(self):
overview = self.env['project.project']
+2 -2
View File
@@ -8,8 +8,8 @@
<field name="arch" type="xml">
<xpath expr="//field[@name='service_type']" position="after">
<field name="service_tracking" widget="radio" attrs="{'invisible': [('type','!=','service')]}"/>
<field name="project_id" context="{'default_allow_billable': True, 'default_bill_type': 'customer_task'}" attrs="{'invisible':[('service_tracking','!=','task_global_project')], 'required':[('service_tracking','==','task_global_project')]}"/>
<field name="project_template_id" context="{'active_test': False, 'default_allow_billable': True, 'default_bill_type': 'customer_project', 'default_pricing_type': 'fixed_rate'}" attrs="{'invisible':[('service_tracking','not in',['task_in_project', 'project_only'])]}"/>
<field name="project_id" context="{'default_allow_billable': True, 'default_pricing_type': 'task_rate'}" attrs="{'invisible':[('service_tracking','!=','task_global_project')], 'required':[('service_tracking','==','task_global_project')]}"/>
<field name="project_template_id" context="{'active_test': False, 'default_allow_billable': True, 'default_pricing_type': 'fixed_rate'}" attrs="{'invisible':[('service_tracking','not in',['task_in_project', 'project_only'])]}"/>
</xpath>
</field>
</record>
-1
View File
@@ -25,7 +25,6 @@ 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',
+7 -11
View File
@@ -48,8 +48,8 @@ class SaleTimesheetCustomerPortal(TimesheetCustomerPortal):
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')})
so={'input': 'so', 'label': _('Search in Sales Order')},
invoice={'input': 'invoice', 'label': _('Search in Invoice')})
return searchbar_inputs
def _get_searchbar_groupby(self):
@@ -61,13 +61,11 @@ class SaleTimesheetCustomerPortal(TimesheetCustomerPortal):
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)
if search_in in ('so', 'all'):
search_domain = expression.OR([search_domain, [('so_line.order_id.name', 'ilike', search)]])
if search_in in ('invoice', 'all'):
invoices = request.env['account.move'].sudo().search([('name', 'ilike', search)])
domain = request.env['account.analytic.line']._timesheet_get_sale_domain(invoices.mapped('invoice_line_ids.sale_line_ids'), invoices)
search_domain = expression.OR([search_domain, domain])
return search_domain
@@ -78,6 +76,4 @@ class SaleTimesheetCustomerPortal(TimesheetCustomerPortal):
@http.route(['/my/timesheets', '/my/timesheets/page/<int: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)
+12 -34
View File
@@ -18,21 +18,15 @@ class AccountAnalyticLine(models.Model):
('billable_time', 'Billed on Timesheets'),
('billable_fixed', 'Billed at a Fixed price'),
('non_billable', 'Non Billable Tasks'),
('non_billable_timesheet', 'Non Billable Timesheet'),
('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)
is_so_line_edited = fields.Boolean("Is Sales Order Item Manually Edited")
# 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')
# TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to pricing_type of project (See in master)
@api.depends('so_line.product_id', 'project_id', 'task_id', 'task_id.pricing_type')
def _compute_timesheet_invoice_type(self):
non_allowed_billable = self.filtered('non_allow_billable')
non_allowed_billable.timesheet_invoice_type = 'non_billable_timesheet'
non_allowed_billable_task = (self - non_allowed_billable).filtered(lambda t: t.task_id.bill_type == 'customer_project' and t.task_id.pricing_type == 'employee_rate' and t.task_id.non_allow_billable)
non_allowed_billable_task.timesheet_invoice_type = 'non_billable'
for timesheet in self - non_allowed_billable - non_allowed_billable_task:
for timesheet in self:
if timesheet.project_id: # AAL will be set to False
invoice_type = 'non_billable_project' if not timesheet.task_id else 'non_billable'
if timesheet.task_id and timesheet.so_line.product_id.type == 'service':
@@ -47,32 +41,18 @@ class AccountAnalyticLine(models.Model):
else:
timesheet.timesheet_invoice_type = False
@api.onchange('employee_id')
def _onchange_task_id_employee_id(self):
if self.project_id and self.task_id.allow_billable: # timesheet only
if self.task_id.bill_type == 'customer_task' or self.task_id.pricing_type == 'fixed_rate':
self.so_line = self.task_id.sale_line_id
elif self.task_id.pricing_type == 'employee_rate':
self.so_line = self._timesheet_determine_sale_line(self.task_id, self.employee_id, self.project_id)
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
for timesheet in self.filtered(lambda t: not t.is_so_line_edited and t._is_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 _is_not_billed(self):
self.ensure_one()
return not self.timesheet_invoice_id or self.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):
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
self._check_can_write(values)
@@ -102,24 +82,22 @@ class AccountAnalyticLine(models.Model):
on the one on the project
"""
if not task:
if project.bill_type == 'customer_project' and project.pricing_type == 'employee_rate':
if 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
if project.sale_line_id:
return project.sale_line_id
if task.allow_billable:
if task.bill_type == 'customer_task':
if task.pricing_type in ('task_rate', 'fixed_rate'):
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:
else: # then pricing_type = 'employee_rate'
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:
return task.sale_line_id or project.sale_line_id
return self.env['sale.order.line']
return False
def _timesheet_get_portal_domain(self):
""" Only the timesheets with a product invoiced on delivered quantity are concerned.
+2 -2
View File
@@ -19,8 +19,8 @@ class ProductTemplate(models.Model):
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
], ondelete={'timesheet': 'set default'})
# override domain
project_id = fields.Many2one(domain="[('allow_billable', '=', True), ('bill_type', '=', 'customer_task'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]")
project_template_id = fields.Many2one(domain="[('allow_billable', '=', True), ('bill_type', '=', 'customer_project'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]")
project_id = fields.Many2one(domain="[('allow_billable', '=', True), ('pricing_type', '=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]")
project_template_id = fields.Many2one(domain="[('allow_billable', '=', True), ('pricing_type', 'in', ('fixed_rate', 'employee_rate')), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet' or '', True])]")
def _default_visible_expense_policy(self):
visibility = self.user_has_groups('project.group_project_user')
+18 -116
View File
@@ -24,16 +24,12 @@ class Project(models.Model):
def _default_timesheet_product_id(self):
return self.env.ref('sale_timesheet.time_product', False)
bill_type = fields.Selection([
('customer_task', 'Different customers'),
('customer_project', 'A unique customer')
], string="Invoice Tasks to", default="customer_task",
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 customers 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 customer either at a fixed rate, or at an employee rate.')
pricing_type = fields.Selection([
('task_rate', 'Task rate'),
('fixed_rate', 'Project rate'),
('employee_rate', 'Employee 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 employee who performed it. The employee 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.')
], string="Pricing", default="task_rate",
help='The task rate is perfect if you would like to bill different services to different customers at different rates. The fixed rate is perfect if you bill a service at a fixed rate per hour or day worked regardless of the employee who performed it. The employee 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.')
sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False,
help="Employee/Sale Order Item Mapping:\n Defines to which sales order item an employee's timesheet entry will be linked."
"By extension, it defines the rate at which an employee's time on the project is billed.")
@@ -51,23 +47,11 @@ class Project(models.Model):
default=_default_timesheet_product_id)
warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate')
_sql_constraints = [
('timesheet_product_required_if_billable_and_timesheets', """
CHECK(
(allow_billable = 't' AND allow_timesheets = 't' AND timesheet_product_id IS NOT NULL)
OR (allow_billable IS NOT TRUE)
OR (allow_timesheets IS NOT TRUE)
OR (allow_billable IS NULL)
OR (allow_timesheets IS NULL)
)""", 'The timesheet product is required when the task can be billed and timesheets are allowed.'),
]
@api.depends('allow_billable', 'sale_order_id', 'partner_id', 'bill_type')
@api.depends('allow_billable', 'sale_order_id', 'partner_id', 'pricing_type')
def _compute_display_create_order(self):
for project in self:
show = True
if not project.partner_id or project.bill_type != 'customer_project' or not project.allow_billable or project.sale_order_id:
if not project.partner_id or project.pricing_type == 'task_rate' or not project.allow_billable or project.sale_order_id:
show = False
project.display_create_order = show
@@ -80,11 +64,10 @@ class Project(models.Model):
elif not project.timesheet_product_id:
project.timesheet_product_id = default_product
@api.depends('pricing_type', 'allow_timesheets', 'allow_billable', 'sale_line_employee_ids', 'sale_line_employee_ids.employee_id', 'bill_type')
@api.depends('pricing_type', 'allow_timesheets', 'allow_billable', 'sale_line_employee_ids', 'sale_line_employee_ids.employee_id')
def _compute_warning_employee_rate(self):
projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.bill_type == 'customer_project' and p.pricing_type == 'employee_rate')
tasks = projects.task_ids.filtered(lambda t: not t.non_allow_billable)
employees = self.env['account.analytic.line'].read_group([('task_id', 'in', tasks.ids), ('non_allow_billable', '=', False)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False)
projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.pricing_type == 'employee_rate')
employees = self.env['account.analytic.line'].read_group([('task_id', 'in', projects.task_ids.ids)], ['employee_id', 'project_id'], ['employee_id', 'project_id'], ['employee_id', 'project_id'], lazy=False)
dict_project_employee = defaultdict(list)
for line in employees:
dict_project_employee[line['project_id'][0]] += [line['employee_id'][0]]
@@ -125,13 +108,9 @@ class Project(models.Model):
})
return res
def _get_not_billed_timesheets(self):
return self.sudo(False).mapped('timesheet_ids').filtered(
lambda t: not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel')
def _update_timesheets_sale_line_id(self):
for project in self.filtered(lambda p: p.allow_billable and p.allow_timesheets):
timesheet_ids = project._get_not_billed_timesheets()
timesheet_ids = project.sudo(False).mapped('timesheet_ids').filtered(lambda t: not t.is_so_line_edited and t._is_not_billed())
if not timesheet_ids:
continue
for employee_id in project.sale_line_employee_ids.filtered(lambda l: l.project_id == project).employee_id:
@@ -207,37 +186,15 @@ class Project(models.Model):
class ProjectTask(models.Model):
_inherit = "project.task"
@api.model
def default_get(self, fields):
result = super(ProjectTask, self).default_get(fields)
if not result.get('timesheet_product_id', False) and 'project_id' in result:
project = self.env['project.project'].browse(result['project_id'])
if project.bill_type != 'customer_project' or project.pricing_type != 'employee_rate':
result['timesheet_product_id'] = project.timesheet_product_id.id
return result
# override sale_order_id and make it computed stored field instead of regular field.
sale_order_id = fields.Many2one(compute='_compute_sale_order_id', store=True, readonly=False,
domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]")
analytic_account_id = fields.Many2one('account.analytic.account', related='sale_order_id.analytic_account_id')
bill_type = fields.Selection(related="project_id.bill_type")
pricing_type = fields.Selection(related="project_id.pricing_type")
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
has_multi_sol = fields.Boolean(compute='_compute_has_multi_sol', compute_sudo=True)
allow_billable = fields.Boolean(related="project_id.allow_billable")
display_create_order = fields.Boolean(compute='_compute_display_create_order')
timesheet_product_id = fields.Many2one(
'product.product', string='Service',
domain="""[
('type', '=', 'service'),
('invoice_policy', '=', 'delivery'),
('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 this task.')
# TODO: [XBO] remove me in master
non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheets linked to this task will not be billed.")
timesheet_product_id = fields.Many2one(related="project_id.timesheet_product_id")
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")
@@ -261,39 +218,18 @@ class ProjectTask(models.Model):
for task in self:
task.remaining_hours_so = mapped_remaining_hours[task._origin.id]
@api.depends(
'allow_billable', 'allow_timesheets', 'sale_order_id')
def _compute_display_create_order(self):
for task in self:
show = True
if not task.allow_billable or not task.allow_timesheets or \
(task.bill_type != 'customer_task' and not task.timesheet_product_id) or (not task.partner_id and task.bill_type != 'customer_task') or \
task.sale_order_id or (task.bill_type != 'customer_task' and task.pricing_type != 'employee_rate'):
show = False
task.display_create_order = show
@api.onchange('sale_line_id')
def _onchange_sale_line_id(self):
# TODO: remove me in master
return
@api.onchange('project_id')
def _onchange_project_id(self):
# TODO: remove me in master
return
@api.depends('analytic_account_id.active')
def _compute_analytic_account_active(self):
super()._compute_analytic_account_active()
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', 'non_allow_billable')
@api.depends('sale_line_id', 'project_id', 'allow_billable')
def _compute_sale_order_id(self):
for task in self:
if not task.allow_billable or task.non_allow_billable:
if not task.allow_billable:
task.sale_order_id = False
elif task.allow_billable:
else:
if task.sale_line_id:
task.sale_order_id = task.sale_line_id.sudo().order_id
elif task.project_id.sale_order_id:
@@ -320,7 +256,7 @@ class ProjectTask(models.Model):
@api.onchange('project_id')
def _onchange_project(self):
if self.project_id and self.project_id.bill_type == 'customer_project':
if self.project_id and self.project_id.pricing_type != 'task_rate':
if not self.partner_id:
self.partner_id = self.project_id.partner_id
if not self.sale_line_id:
@@ -331,23 +267,8 @@ class ProjectTask(models.Model):
# Done after super to avoid constraints on field recomputation
if values.get('project_id'):
project_dest = self.env['project.project'].browse(values['project_id'])
if project_dest.bill_type == 'customer_project' and project_dest.pricing_type == 'employee_rate':
if project_dest.pricing_type == 'employee_rate':
self.write({'sale_line_id': False})
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')
)
if values['non_allow_billable']:
timesheet_ids.write({'so_line': False})
self.sale_line_id = False
else:
# We write project on timesheet lines to call _timesheet_preprocess. This function will set correct the SOL
for project in timesheet_ids.project_id:
current_timesheet_ids = timesheet_ids.filtered(lambda t: t.project_id == project)
current_timesheet_ids.task_id.update({'sale_line_id': project.sale_line_id.id})
for employee in current_timesheet_ids.employee_id:
current_timesheet_ids.filtered(lambda t: t.employee_id == employee).write({'project_id': project.id})
return res
def _get_last_sol_of_customer(self):
@@ -355,29 +276,10 @@ class ProjectTask(models.Model):
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_project' and self.project_sale_order_id:
domain = [('is_service', '=', True), ('order_partner_id', 'child_of', self.commercial_partner_id.id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done']), ('remaining_hours', '>', 0)]
if self.project_id.pricing_type != 'task_rate' 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"),
"type": 'ir.actions.act_window',
"res_model": 'project.task.create.sale.order',
"views": [[False, "form"]],
"target": 'new',
"context": {
'active_id': self.id,
'active_model': 'project.task',
'form_view_initial_mode': 'edit',
'default_product_id': self.timesheet_product_id.id or self.project_id.timesheet_product_id.id,
},
}
return self.env['sale.order.line'].search(domain, limit=1)
def _get_timesheet(self):
# return not invoiced timesheet and timesheet without so_line or so_line linked to task
@@ -394,7 +394,7 @@ class Project(models.Model):
task_order_line_ids = [ol['sale_line_id'][0] for ol in task_order_line_ids]
if self.env.user.has_group('sales_team.group_sale_salesman'):
if self.bill_type == 'customer_project' and self.allow_billable and not self.sale_order_id:
if self.pricing_type != 'task_rate' and self.allow_billable and not self.sale_order_id:
actions.append({
'label': _("Create a Sales Order"),
'type': 'action',
@@ -10,7 +10,7 @@ class ProjectProductEmployeeMap(models.Model):
project_id = fields.Many2one('project.project', "Project", required=True)
employee_id = fields.Many2one('hr.employee', "Employee", required=True)
sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", domain=[('is_service', '=', True)])
sale_line_id = fields.Many2one('sale.order.line', "Sale Order Item", compute="_compute_sale_line_id", store=True, readonly=False, required=True, domain=[('is_service', '=', True)])
company_id = fields.Many2one('res.company', string='Company', related='project_id.company_id')
timesheet_product_id = fields.Many2one(
'product.product', string='Service',
@@ -26,6 +26,10 @@ class ProjectProductEmployeeMap(models.Model):
('uniqueness_employee', 'UNIQUE(project_id,employee_id)', 'An employee cannot be selected more than once in the mapping. Please remove duplicate(s) and try again.'),
]
@api.depends('project_id.sale_order_id')
def _compute_sale_line_id(self):
self.filtered(lambda map_entry: not map_entry.project_id.sale_order_id and map_entry.sale_line_id).update({'sale_line_id': None})
@api.depends('sale_line_id', 'sale_line_id.price_unit', 'timesheet_product_id')
def _compute_price_unit(self):
for line in self:
+8 -8
View File
@@ -1,9 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import math
from odoo import api, fields, models, _
from odoo.osv import expression
import math
class SaleOrder(models.Model):
@@ -13,7 +14,7 @@ class SaleOrder(models.Model):
timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user")
# override domain
project_id = fields.Many2one(domain="['|', ('bill_type', '=', 'customer_task'), ('pricing_type', '=', 'fixed_rate'), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]")
project_id = fields.Many2one(domain="[('pricing_type', 'in', ('fixed_rate', 'task_rate')), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]")
timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
timesheet_total_duration = fields.Integer("Timesheet Total Duration", compute='_compute_timesheet_total_duration', help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit")
@@ -34,7 +35,7 @@ class SaleOrder(models.Model):
for sale_order in self:
timesheets = sale_order.timesheet_ids if self.user_has_groups('hr_timesheet.group_hr_timesheet_approver') else sale_order.timesheet_ids.filtered(lambda t: t.user_id.id == self.env.uid)
total_time = 0.0
for timesheet in timesheets.filtered(lambda t: not t.non_allow_billable):
for timesheet in timesheets:
# Timesheets may be stored in a different unit of measure, so first we convert all of them to the reference unit
total_time += timesheet.unit_amount * timesheet.product_uom_id.factor_inv
# Now convert to the proper unit of measure
@@ -79,7 +80,7 @@ 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')
remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours', store=True)
def name_get(self):
res = super(SaleOrderLine, self).name_get()
@@ -103,7 +104,7 @@ class SaleOrderLine(models.Model):
hours += 1
else:
minutes = minutes * 30
remaining_time =' ({sign}{hours:02.0f}:{minutes:02.0f})'.format(
remaining_time = ' ({sign}{hours:02.0f}:{minutes:02.0f})'.format(
sign='-' if line.remaining_hours < 0 else '',
hours=hours,
minutes=minutes)
@@ -147,7 +148,7 @@ class SaleOrderLine(models.Model):
if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
line.qty_delivered_method = 'timesheet'
@api.depends('analytic_line_ids.project_id', 'analytic_line_ids.non_allow_billable', 'project_id.pricing_type', 'project_id.bill_type')
@api.depends('analytic_line_ids.project_id', 'project_id.pricing_type')
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
@@ -159,7 +160,7 @@ class SaleOrderLine(models.Model):
def _timesheet_compute_delivered_quantity_domain(self):
""" Hook for validated timesheet in addionnal module """
return [('project_id', '!=', False), ('non_allow_billable', '=', False)]
return [('project_id', '!=', False)]
###########################################
# Service : Project and task generation
@@ -182,7 +183,6 @@ class SaleOrderLine(models.Model):
"""Generate project values"""
values = super()._timesheet_create_project_prepare_values()
values['allow_billable'] = True
values['bill_type'] = 'customer_project'
values['pricing_type'] = 'fixed_rate'
return values
@@ -1,68 +0,0 @@
odoo.define('sale_timesheet.sale_project_kanban_controller', function (require) {
"use strict";
var core = require('web.core');
var ProjectKanbanController = require('project.project_kanban');
var session = require('web.session');
var QWeb = core.qweb;
// YTI TODO : Master remove file
var SaleProjectKanbanController = ProjectKanbanController.include({
//--------------------------------------------------------------------------
// Private
//--------------------------------------------------------------------------
_showCreateSOButton: async function () {
var self = this;
this.activeProjectIds = this.initialState.context.active_ids;
if (!this.activeProjectIds || this.activeProjectIds.length !== 1) {
this.showCreateSaleOrder = false;
return;
}
var canCreateSO = await session.user_has_group('sales_team.group_sale_salesman');
if (canCreateSO) {
await this._rpc({
model: 'project.project',
method: 'search_count',
args: [[
["id", "in", this.activeProjectIds],
["bill_type", "=", "customer_project"],
["sale_order_id", "=", false],
["allow_billable", "=", true],
["allow_timesheets", "=", true],
]],
}).then(function (projectCount) {
self.showCreateSaleOrder = projectCount !== 0;
});
} else {
this.showCreateSaleOrder = false;
}
},
//--------------------------------------------------------------------------
// Handlers
//--------------------------------------------------------------------------
/**
* @private
* @param {MouseEvent} ev
*/
_onCreateSaleOrder: function (ev) {
ev.preventDefault();
this.do_action('sale_timesheet.project_project_action_multi_create_sale_order', {
additional_context: {
'active_id': this.activeProjectIds && this.activeProjectIds[0],
'active_model': "project.project",
},
on_close: async () => await this.reload()
});
},
});
return SaleProjectKanbanController;
});
@@ -1,4 +1,4 @@
odoo.define('sale_timesheet_edit.so_line_many2one', function (require) {
odoo.define('sale_timesheet.so_line_many2one', function (require) {
"use strict";
const fieldRegistry = require('web.field_registry');
@@ -25,4 +25,6 @@ const SoLineOne2Many = FieldOne2Many.extend({
fieldRegistry.add('so_line_one2many', SoLineOne2Many);
return SoLineOne2Many;
});
@@ -11,7 +11,7 @@ class TestProjectBilling(TestCommonSaleTimesheet):
@classmethod
def setUpClass(cls, chart_template_ref=None):
super().setUpClass(chart_template_ref=chart_template_ref)
# set up
cls.employee_tde = cls.env['hr.employee'].create({
'name': 'Employee TDE',
@@ -97,7 +97,6 @@ class TestProjectBilling(TestCommonSaleTimesheet):
'name': "Project billed at Employee Rate",
'allow_timesheets': True,
'allow_billable': True,
'bill_type': 'customer_project',
'pricing_type': 'employee_rate',
'sale_order_id': cls.sale_order_1.id,
'partner_id': cls.sale_order_1.partner_id.id,
@@ -120,7 +119,8 @@ class TestProjectBilling(TestCommonSaleTimesheet):
Task = self.env['project.task']
# set a customer on the project
self.project_non_billable.write({
'partner_id': self.partner_2.id
'partner_id': self.partner_2.id,
'timesheet_product_id': self.product_delivery_timesheet3,
})
# create a task and 2 timesheets
task = Task.with_context(default_project_id=self.project_non_billable.id).create({
@@ -146,10 +146,8 @@ class TestProjectBilling(TestCommonSaleTimesheet):
# Change project to billable at task rate
self.project_non_billable.write({
'allow_billable': True,
'bill_type': 'customer_project',
'pricing_type': 'fixed_rate',
})
task.timesheet_product_id = self.product_delivery_timesheet3
# create wizard
wizard = self.env['project.create.sale.order'].with_context(active_id=self.project_non_billable.id, active_model='project.project').create({})
@@ -202,7 +200,6 @@ class TestProjectBilling(TestCommonSaleTimesheet):
# Change project to billable at employee rate
self.project_non_billable.write({
'allow_billable': True,
'bill_type': 'customer_project',
'pricing_type': 'employee_rate',
})
@@ -260,7 +257,6 @@ class TestProjectBilling(TestCommonSaleTimesheet):
})
self.assertTrue(task.allow_billable, "Task in project 'employee rate' should be billable")
self.assertEqual(task.bill_type, 'customer_project', "Task in project 'employee rate' should be billed at employee rate")
self.assertEqual(task.pricing_type, 'employee_rate', "Task in project 'employee rate' should be billed at employee rate")
self.assertFalse(task.sale_line_id, "Task created in a project billed on 'employee rate' should not be linked to a SOL")
self.assertEqual(task.partner_id, task.project_id.partner_id, "Task created in a project billed on 'employee rate' should have the same customer as the one from the project")
@@ -17,7 +17,7 @@ class TestProjectBillingMulticompany(TestCommonSaleTimesheet):
'name': "Non Billable Project",
'allow_timesheets': True,
'allow_billable': True,
'bill_type': 'customer_project',
'pricing_type': 'fixed_rate',
'company_id': cls.env.company.id,
})
@@ -5,7 +5,7 @@
<xpath expr="." position="inside">
<link rel="stylesheet" type="text/scss" href="/sale_timesheet/static/src/scss/sale_timesheet.scss"/>
<script type="text/javascript" src="/sale_timesheet/static/src/js/project_overview.js"></script>
<script type="text/javascript" src="/sale_timesheet/static/src/js/sale_project_kanban_controller.js"/>
<script type="text/javascript" src="/sale_timesheet/static/src/js/so_line_one2many.js"></script>
</xpath>
</template>
@@ -10,8 +10,8 @@
<field name="timesheet_invoice_type"/>
</xpath>
<xpath expr="//filter[@name='month']" position="before">
<filter name="billable_timesheet" string="Billable" domain="[('so_line', '!=', False), ('non_allow_billable', '=', False)]"/>
<filter name="non_billable_timesheet" string="Non Billable" domain="['|', ('so_line', '=', False), ('non_allow_billable', '=', True)]"/>
<filter name="billable_timesheet" string="Billable" domain="[('so_line', '!=', False)]"/>
<filter name="non_billable_timesheet" string="Non Billable" domain="[('so_line', '=', False)]"/>
<separator/>
<filter name="billable_time" string="Billed on Timesheets" domain="[('timesheet_invoice_type', '=', 'billable_time')]"/>
<filter name="billable_fixed" string="Billed at a Fixed Price" domain="[('timesheet_invoice_type', '=', 'billable_fixed')]"/>
@@ -36,17 +36,6 @@
</field>
</record>
<record id="timesheet_view_tree_sale" model="ir.ui.view">
<field name="name">account.analytic.line.view.tree.with.allow.billable</field>
<field name="model">account.analytic.line</field>
<field name="inherit_id" ref="hr_timesheet.timesheet_view_tree_user"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='user_id']" position="after">
<field name="non_allow_billable" attrs="{'invisible': [('non_allow_billable', '=', False)]}"/>
</xpath>
</field>
</record>
<!--
Timesheet from Sales Order
-->
@@ -10,7 +10,7 @@
<button string="Project Overview" class="oe_stat_button" type="object" name="action_view_timesheet" icon="fa-puzzle-piece" attrs="{'invisible': [('allow_billable', '=', False)]}"/>
<button class="d-none d-md-inline oe_stat_button"
type="object" name="action_view_so" icon="fa-dollar"
attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('bill_type', '!=', 'customer_project')]}"
attrs="{'invisible': ['|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('pricing_type', '=', 'task_rate')]}"
string="Sales Order"
groups="sales_team.group_sale_salesman"/>
</div>
@@ -22,19 +22,17 @@
<group>
<group>
<field name="display_create_order" invisible="1"/>
<field name="bill_type" widget="radio"/>
<field name="pricing_type" attrs="{'invisible': ['|', ('allow_billable', '=', False), ('bill_type', '!=', 'customer_project')], 'required': ['&amp;', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/>
<field name="timesheet_product_id" string="Service" attrs="{'invisible': ['|', '|', ('allow_timesheets', '=', False), ('sale_order_id', '!=', False), ('bill_type', '!=', 'customer_task')], 'required': ['&amp;', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
<field name="sale_order_id" attrs="{'invisible': [('bill_type', '!=', 'customer_project')], 'readonly': [('sale_order_id', '!=', False)]}" force_save="1" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
<field name="sale_line_id" string="Default Sales Order Item" attrs="{'invisible': [('bill_type', '!=', 'customer_project')]}" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
<field name="pricing_type" attrs="{'invisible': [('allow_billable', '=', False)], 'required': ['&amp;', ('allow_billable', '=', True), ('allow_timesheets', '=', True)]}" widget="radio"/>
<field name="timesheet_product_id" string="Service" invisible="1" context="{'default_type': 'service', 'default_service_policy': 'delivered_timesheet', 'default_service_type': 'timesheet'}"/>
<field name="sale_order_id" attrs="{'invisible': [('pricing_type', '=', 'task_rate')], 'readonly': [('sale_order_id', '!=', False)]}" force_save="1" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
<field name="sale_line_id" string="Default Sales Order Item" attrs="{'invisible': [('pricing_type', '=', 'task_rate')]}" options="{'no_create': True, 'no_edit': True, 'delete': False, 'no_open': True}"/>
</group>
</group>
<field name="sale_line_employee_ids" attrs="{'invisible': ['|', ('bill_type', '!=', 'customer_project'), ('pricing_type', '!=', 'employee_rate')]}">
<field name="sale_line_employee_ids" attrs="{'invisible': [('pricing_type', '!=', 'employee_rate')]}">
<tree editable="top">
<field name="company_id" invisible="1"/>
<field name="project_id" invisible="1"/>
<field name="employee_id" options="{'no_create': True}"/>
<field name="timesheet_product_id" attrs="{'column_invisible': [('parent.sale_order_id', '!=', False)]}" invisible="1"/>
<field name="sale_line_id" attrs="{'required': True}" options="{'no_create': True}" domain="[('order_id','=',parent.sale_order_id), ('is_service', '=', True)]"/>
<field name="price_unit" widget="monetary" force_save="1" options="{'currency_field': 'currency_id'}"/>
<field name="currency_id" invisible="1"/>
@@ -122,8 +120,6 @@
<field name="arch" type="xml">
<xpath expr="//header" position='inside'>
<field name="allow_billable" invisible="1"/>
<field name="display_create_order" invisible="1"/>
<button name="action_make_billable" string="Create Sales Order" type="object" attrs="{'invisible': [('display_create_order', '=', False)]}" groups="sales_team.group_sale_salesman" invisible="1"/>
</xpath>
<xpath expr="//field[@name='email_cc']" position="after">
<field name="analytic_account_id" groups="base.group_no_one"/>
@@ -144,21 +140,37 @@
<field name="has_multi_sol" invisible="1"/>
</xpath>
<xpath expr="//field[@name='partner_phone']" position="after">
<field name="bill_type" invisible="1"/>
<field name="pricing_type" invisible="1"/>
<field name="timesheet_product_id" invisible="1"/>
<field name="non_allow_billable" attrs="{'invisible': ['|', '|', '|', ('allow_billable', '=', False), ('allow_timesheets', '=', False), ('pricing_type', '!=', 'employee_rate'), ('bill_type', '=', 'customer_task')]}" invisible="1"/>
</xpath>
<xpath expr="//field[@name='timesheet_ids']" position="attributes">
<attribute name="widget">so_line_one2many</attribute>
</xpath>
<xpath expr="//field[@name='timesheet_ids']/tree" position="inside">
<field name="is_so_line_edited" invisible="1" />
</xpath>
<xpath expr="//field[@name='timesheet_ids']/tree/field[@name='unit_amount']" position="before">
<field name="timesheet_invoice_id" invisible="1"/>
<field name="so_line" readonly="1" attrs="{'column_invisible': [('parent.allow_billable', '=', False)]}" context="{'with_remaining_hours': True}" optional="hide"/>
</xpath>
<xpath expr="//field[@name='timesheet_ids']/tree" position="inside">
<field name="non_allow_billable" attrs="{'column_invisible': ['|', '&amp;', '&amp;', '|', ('parent.bill_type', '!=', 'customer_project'), ('parent.pricing_type', '!=', 'employee_rate'), ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&amp;', ('parent.bill_type', '=', 'customer_project'), ('parent.pricing_type', '=', 'employee_rate')]}" invisible="1"/>
<field name="so_line"
attrs="{'column_invisible': [('parent.allow_billable', '=', False)]}"
context="{'with_remaining_hours': True}" options="{'no_create': True, 'no_open': True}"
domain="[('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)]"
optional="hide"/>
</xpath>
</field>
</record>
<record id="project_task_view_form_inherit_sale_timesheet_editable" model="ir.ui.view">
<field name="name">project.task.form.view.form.inherit.sale.timesheet.editable</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project_task_view_form_inherit_sale_timesheet"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='timesheet_ids']/tree/field[@name='so_line']" position="attributes">
<attribute name="options">{'no_create': True}</attribute>
</xpath>
</field>
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
<record id="view_task_form2_inherit_sale_timesheet" model="ir.ui.view">
<field name="name">view.task.form2.inherit</field>
<field name="model">project.task</field>
@@ -190,17 +202,6 @@
</field>
</record>
<record id="quick_create_task_form_sale_timesheet" model="ir.ui.view">
<field name="name">project.task.form.inherit.timesheet</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project.quick_create_task_form"/>
<field name="arch" type="xml">
<field name="project_id" position="after">
<field name="timesheet_product_id" invisible="1"/>
</field>
</field>
</record>
<record id="project_timesheet_action_client_timesheet_plan" model="ir.actions.act_window">
<field name="name">Overview</field>
<field name="res_model">project.project</field>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<template id="report_invoice_document" inherit_id="account.report_invoice_document">
<xpath expr="//div[hasclass('page')]/h2" position="after">
<a t-if="report_type == 'html' and o.move_type == 'out_invoice' and o.state in ('draft', 'posted') and o.timesheet_count > 0" target="_blank" t-att-href="'/my/timesheets?search_in=invoice_id&amp;search=%s' % o.id">View Timesheets</a>
</xpath>
</template>
</data>
</odoo>
@@ -7,123 +7,9 @@
</xpath>
</template>
<!-- TODO: [XBO] Remove me in master -->
<template id="portal_invoice_page_inherit_timesheet" inherit_id="account.portal_invoice_page">
<xpath expr="//t[@t-call='portal.portal_record_sidebar']//div[hasclass('o_download_pdf')]" position="after">
<t t-if="1 == 0">
<li t-if="timesheets" class="list-group-item flex-grow-1" >
<a href="#accordion">Timesheets</a>
</li>
</t>
</xpath>
<xpath expr="//div[@id='invoice_content']//div[hasclass('o_portal_html_view')]" position="after">
<t t-if="1 == 0">
<div t-if="timesheets" class="container">
<div id="accordion" class="o_timesheet_accordion mt-4">
<div class="card mb-0">
<div class="card-header">
<h5 class="mb0">
<a class="card-title" data-toggle="collapse" href="#collapseTimesheet">
Timesheets
</a>
</h5>
</div>
<div id="collapseTimesheet" class="card-body show" data-parent="#accordion">
<t t-set="nr_tasks" t-value="len(timesheets.mapped('task_id'))"/>
<t t-set="nr_projects" t-value="len(timesheets.mapped('project_id'))"/>
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Employee</th>
<th t-if="nr_projects &gt; 1">Project</th>
<th t-if="nr_tasks &gt; 0">Task</th>
<th>Description</th>
<th t-if="timesheets[0]._is_timesheet_encode_uom_day()" class="text-right">Duration (days)</th>
<th t-else="" class="text-right">Duration (hours)</th>
</tr>
</thead>
<tr t-foreach="timesheets" t-as="timesheet">
<td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td>
<td><t t-esc="timesheet.employee_id.name"/></td>
<td t-if="nr_projects &gt; 1"><span t-field="timesheet.project_id"/></td>
<td t-if="nr_tasks &gt; 0"><span t-field="timesheet.task_id"/></td>
<td><t t-esc="timesheet.name"/></td>
<td class="text-right">
<span t-if="timesheet._is_timesheet_encode_uom_day()" t-esc="timesheet._get_timesheet_time_day()" t-options='{"widget": "timesheet_uom"}'/>
<span t-else="" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</t>
</xpath>
</template>
<template id="sale_order_portal_content_inherit" inherit_id="sale.sale_order_portal_content">
<xpath expr="//td[@id='product_name']" position="inside">
<a t-if="timesheets and len(timesheets.filtered(lambda t: t.so_line == line)) > 0" t-att-href="'/my/timesheets?search_in=sol_id&amp;search=%s' % line.id">View Timesheets</a>
</xpath>
</template>
<!-- TODO: [XBO] Remove me in master -->
<template id="sale_order_portal_template_inherit" inherit_id="sale.sale_order_portal_template">
<xpath expr="//t[@t-call='portal.portal_record_sidebar']//div[hasclass('o_download_pdf')]" position="after">
<t t-if="1 == 0">
<li t-if="timesheets" class="list-group-item flex-grow-1" >
<a href="#accordion">Timesheets</a>
</li>
</t>
</xpath>
<xpath expr="//div[@id='sale_order_communication']" position="before">
<t t-if="1 == 0">
<div t-if="timesheets" class="container">
<div id="accordion" class="o_timesheet_accordion mt-4">
<div class="card mb-0">
<div class="card-header">
<h5 class="mb0">
<a class="card-title" data-toggle="collapse" href="#collapseTimesheet">
Timesheets
</a>
</h5>
</div>
<div id="collapseTimesheet" class="card-body show" data-parent="#accordion">
<t t-set="nr_tasks" t-value="len(timesheets.mapped('task_id'))"/>
<t t-set="nr_projects" t-value="len(timesheets.mapped('project_id'))"/>
<table class="table table-sm">
<thead>
<tr>
<th>Date</th>
<th>Employee</th>
<th t-if="nr_projects &gt; 1">Project</th>
<th t-if="nr_tasks &gt; 0">Task</th>
<th>Description</th>
<th t-if="timesheets[0]._is_timesheet_encode_uom_day()" class="text-right">Duration (days)</th>
<th t-else="" class="text-right">Duration (hours)</th>
</tr>
</thead>
<tr t-foreach="timesheets" t-as="timesheet">
<td><t t-esc="timesheet.date" t-options='{"widget": "date"}'/></td>
<td><t t-esc="timesheet.employee_id.name"/></td>
<td t-if="nr_projects &gt; 1"><span t-field="timesheet.project_id"/></td>
<td t-if="nr_tasks &gt; 0"><span t-field="timesheet.task_id"/></td>
<td><t t-esc="timesheet.name"/></td>
<td class="text-right">
<span t-if="timesheet._is_timesheet_encode_uom_day()" t-esc="timesheet._get_timesheet_time_day()" t-options='{"widget": "timesheet_uom"}'/>
<span t-else="" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</t>
<a t-if="timesheets and len(timesheets.filtered(lambda t: t.so_line == line)) > 0" t-att-href="'/my/timesheets?search_in=so&amp;search=%s' % line.order_id.name">View Timesheets</a>
</xpath>
</template>
@@ -158,4 +44,17 @@
</xpath>
</template>
<template id="portal_invoice_page_inherit" inherit_id="account.portal_invoice_page">
<xpath expr="//t[@t-set='entries']/ul/li[.//a[@id='print_invoice_report']]" position="after">
<li class="list-group-item flex-grow-1">
<div class="btn-toolbar flex-sm-nowrap justify-content-center">
<div class="btn-group mb-1">
<a t-if="invoice.move_type == 'out_invoice' and invoice.state in ('draft', 'posted') and invoice.timesheet_count > 0"
target="_blank" t-att-href="'/my/timesheets?search_in=invoice&amp;search=%s' % invoice.name">View Timesheets</a>
</div>
</div>
</li>
</xpath>
</template>
</odoo>
@@ -25,7 +25,7 @@ class ProjectCreateSalesOrder(models.TransientModel):
result['project_id'] = active_id
if not result.get('partner_id', False):
result['partner_id'] = project.partner_id.id
if project.bill_type == 'customer_project' and not result.get('line_ids', False):
if project.pricing_type != 'task_rate' and not result.get('line_ids', False):
if project.pricing_type == 'employee_rate':
default_product = self.env.ref('sale_timesheet.time_product', False)
result['line_ids'] = [
@@ -66,9 +66,8 @@ class ProjectCreateSalesOrder(models.TransientModel):
@api.depends('sale_order_id', 'link_selection')
def _compute_info_invoice(self):
for line in self:
tasks = line.project_id.tasks.filtered(lambda t: not t.non_allow_billable)
domain = self.env['sale.order.line']._timesheet_compute_delivered_quantity_domain()
timesheet = self.env['account.analytic.line'].read_group(domain + [('task_id', 'in', tasks.ids), ('so_line', '=', False), ('timesheet_invoice_id', '=', False)], ['unit_amount'], ['task_id'])
timesheet = self.env['account.analytic.line'].read_group(domain + [('task_id', 'in', line.project_id.tasks.ids), ('so_line', '=', False), ('timesheet_invoice_id', '=', False)], ['unit_amount'], ['task_id'])
unit_amount = round(sum(t.get('unit_amount', 0) for t in timesheet), 2) if timesheet else 0
if not unit_amount:
line.info_invoice = False
@@ -111,11 +110,9 @@ class ProjectCreateSalesOrder(models.TransientModel):
'sale_line_id': wizard_line.sale_line_id.id
})
self.project_id.tasks.filtered(lambda task: task.non_allow_billable).sale_line_id = False
tasks = self.project_id.tasks.filtered(lambda t: not t.non_allow_billable)
# assign SOL to timesheets
for map_entry in self.project_id.sale_line_employee_ids:
self.env['account.analytic.line'].search([('task_id', 'in', tasks.ids), ('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False)]).write({
self.env['account.analytic.line'].search([('task_id', 'in', self.project_id.tasks.ids), ('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False)]).write({
'so_line': map_entry.sale_line_id.id
})
else:
@@ -193,7 +190,6 @@ class ProjectCreateSalesOrder(models.TransientModel):
def _make_billable_at_project_rate(self, sale_order):
self.ensure_one()
task_left = self.project_id.tasks.filtered(lambda task: not task.sale_line_id)
ticket_timesheet_ids = self.env.context.get('ticket_timesheet_ids', [])
for wizard_line in self.line_ids:
task_ids = self.project_id.tasks.filtered(lambda task: not task.sale_line_id and task.timesheet_product_id == wizard_line.product_id)
task_left -= task_ids
@@ -213,17 +209,6 @@ class ProjectCreateSalesOrder(models.TransientModel):
'product_uom_qty': 0.0,
})
if ticket_timesheet_ids and not self.project_id.sale_line_id and not task_ids:
# With pricing = "project rate" in project. When the user wants to create a sale order from a ticket in helpdesk
# The project cannot contain any tasks. Thus, we need to give the first sale_order_line created to link
# the timesheet to this first sale order line.
# link the project to the SO line
self.project_id.write({
'sale_order_id': sale_order.id,
'sale_line_id': sale_order_line.id,
'partner_id': self.partner_id.id,
})
# link the tasks to the SO line
task_ids.write({
'sale_line_id': sale_order_line.id,
@@ -233,9 +218,6 @@ class ProjectCreateSalesOrder(models.TransientModel):
# assign SOL to timesheets
search_domain = [('task_id', 'in', task_ids.ids), ('so_line', '=', False)]
if ticket_timesheet_ids:
search_domain = [('id', 'in', ticket_timesheet_ids), ('so_line', '=', False)]
self.env['account.analytic.line'].search(search_domain).write({
'so_line': sale_order_line.id
})
@@ -243,18 +225,11 @@ class ProjectCreateSalesOrder(models.TransientModel):
'product_uom_qty': sale_order_line.qty_delivered
})
if ticket_timesheet_ids and self.project_id.sale_line_id and not self.project_id.tasks and len(self.line_ids) > 1:
# Then, we need to give to the project the last sale order line created
self.project_id.write({
'sale_line_id': sale_order_line.id
})
else: # Otherwise, we are in the normal behaviour
# link the project to the SO line
self.project_id.write({
'sale_order_id': sale_order.id,
'sale_line_id': sale_order_line.id, # we take the last sale_order_line created
'partner_id': self.partner_id.id,
})
self.project_id.write({
'sale_order_id': sale_order.id,
'sale_line_id': sale_order_line.id, # we take the last sale_order_line created
'partner_id': self.partner_id.id,
})
if task_left:
task_left.sale_line_id = False
@@ -268,7 +243,6 @@ class ProjectCreateSalesOrder(models.TransientModel):
lines_already_present = dict([(l.employee_id.id, l) for l in self.project_id.sale_line_employee_ids])
non_billable_tasks = self.project_id.tasks.filtered(lambda task: not task.sale_line_id)
non_allow_billable_tasks = self.project_id.tasks.filtered(lambda task: task.non_allow_billable)
map_entries = self.env['project.sale.line.employee.map']
EmployeeMap = self.env['project.sale.line.employee.map'].sudo()
@@ -314,17 +288,10 @@ class ProjectCreateSalesOrder(models.TransientModel):
'partner_id': sale_order.partner_id.id,
'email_from': sale_order.partner_id.email,
})
non_allow_billable_tasks.sale_line_id = False
tasks = self.project_id.tasks.filtered(lambda t: not t.non_allow_billable)
# assign SOL to timesheets
for map_entry in map_entries:
search_domain = [('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False)]
ticket_timesheet_ids = self.env.context.get('ticket_timesheet_ids', [])
if ticket_timesheet_ids:
search_domain.append(('id', 'in', ticket_timesheet_ids))
else:
search_domain.append(('task_id', 'in', tasks.ids))
search_domain = [('employee_id', '=', map_entry.employee_id.id), ('so_line', '=', False), ('task_id', 'in', self.project_id.tasks.ids)]
self.env['account.analytic.line'].search(search_domain).write({
'so_line': map_entry.sale_line_id.id
})
@@ -127,7 +127,7 @@ class ProjectTaskCreateSalesOrder(models.TransientModel):
'price_unit': self.price_unit,
'project_id': self.task_id.project_id.id, # prevent to re-create a project on confirmation
'task_id': self.task_id.id,
'product_uom_qty': round(sum(self.task_id.timesheet_ids.filtered(lambda t: not t.non_allow_billable and not t.so_line).mapped('unit_amount')), 2),
'product_uom_qty': round(sum(self.task_id.timesheet_ids.filtered(lambda t: not t.so_line).mapped('unit_amount')), 2),
})
# link task to SOL
-4
View File
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
@@ -1,25 +0,0 @@
# -*- 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,
}
@@ -1,5 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account_analytic_line
from . import project
@@ -1,20 +0,0 @@
# -*- 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
@@ -1,15 +0,0 @@
# -*- 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')
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="assets_backend" inherit_id="web.assets_backend">
<xpath expr="script[last()]" position="after">
<script type="text/javascript" src="/sale_timesheet_edit/static/src/js/so_line_one2many.js"></script>
</xpath>
</template>
</odoo>
@@ -1,41 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- This view can be removed to change only if it is needed in the inherit view in sale_timesheet -->
<record id="project_task_view_form_inherit_sale_timesheet_edit" model="ir.ui.view">
<field name="name">project.task.form.view.form.inherit.sale.timesheet.edit</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="sale_timesheet.project_task_view_form_inherit_sale_timesheet"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='timesheet_ids']" position="attributes">
<attribute name="widget">so_line_one2many</attribute>
</xpath>
<xpath expr="//field[@name='timesheet_ids']/tree/field[@name='so_line']" position="attributes">
<attribute name="readonly">0</attribute>
<attribute name="domain">[('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)]</attribute>
<attribute name="options">{'no_create': True, 'no_open': True}</attribute>
</xpath>
</field>
</record>
<!--
TODO: [XBO] In master, add this view in the sale_timesheet when we will merge of the both modules
Don't forget to change the inherit_id to have the correct view in sale_timesheet,
since the view above can be merged with project_task_view_form_inherit_sale_timesheet view.
-->
<record id="project_task_view_form_inherit_sale_timesheet_editable" model="ir.ui.view">
<field name="name">project.task.form.view.form.inherit.sale.timesheet.editable</field>
<field name="model">project.task</field>
<field name="inherit_id" ref="project_task_view_form_inherit_sale_timesheet_edit"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='timesheet_ids']/tree/field[@name='so_line']" position="attributes">
<attribute name="options">{'no_create': True}</attribute>
</xpath>
<xpath expr="//field[@name='timesheet_ids']/tree" position="inside">
<field name="is_so_line_edited" invisible="1" />
</xpath>
</field>
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
</record>
</odoo>