=======

This PR is in the continuity of odoo#62360.

- Display remaining hours in task sales_line_id (name_get + task form view)
- Remove internal reference from services demo data
- Display sol in project timesheets list if project is billable
- Remove use of non_allow_billable in timesheets as it has been removed from project and task in previous PR (see above)
- Only recompute planned_hours for service product
- Determine the correct SOL for timesheet
- Set the last SOL of customer on timesheet if none is set on task or project
- Allow edition of so_line in timesheet
- Restrict SOL on project to sale lines with a service product
- Use same widget on partner_id many2One than in sale.order (using ranking)
- Remove timesheets table in SO and invoice portal.They were added in previous PR (see above). This introduces the use of links to /my/timesheets/.
- Review portal timesheets (my/timesheets/ and link from orders and invoices)
- Activate group_uom "Units of Measure" on sale_timesheet install
- Hide partner phone number in task form view
- Only determine SOL of task and timesheet if allow_billable=True
- Filter SOL in task so that it matches the SOL of the project SO
- Display red label if remaining hours is negative
- Change SO compute behavior
- Only open SO for salesman on project overview

task-2409761

--
I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr

closes odoo/odoo#63695

Forward-port-of: #62900
X-original-commit: b1985773f3a67e28049f9e2bb635bcab059ad9c0
Related: odoo/enterprise#15427
Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
fw-bot
2020-12-22 13:40:28 +00:00
committed by Laurent Stukkens (LTU)
parent fef9634dfb
commit 0a8f4b6cda
34 changed files with 678 additions and 226 deletions
@@ -14,7 +14,6 @@
<field name="uom_po_id" ref="uom.product_uom_unit"/>
<field name="name">Event Registration</field>
<field name="description_sale" eval="False"/>
<field name="default_code">EVENT_REG</field>
<field name="categ_id" ref="event_sale.product_category_events"/>
<field name="type">service</field>
</record>
-2
View File
@@ -72,8 +72,6 @@ class EventTemplateTicket(models.Model):
'list_price': 0,
'standard_price': 0,
'type': 'service',
'default_code': 'EVENT_REG',
'type': 'service',
}).id
self.env['ir.model.data'].create({
'name': 'product_product_event',
@@ -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'
@@ -6,7 +6,6 @@
<field name="list_price">0.0</field>
<field name="standard_price">1.0</field>
<field name="type">service</field>
<field name="default_code">EXP_GEN</field>
<field name="categ_id" ref="product.cat_expense"/>
<field name="can_be_expensed" eval="True"/>
</record>
@@ -37,7 +37,6 @@
<field name="standard_price">0.32</field>
<field name="type">service</field>
<field name="name">Car Travel Expenses</field>
<field name="default_code">EXP_CT</field>
<field name="uom_id" ref="uom.product_uom_km"/>
<field name="uom_po_id" ref="uom.product_uom_km"/>
<field name="categ_id" ref="product.cat_expense"/>
@@ -50,7 +49,6 @@
<field name="standard_price">700.0</field>
<field name="type">service</field>
<field name="name">Air Flight</field>
<field name="default_code">EXP_AF</field>
<field name="categ_id" ref="product.cat_expense"/>
<field name="can_be_expensed" eval="True" />
<field name="image_1920" type="base64" file="hr_expense/static/img/air_ticket-image.jpg"/>
+46 -33
View File
@@ -23,32 +23,59 @@ class TimesheetCustomerPortal(CustomerPortal):
values['timesheet_count'] = request.env['account.analytic.line'].sudo().search_count(domain)
return values
def _get_searchbar_inputs(self):
return {
'all': {'input': 'all', 'label': _('Search in All')},
'project': {'input': 'project', 'label': _('Search in Project')},
'name': {'input': 'name', 'label': _('Search in Description')},
'employee': {'input': 'employee', 'label': _('Search in Employee')},
'task': {'input': 'task', 'label': _('Search in Task')}
}
def _get_searchbar_groupby(self):
return {
'none': {'input': 'none', 'label': _('None')},
'project': {'input': 'project', 'label': _('Project')},
'task': {'input': 'task', 'label': _('Task')},
'date': {'input': 'date', 'label': _('Date')},
'employee': {'input': 'employee', 'label': _('Employee')}
}
def _get_search_domain(self, search_in, search):
search_domain = []
if search_in in ('project', 'all'):
search_domain = OR([search_domain, [('project_id', 'ilike', search)]])
if search_in in ('name', 'all'):
search_domain = OR([search_domain, [('name', 'ilike', search)]])
if search_in in ('employee', 'all'):
search_domain = OR([search_domain, [('employee_id', 'ilike', search)]])
if search_in in ('task', 'all'):
search_domain = OR([search_domain, [('task_id', 'ilike', search)]])
return search_domain
def _get_groupby_mapping(self):
return {
'project': 'project_id',
'task': 'task_id',
'employee': 'employee_id',
'date': 'date'
}
@http.route(['/my/timesheets', '/my/timesheets/page/<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()
values = self._prepare_portal_layout_values()
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
_items_per_page = 100
searchbar_sortings = {
'date': {'label': _('Newest'), 'order': 'date desc'},
'name': {'label': _('Description'), 'order': 'name'},
}
searchbar_inputs = {
'all': {'input': 'all', 'label': _('Search in All')},
'project': {'input': 'project', 'label': _('Search in Project')},
'name': {'input': 'name', 'label': _('Search in Description')},
'employee': {'input': 'employee', 'label': _('Search in Employee')},
'task': {'input': 'task', 'label': _('Search in Task')},
}
searchbar_inputs = self._get_searchbar_inputs()
searchbar_groupby = {
'none': {'input': 'none', 'label': _('None')},
'project': {'input': 'project', 'label': _('Project')},
'task': {'input': 'task', 'label': _('Task')},
'date': {'input': 'date', 'label': _('Date')},
'employee': {'input': 'employee', 'label': _('Employee')},
}
searchbar_groupby = self._get_searchbar_groupby()
today = fields.Date.today()
quarter_start, quarter_end = date_utils.get_quarter(today)
@@ -77,16 +104,7 @@ class TimesheetCustomerPortal(CustomerPortal):
domain = AND([domain, searchbar_filters[filterby]['domain']])
if search and search_in:
search_domain = []
if search_in in ('project', 'all'):
search_domain = OR([search_domain, [('project_id', 'ilike', search)]])
if search_in in ('name', 'all'):
search_domain = OR([search_domain, [('name', 'ilike', search)]])
if search_in in ('employee', 'all'):
search_domain = OR([search_domain, [('employee_id', 'ilike', search)]])
if search_in in ('task', 'all'):
search_domain = OR([search_domain, [('task_id', 'ilike', search)]])
domain += search_domain
domain += self._get_search_domain(search_in, search)
timesheet_count = Timesheet_sudo.search_count(domain)
# pager
@@ -95,19 +113,14 @@ class TimesheetCustomerPortal(CustomerPortal):
url_args={'sortby': sortby, 'search_in': search_in, 'search': search, 'filterby': filterby, 'groupby': groupby},
total=timesheet_count,
page=page,
step=self._items_per_page
step=_items_per_page
)
def get_timesheets():
groupby_mapping = {
'project': 'project_id',
'task': 'task_id',
'employee': 'employee_id',
'date': 'date',
}
groupby_mapping = self._get_groupby_mapping()
field = groupby_mapping.get(groupby, None)
orderby = '%s, %s' % (field, order) if field else order
timesheets = Timesheet_sudo.search(domain, order=orderby, limit=self._items_per_page, offset=pager['offset'])
timesheets = Timesheet_sudo.search(domain, order=orderby, limit=_items_per_page, offset=pager['offset'])
if field:
if groupby == 'date':
time_data = Timesheet_sudo.read_group(domain, ['date', 'unit_amount:sum'], ['date:day'])
@@ -115,7 +128,7 @@ class TimesheetCustomerPortal(CustomerPortal):
grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k]) for k, g in groupbyelem(timesheets, itemgetter('date'))]
else:
time_data = time_data = Timesheet_sudo.read_group(domain, [field, 'unit_amount:sum'], [field])
mapped_time = dict([(m[field][0], m['unit_amount']) for m in time_data])
mapped_time = dict([(m[field][0] if m[field] else False, m['unit_amount']) for m in time_data])
grouped_timesheets = [(Timesheet_sudo.concat(*g), mapped_time[k.id]) for k, g in groupbyelem(timesheets, itemgetter(field))]
return timesheets, grouped_timesheets
@@ -36,24 +36,74 @@
<t t-foreach="grouped_timesheets" t-as="timesheets_with_hours">
<t t-set="timesheets" t-value="timesheets_with_hours[0]"/>
<t t-set="hours_spent" t-value="timesheets_with_hours[1]"/>
<thead>
<thead style="font-size: 0.8rem">
<tr t-if="not groupby =='none'" t-attf-class="{{'thead-light'}}">
<th t-if="groupby == 'project'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets for project:</em>
<span t-field="timesheets[0].project_id.name"/>
</th>
<th t-elif="groupby == 'task'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets for task:</em>
<span t-field="timesheets[0].task_id.name"/>
</th>
<th t-elif="groupby == 'date'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets on </em>
<span t-field="timesheets[0].date"/>
</th>
<th t-elif="groupby == 'employee'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets for employee:</em>
<span t-field="timesheets[0].employee_id.name"/>
</th>
<t t-if="groupby == 'project'">
<th t-if="groupby == 'project'" colspan="5">
<em class="font-weight-normal text-muted">Timesheets for project:</em>
<span t-field="timesheets[0].project_id.name"/>
</th>
<th colspan="1" class="text-right text-muted">
<t t-if="is_uom_day">
Total: <span class="text-muted" t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/>
</t>
<t t-else="">
Total: <span class="text-muted" t-esc="hours_spent" t-options='{"widget": "float_time"}'/>
</t>
</th>
</t>
<t t-elif="groupby == 'task'">
<th colspan="5">
<em class="font-weight-normal text-muted">Timesheets for task:</em>
<span t-field="timesheets[0].task_id.name"/>
</th>
<th colspan="1" class="text-right text-muted">
<t t-if="is_uom_day">
Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/>
</t>
<t t-else="">
Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>
</t>
</th>
</t>
<t t-elif="groupby == 'date'">
<th colspan="5">
<em class="font-weight-normal text-muted">Timesheets on </em>
<span t-field="timesheets[0].date"/>
</th>
<th colspan="1" class="text-right text-muted">
<t t-if="is_uom_day">
Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/>
</t>
<t t-else="">
Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>
</t>
</th>
</t>
<t t-elif="groupby == 'employee'">
<th colspan="5">
<em class="font-weight-normal text-muted">Timesheets for employee:</em>
<span t-field="timesheets[0].employee_id.name"/>
</th>
<th colspan="1" class="text-right text-muted">
<t t-if="is_uom_day">
Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/>
</t>
<t t-else="">
Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>
</t>
</th>
</t>
</tr>
<tr t-else="">
<div style="text-align: right;" class="mr-2 mb-1 text-muted">
<t t-if="is_uom_day">
Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/>
</t>
<t t-else="">
Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>
</t>
</div>
</tr>
<tr>
<th t-if="not groupby == 'date'">Date</th>
@@ -61,21 +111,21 @@
<th t-if="not groupby == 'project'">Project</th>
<th t-if="not groupby == 'task'">Task</th>
<th>Description</th>
<th t-if="is_uom_day" class="text-right">Days Spent <small class="text-muted">(Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/>)</small></th>
<th t-else="" class="text-right">Hours Spent <small class="text-muted">(Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>)</small></th>
<th t-if="is_uom_day" class="text-right">Days Spent</th>
<th t-else="" class="text-right">Hours Spent</th>
</tr>
</thead>
<tbody>
<tbody style="font-size: 0.8rem">
<t t-foreach="timesheets" t-as="timesheet">
<tr>
<td t-if="not groupby == 'date'"><span t-field="timesheet.date" t-options='{"widget": "date"}'/></td>
<td t-if="not groupby == 'employee'"><span t-field="timesheet.employee_id"/></td>
<td t-if="not groupby == 'project'"><span t-field="timesheet.project_id"/></td>
<td t-if="not groupby == 'task'"><span t-field="timesheet.task_id"/></td>
<td><span t-esc="timesheet.name"/></td>
<td t-if="not groupby == 'employee'"><span t-field="timesheet.employee_id" t-att-title="timesheet.employee_id.display_name" /></td>
<td t-if="not groupby == 'project'"><span t-field="timesheet.project_id" t-att-title="timesheet.project_id.display_name"/></td>
<td t-if="not groupby == 'task'"><span t-field="timesheet.task_id" t-att-title="timesheet.task_id.display_name"/></td>
<td><span t-esc="timesheet.name" t-att-title="timesheet.name"/></td>
<td class="text-right">
<span t-if="is_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"}'/>
<span t-else="" t-field="timesheet.unit_amount" t-options='{"widget": "float_time"}'/>
</td>
</tr>
</t>
+1 -1
View File
@@ -299,7 +299,7 @@
</div>
<t t-raw="0"/>
</div>
<form t-if="searchbar_inputs" class="form-inline o_portal_search_panel ml-lg-4">
<form t-if="searchbar_inputs" class="form-inline o_portal_search_panel ml-lg-4 col-xl-4 col-md-5">
<div class="input-group input-group-sm w-100">
<div class="input-group-prepend">
<button type="button" class="btn btn-secondary dropdown-toggle" data-toggle="dropdown"/>
-2
View File
@@ -37,7 +37,6 @@
<field name="list_price">14.0</field>
<field name="standard_price">8.0</field>
<field name="type">service</field>
<field name="default_code">EXP_REST</field>
<field name="categ_id" ref="product.cat_expense"/>
</record>
@@ -46,7 +45,6 @@
<field name="list_price">400.0</field>
<field name="standard_price">400.0</field>
<field name="type">service</field>
<field name="default_code">EXP_HA</field>
<field name="uom_id" ref="uom.product_uom_day"/>
<field name="uom_po_id" ref="uom.product_uom_day"/>
<field name="categ_id" ref="cat_expense"/>
+3 -3
View File
@@ -294,7 +294,7 @@
<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"/>
<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"
@@ -678,9 +678,9 @@
</group>
<group>
<field name="active" invisible="1"/>
<field name="partner_id" class="o_task_customer_field"/>
<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': [('partner_id', '=', False)]}"/>
<field name="partner_phone" widget="phone" attrs="{'invisible': True}"/>
<field name="legend_blocked" invisible="1"/>
<field name="legend_normal" invisible="1"/>
<field name="legend_done" invisible="1"/>
-1
View File
@@ -687,7 +687,6 @@ Thanks!</field>
<record id="advance_product_0" model="product.product">
<field name="name">Deposit</field>
<field name="default_code">Deposit</field>
<field name="categ_id" ref="product.product_category_3"/>
<field name="type">service</field>
<field name="list_price">150.0</field>
+5 -3
View File
@@ -12,10 +12,12 @@ class Project(models.Model):
sale_line_id = fields.Many2one(
'sale.order.line', 'Sales Order Item', copy=False,
domain="[('is_expense', '=', False), ('order_id', '=', sale_order_id), ('state', 'in', ['sale', 'done']), '|', ('company_id', '=', False), ('company_id', '=', company_id)]",
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', domain="[('partner_id', '=', partner_id)]", copy=False, help="Sales order to which the project is linked.")
sale_order_id = fields.Many2one('sale.order', 'Sales Order',
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')
_sql_constraints = [
@@ -56,7 +58,7 @@ class ProjectTask(models.Model):
task.partner_id = task.project_id.sale_line_id.order_partner_id
super()._compute_partner_id()
@api.depends('partner_id.commercial_partner_id', 'sale_line_id.order_partner_id.commercial_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_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')
def _compute_sale_line(self):
for task in self:
if not task.sale_line_id:
+1 -1
View File
@@ -170,7 +170,7 @@ class SaleOrderLine(models.Model):
# of a locked sale order.
if 'product_uom_qty' in values and not self.env.context.get('no_update_planned_hours', False):
for line in self:
if line.task_id:
if line.task_id and line.product_id.type == 'service':
planned_hours = line._convert_qty_company_hours(line.task_id.company_id)
line.task_id.write({'planned_hours': planned_hours})
return result
@@ -13,6 +13,18 @@
</field>
</record>
<record id="view_edit_project_inherit_form" model="ir.ui.view">
<field name="name">project.project.view.inherit</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="project.edit_project"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='partner_id']" position="attributes">
<attribute name="options">{'always_reload': True}</attribute>
<attribute name="context">{'res_partner_search_mode': 'customer'}</attribute>
</xpath>
</field>
</record>
<record id="view_sale_project_inherit_form" model="ir.ui.view">
<field name="name">project.task.view.inherit</field>
<field name="model">project.task</field>
@@ -26,9 +38,13 @@
string="Sales Order"
groups="sales_team.group_sale_salesman"/>
</div>
<xpath expr="//field[@name='partner_id']" position="attributes">
<attribute name="options">{'always_reload': True}</attribute>
<attribute name="context">{'res_partner_search_mode': 'customer'}</attribute>
</xpath>
<xpath expr="//field[@name='partner_phone']" position="after">
<field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': [('partner_id', '=', False)]}" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}" groups="sales_team.group_sale_salesman"/>
<field name="sale_order_id" attrs="{'invisible': [('project_id', '!=', False)]}" groups="sales_team.group_sale_salesman"/>
<field name="sale_order_id" attrs="{'invisible': True}" groups="sales_team.group_sale_salesman"/>
<field name="sale_line_id" string="Sales Order Item" attrs="{'invisible': [('partner_id', '=', False)]}" options='{"no_open": True}' readonly="1" context="{'create': False, 'edit': False, 'delete': False}"/>
<field name="commercial_partner_id" invisible="1" />
<field name="project_sale_order_id" invisible="1"/>
</xpath>
+1
View File
@@ -25,6 +25,7 @@ have real delivered quantities in sales orders.
'views/hr_timesheet_views.xml',
'views/res_config_settings_views.xml',
'views/hr_timesheet_templates.xml',
'views/report_invoice.xml',
'views/sale_timesheet_portal_templates.xml',
'report/project_profitability_report_analysis_views.xml',
'data/sale_timesheet_filters.xml',
@@ -1,10 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http, _
from odoo.http import request
from odoo.osv import expression
from odoo.addons.account.controllers import portal
from odoo.addons.hr_timesheet.controllers.portal import TimesheetCustomerPortal
class PortalAccount(portal.PortalAccount):
@@ -38,3 +40,44 @@ class CustomerPortal(portal.CustomerPortal):
values['timesheets'] = request.env['account.analytic.line'].sudo().search(domain)
values['is_uom_day'] = request.env['account.analytic.line'].sudo()._is_timesheet_encode_uom_day()
return values
class SaleTimesheetCustomerPortal(TimesheetCustomerPortal):
def _get_searchbar_inputs(self):
searchbar_inputs = super()._get_searchbar_inputs()
searchbar_inputs.update(
sol={'input': 'sol', 'label': _('Search in Sales Order Item')},
sol_id={'input': 'sol_id', 'label': _('Search in Sales Order Item ID')},
invoice={'input': 'invoice_id', 'label': _('Search in Invoice ID')})
return searchbar_inputs
def _get_searchbar_groupby(self):
searchbar_groupby = super()._get_searchbar_groupby()
searchbar_groupby.update(sol={'input': 'sol', 'label': _('Sales Order Item')})
return searchbar_groupby
def _get_search_domain(self, search_in, search):
search_domain = super()._get_search_domain(search_in, search)
if search_in in ('sol', 'all'):
search_domain = expression.OR([search_domain, [('so_line', 'ilike', search)]])
if search_in in ('sol_id', 'invoice_id'):
search = int(search) if search.isdigit() else 0
if search_in == 'sol_id':
search_domain = expression.OR([search_domain, [('so_line.id', '=', search)]])
if search_in == 'invoice_id':
invoice = request.env['account.move'].browse(search)
domain = request.env['account.analytic.line']._timesheet_get_sale_domain(invoice.mapped('invoice_line_ids.sale_line_ids'), invoice)
search_domain = expression.OR([search_domain, domain])
return search_domain
def _get_groupby_mapping(self):
groupby_mapping = super()._get_groupby_mapping()
groupby_mapping.update(sol='so_line')
return groupby_mapping
@http.route(['/my/timesheets', '/my/timesheets/page/<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)
@@ -5,8 +5,15 @@
<field name="name">Service on Timesheet</field>
<field name="type">service</field>
<field name="list_price">40</field>
<field name="uom_id" ref="uom.product_uom_hour"/>
<field name="uom_po_id" ref="uom.product_uom_hour"/>
<field name="service_policy">delivered_timesheet</field>
<field name="image_1920" type="base64" file="sale_timesheet/static/img/product_product_time_product.png"/>
</record>
</data>
<data>
<record model="res.groups" id="base.group_user">
<field name="implied_ids" eval="[(4, ref('uom.group_uom'))]"/>
</record>
</data>
</odoo>
@@ -22,6 +22,7 @@
<field name="date_start" eval="time.strftime('%Y-%m-01 10:00:00')"/>
<field name="name">After-Sales Services</field>
<field name="analytic_account_id" ref="account_analytic_account_project_support"/>
<field name="allow_billable" eval="True" />
<field name="type_ids" eval="[(4, ref('project.project_stage_0')), (4, ref('project.project_stage_1')), (4, ref('project.project_stage_2'))]"/>
</record>
@@ -45,7 +46,6 @@
<record id="product_service_order_timesheet" model="product.product">
<field name="name">Customer Care (Prepaid Hours)</field>
<field name="default_code">SERV_585189</field>
<field name="categ_id" ref="product.product_category_3"/>
<field name="type">service</field>
<field name="list_price">250.00</field>
@@ -59,7 +59,6 @@
<record id="product_service_deliver_timesheet_1" model="product.product">
<field name="name">Senior Architect (Invoice on Timesheets)</field>
<field name="default_code">SERV_89744</field>
<field name="categ_id" ref="product.product_category_3"/>
<field name="list_price">200.00</field>
<field name="standard_price">150.00</field>
@@ -72,7 +71,6 @@
<record id="product_service_deliver_timesheet_2" model="product.product">
<field name="name">Junior Architect (Invoice on Timesheets)</field>
<field name="default_code">SERV_89665</field>
<field name="categ_id" ref="product.product_category_3"/>
<field name="list_price">100.00</field>
<field name="standard_price">85.00</field>
@@ -85,7 +83,6 @@
<record id="product_service_deliver_manual" model="product.product">
<field name="name">Kitchen Assembly (Milestones)</field>
<field name="default_code">SERV_32289</field>
<field name="categ_id" ref="product.product_category_3"/>
<field name="list_price">500</field>
<field name="standard_price">420.00</field>
+22 -25
View File
@@ -22,7 +22,9 @@ class AccountAnalyticLine(models.Model):
('non_billable_project', 'No task found')], string="Billable Type", compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True)
timesheet_invoice_id = fields.Many2one('account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet")
non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheet will not be billed.")
so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False)
# TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to bill_type and pricing_type of project (See in master)
@api.depends('so_line.product_id', 'project_id', 'task_id', 'non_allow_billable', 'task_id.bill_type', 'task_id.pricing_type', 'task_id.non_allow_billable')
def _compute_timesheet_invoice_type(self):
non_allowed_billable = self.filtered('non_allow_billable')
@@ -55,12 +57,21 @@ class AccountAnalyticLine(models.Model):
else:
self.so_line = False
@api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'employee_id', 'project_id.allow_billable')
def _compute_so_line(self):
for timesheet in self._get_not_billed(): # Get only the timesheets are not yet invoiced
timesheet.so_line = timesheet.project_id.allow_billable and timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id, timesheet.project_id)
def _get_not_billed(self):
return self.filtered(lambda t: not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel')
def _check_timesheet_can_be_billed(self):
return self.so_line in self.project_id.mapped('sale_line_employee_ids.sale_line_id') | self.task_id.sale_line_id | self.project_id.sale_line_id
@api.constrains('so_line', 'project_id')
def _check_sale_line_in_project_map(self):
for timesheet in self:
if timesheet.project_id and timesheet.so_line: # billed timesheet
if timesheet.so_line not in timesheet.project_id.mapped('sale_line_employee_ids.sale_line_id') | timesheet.task_id.sale_line_id | timesheet.project_id.sale_line_id:
raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line."))
if not all(t._check_timesheet_can_be_billed() for t in self._get_not_billed().filtered(lambda t: t.project_id and t.so_line)):
raise ValidationError(_("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line."))
def write(self, values):
# prevent to update invoiced timesheets if one line is of type delivery
@@ -81,26 +92,8 @@ class AccountAnalyticLine(models.Model):
values['account_id'] = task.analytic_account_id.id
values['company_id'] = task.analytic_account_id.company_id.id
values = super(AccountAnalyticLine, self)._timesheet_preprocess(values)
# task implies so line (at create)
if any([field_name in values for field_name in ['task_id', 'project_id']]) and not values.get('so_line') and (values.get('employee_id') or self.mapped('employee_id')):
if not values.get('employee_id') and len(self.mapped('employee_id')) > 1:
raise UserError(_('You can not modify timesheets from different employees'))
task = self.env['project.task'].sudo().browse(values['task_id']) if values.get('task_id') else self.env['project.task']
employee = self.env['hr.employee'].sudo().browse(values['employee_id']) if values.get('employee_id') else self.mapped('employee_id')
project = self.env['project.project'].sudo().browse(values['project_id']) if values.get('project_id') else task.project_id
values['so_line'] = self._timesheet_determine_sale_line(task, employee, project).id
return values
def _timesheet_postprocess_values(self, values):
result = super(AccountAnalyticLine, self)._timesheet_postprocess_values(values)
# (re)compute the sale line
if any(field_name in values for field_name in ['task_id', 'employee_id', 'project_id']):
for timesheet in self:
result[timesheet.id].update({
'so_line': timesheet._timesheet_determine_sale_line(timesheet.task_id, timesheet.employee_id, timesheet.project_id).id,
})
return result
@api.model
def _timesheet_determine_sale_line(self, task, employee, project):
""" Deduce the SO line associated to the timesheet line:
@@ -108,19 +101,20 @@ class AccountAnalyticLine(models.Model):
2/ timesheet on employee rate task: find the SO line in the map of the project (even for subtask), or fallback on the SO line of the task, or fallback
on the one on the project
"""
if project.sale_line_id and not task:
if not task:
if project.bill_type == 'customer_project' and project.pricing_type == 'employee_rate':
map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', project.id), ('employee_id', '=', employee.id)])
if map_entry:
return map_entry.sale_line_id
return project.sale_line_id
if project.sale_line_id:
return project.sale_line_id
if task.allow_billable:
if task.bill_type == 'customer_task':
return task.sale_line_id
if task.pricing_type == 'fixed_rate':
return task.sale_line_id
elif task.pricing_type == 'employee_rate' and not task.non_allow_billable:
map_entry = self.env['project.sale.line.employee.map'].search([('project_id', '=', task.project_id.id), ('employee_id', '=', employee.id)])
map_entry = project.sale_line_employee_ids.filtered(lambda map_entry: map_entry.employee_id == employee)
if map_entry:
return map_entry.sale_line_id
if task.sale_line_id or project.sale_line_id:
@@ -137,6 +131,9 @@ class AccountAnalyticLine(models.Model):
@api.model
def _timesheet_get_sale_domain(self, order_lines_ids, invoice_ids):
if not invoice_ids:
return [('so_line', 'in', order_lines_ids.ids)]
return [
'|',
'&',
+53 -24
View File
@@ -47,6 +47,7 @@ class Project(models.Model):
('service_type', '=', 'timesheet'),
'|', ('company_id', '=', False), ('company_id', '=', company_id)]""",
help='Select a Service product with which you would like to bill your time spent on tasks.',
compute="_compute_timesheet_product_id", store=True, readonly=False,
default=_default_timesheet_product_id)
warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate')
@@ -237,6 +238,28 @@ class ProjectTask(models.Model):
# TODO: [XBO] remove me in master
non_allow_billable = fields.Boolean("Non-Billable", help="Your timesheets linked to this task will not be billed.")
remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so')
remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available")
@api.depends('sale_line_id', 'timesheet_ids', 'timesheet_ids.unit_amount')
def _compute_remaining_hours_so(self):
# TODO This is not yet perfectly working as timesheet.so_line stick to its old value although changed
# in the task From View.
timesheets = self.timesheet_ids.filtered(lambda t: t.task_id.sale_line_id in (t.so_line, t._origin.so_line) and t.so_line.remaining_hours_available)
mapped_remaining_hours = {task._origin.id: task.sale_line_id and task.sale_line_id.remaining_hours or 0.0 for task in self}
uom_hour = self.env.ref('uom.product_uom_hour')
for timesheet in timesheets:
delta = 0
if timesheet._origin.so_line == timesheet.task_id.sale_line_id:
delta += timesheet._origin.unit_amount
if timesheet.so_line == timesheet.task_id.sale_line_id:
delta -= timesheet.unit_amount
if delta:
mapped_remaining_hours[timesheet.task_id._origin.id] += timesheet.so_line.product_uom._compute_quantity(delta, uom_hour)
for task in self:
task.remaining_hours_so = mapped_remaining_hours[task._origin.id]
@api.depends(
'allow_billable', 'allow_timesheets', 'sale_order_id')
@@ -265,17 +288,25 @@ class ProjectTask(models.Model):
for task in self:
task.analytic_account_active = task.analytic_account_active or task.analytic_account_id.active
@api.depends('sale_line_id', 'project_id', 'allow_billable', 'bill_type', 'pricing_type', 'non_allow_billable')
@api.depends('sale_line_id', 'project_id', 'allow_billable', 'non_allow_billable')
def _compute_sale_order_id(self):
for task in self:
if task.allow_billable and task.bill_type == 'customer_project' and task.pricing_type == 'employee_rate' and task.non_allow_billable:
task.sale_order_id = False
elif task.allow_billable and task.bill_type == 'customer_project':
task.sale_order_id = task.project_id.sale_order_id
elif task.allow_billable and task.bill_type == 'customer_task':
task.sale_order_id = task.sale_line_id.sudo().order_id
elif not task.sale_order_id:
if not task.allow_billable or task.non_allow_billable:
task.sale_order_id = False
elif task.allow_billable:
if task.sale_line_id:
task.sale_order_id = task.sale_line_id.sudo().order_id
elif task.project_id.sale_order_id:
task.sale_order_id = task.project_id.sale_order_id
if task.sale_order_id and not task.partner_id:
task.partner_id = task.sale_order_id.partner_id
@api.depends('commercial_partner_id', 'sale_line_id.order_partner_id.commercial_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'allow_billable')
def _compute_sale_line(self):
billable_tasks = self.filtered('allow_billable')
super(ProjectTask, billable_tasks)._compute_sale_line()
for task in billable_tasks.filtered(lambda t: not t.sale_line_id):
task.sale_line_id = task._get_last_sol_of_customer()
@api.depends('project_id.sale_line_employee_ids')
def _compute_is_project_map_empty(self):
@@ -302,22 +333,6 @@ class ProjectTask(models.Model):
project_dest = self.env['project.project'].browse(values['project_id'])
if project_dest.bill_type == 'customer_project' and project_dest.pricing_type == 'employee_rate':
self.write({'sale_line_id': False})
if 'sale_line_id' in values and self.filtered('allow_timesheets').sudo().timesheet_ids:
so = self.env['sale.order.line'].browse(values['sale_line_id']).order_id
if so and not so.analytic_account_id:
so.analytic_account_id = self.project_id.analytic_account_id
timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered(
lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel')
)
timesheet_ids.write({'so_line': values['sale_line_id']})
if 'project_id' in values:
# Special case when we edit SOL an project in same time, as we edit SOL of
# timesheet lines, function '_get_timesheet' won't find the right timesheet
# to edit so we must edit those here.
project = self.env['project.project'].browse(values.get('project_id'))
if project.allow_timesheets:
timesheet_ids.write({'project_id': values.get('project_id')})
if 'non_allow_billable' in values and self.filtered('allow_timesheets').sudo().timesheet_ids:
timesheet_ids = self.filtered('allow_timesheets').timesheet_ids.filtered(
lambda t: (not t.timesheet_invoice_id or t.timesheet_invoice_id.state == 'cancel')
@@ -335,6 +350,20 @@ class ProjectTask(models.Model):
return res
def _get_last_sol_of_customer(self):
# Get the last SOL made for the customer in the current task where we need to compute
self.ensure_one()
if not self.commercial_partner_id or not self.allow_billable:
return False
domain = [('is_service', '=', True), ('order_partner_id', 'child_of', self.commercial_partner_id.id), ('is_expense', '=', False), ('state', 'in', ['sale', 'done'])]
if self.project_id.bill_type == 'customer_type' and self.project_sale_order_id:
domain.append(('order_id', '=?', self.project_sale_order_id.id))
sale_lines = self.env['sale.order.line'].search(domain)
for line in sale_lines:
if line.remaining_hours_available and line.remaining_hours > 0:
return line
return False
def action_make_billable(self):
return {
"name": _("Create Sales Order"),
+62 -1
View File
@@ -1,8 +1,9 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo import api, fields, models, _
from odoo.osv import expression
import math
class SaleOrder(models.Model):
@@ -77,6 +78,66 @@ class SaleOrderLine(models.Model):
qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')])
analytic_line_ids = fields.One2many(domain=[('project_id', '=', False)]) # only analytic lines, not timesheets (since this field determine if SO line came from expense)
remaining_hours_available = fields.Boolean(compute='_compute_remaining_hours_available')
remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours')
def name_get(self):
res = super(SaleOrderLine, self).name_get()
if self.env.context.get('with_remaining_hours'):
names = dict(res)
result = []
uom_hour = self.env.ref('uom.product_uom_hour')
uom_day = self.env.ref('uom.product_uom_day')
for line in self:
name = names.get(line.id)
if line.remaining_hours_available:
company = self.env.company
encoding_uom = company.timesheet_encode_uom_id
remaining_time = ''
if encoding_uom == uom_hour:
hours, minutes = divmod(abs(line.remaining_hours) * 60, 60)
round_minutes = minutes / 30
minutes = math.ceil(round_minutes) if line.remaining_hours >= 0 else math.floor(round_minutes)
if minutes > 1:
minutes = 0
hours += 1
else:
minutes = minutes * 30
remaining_time =' ({sign}{hours:02.0f}:{minutes:02.0f})'.format(
sign='-' if line.remaining_hours < 0 else '',
hours=hours,
minutes=minutes)
elif encoding_uom == uom_day:
remaining_days = company.project_time_mode_id._compute_quantity(line.remaining_hours, encoding_uom, round=False)
remaining_time = ' ({qty:.02f} {unit})'.format(
qty=remaining_days,
unit=_('days') if abs(remaining_days) > 1 else _('day')
)
name = '{name}{remaining_time}'.format(
name=name,
remaining_time=remaining_time
)
result.append((line.id, name))
return result
return res
@api.depends('product_id.service_policy')
def _compute_remaining_hours_available(self):
uom_hour = self.env.ref('uom.product_uom_hour')
for line in self:
is_ordered_timesheet = line.product_id.service_policy == 'ordered_timesheet'
is_time_product = line.product_uom.category_id == uom_hour.category_id
line.remaining_hours_available = is_ordered_timesheet and is_time_product
@api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids')
def _compute_remaining_hours(self):
uom_hour = self.env.ref('uom.product_uom_hour')
for line in self:
remaining_hours = None
if line.remaining_hours_available:
qty_left = line.product_uom_qty - line.qty_delivered
remaining_hours = line.product_uom._compute_quantity(qty_left, uom_hour)
line.remaining_hours = remaining_hours
@api.depends('product_id')
def _compute_qty_delivered_method(self):
@@ -404,9 +404,14 @@
<span t-if="foldable" t-att-class="('fa fa-caret-down' if row_type == 'sale_order' else 'fa fa-caret-right') + (' project_overview_foldable' if foldable else '')"
style="cursor: pointer;" t-att-data-model="row[0].get('res_model')" t-att-data-res-id="row[0].get('res_id')"/>
<t t-if="row_type == 'sale_order'">
<a type="action" t-att-data-model="row_value['res_model']" t-att-data-res-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''">
<t t-if="env.user.has_group('sales_team.group_sale_salesman')">
<a type="action" t-att-data-model="row_value['res_model']" t-att-data-res-id="row_value['res_id']" t-att-class="'o_timesheet_plan_redirect' if row_value['res_id'] else ''">
<t t-esc="row_value.get('label')"/>
</a>
</t>
<t t-else="">
<t t-esc="row_value.get('label')"/>
</a>
</t>
<span t-if="row_value.get('canceled')" class="badge badge-pill o_canceled_tag">
Cancelled
</span>
@@ -42,7 +42,7 @@
<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" optional="hide"/>
<field name="non_allow_billable" attrs="{'invisible': [('non_allow_billable', '=', False)]}"/>
</xpath>
</field>
</record>
@@ -25,8 +25,8 @@
<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}"/>
<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}"/>
<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}"/>
</group>
</group>
<field name="sale_line_employee_ids" attrs="{'invisible': ['|', ('bill_type', '!=', 'customer_project'), ('pricing_type', '!=', 'employee_rate')]}">
@@ -60,6 +60,21 @@
</field>
</record>
<record id="project_project_view_form_salesman" model="ir.ui.view">
<field name="name">project.project.form.inherit.salesman</field>
<field name="model">project.project</field>
<field name="inherit_id" ref="sale_timesheet.project_project_view_form"/>
<field name="groups_id" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="arch" type="xml">
<field name="sale_order_id" position="attributes">
<attribute name="options">{'no_create': True, 'no_edit': True, 'delete': False}</attribute>
</field>
<field name="sale_line_id" position="attributes">
<attribute name="options">{'no_create': True, 'no_edit': True, 'delete': False}</attribute>
</field>
</field>
</record>
<record id="project_project_view_form_simplified_inherit" model="ir.ui.view">
<field name="name">project.project.view.form.simplified.inherit</field>
<field name="model">project.project</field>
@@ -134,10 +149,12 @@
<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']/tree" position="inside">
<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.has_multi_sol', '=', False), '|', ('parent.is_project_map_empty', '=', True), ('parent.pricing_type', '!=', 'employee_rate')]}" optional="hide"/>
<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')]}" optional="hide"/>
<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"/>
</xpath>
</field>
</record>
@@ -145,13 +162,31 @@
<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>
<field name="inherit_id" ref="sale_project.view_sale_project_inherit_form"/>
<field name="inherit_id" ref="project.view_task_form2"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='sale_line_id']" position="attributes">
<attribute name="context">{'with_remaining_hours': True}</attribute>
<attribute name="attrs">
{'invisible': ['|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), '&amp;', ('bill_type', '=', 'customer_project'), ('pricing_type', '=', 'employee_rate'), ('partner_id', '=', False)]}
{'invisible': ['|', ('allow_billable', '=', False), ('partner_id', '=', False)]}
</attribute>
</xpath>
<xpath expr="//field[@name='remaining_hours']" position="after">
<field name="remaining_hours_available" invisible="1"/>
<span id="remaining_hours_so_label" attrs="{'invisible': ['|', '|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('partner_id', '=', False), ('sale_line_id', '=', False), ('remaining_hours_available', '=', False)]}">
<label class="font-weight-bold" for="remaining_hours_so" string="Remaining Hours on SO"
attrs="{'invisible': ['|', ('encode_uom_in_days', '=', True), ('remaining_hours_so', '&lt;', 0)]}"/>
<label class="font-weight-bold" for="remaining_hours_so" string="Remaining Days on SO"
attrs="{'invisible': ['|', ('encode_uom_in_days', '=', False), ('remaining_hours_so', '&lt;', 0)]}"/>
<label class="font-weight-bold text-danger" for="remaining_hours_so" string="Remaining Hours on SO"
attrs="{'invisible': ['|', ('encode_uom_in_days', '=', True), ('remaining_hours_so', '&gt;=', 0)]}"/>
<label class="font-weight-bold text-danger" for="remaining_hours_so" string="Remaining Days on SO"
attrs="{'invisible': ['|', ('encode_uom_in_days', '=', False), ('remaining_hours_so', '&gt;=', 0)]}"/>
</span>
<field name="remaining_hours_so" nolabel="1" widget="timesheet_uom" attrs="{'invisible': ['|', '|', '|', '|', ('allow_billable', '=', False), ('sale_order_id', '=', False), ('partner_id', '=', False), ('sale_line_id', '=', False), ('remaining_hours_available', '=', False)]}"></field>
</xpath>
<xpath expr="//field[@name='sale_order_id']" position="attributes">
<attribute name="invisible">1</attribute>
</xpath>
</field>
</record>
@@ -0,0 +1,10 @@
<?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,107 +7,154 @@
</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">
<li t-if="timesheets" class="list-group-item flex-grow-1" >
<a href="#accordion">Timesheets</a>
</li>
<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">
<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>
<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>
</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="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">
<li t-if="timesheets" class="list-group-item flex-grow-1" >
<a href="#accordion">Timesheets</a>
</li>
<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">
<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>
<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>
</div>
</t>
</xpath>
</template>
<template id="portal_my_timesheets_inherit" inherit_id="hr_timesheet.portal_my_timesheets">
<xpath expr="//thead/tr[contains(@t-attf-class, 'thead-light')]" position="inside">
<t t-elif="groupby == 'sol'">
<t t-set="sol" t-value="timesheets[0].so_line"/>
<th colspan="5">
<t t-if="sol">
<em class="font-weight-normal text-muted">Timesheets for sales order item:</em>
<span t-field="sol.display_name"/>
<t t-if="sol.remaining_hours_available">
<span class="text-muted font-weight-normal">(<span t-field="sol.product_uom_qty" t-options='{"widget": "float_time"}'></span> <span t-field="sol.product_uom.display_name"></span> Ordered, <span t-field="sol.remaining_hours" t-options='{"widget": "float_time"}'></span> <span t-field="sol.product_uom.display_name"></span> Remaining)</span>
</t>
</t>
</th>
<th colspan="1" class="text-right text-muted font-weight-normal">
<t t-if="is_uom_day">
Total: <span t-esc="timesheets[0]._convert_hours_to_days(hours_spent)" t-options='{"widget": "timesheet_uom"}'/>
</t>
<t t-else="">
Total: <span t-esc="hours_spent" t-options='{"widget": "float_time"}'/>
</t>
</th>
</t>
</xpath>
<xpath expr="//thead/tr/th[@t-if='is_uom_day']" position="before">
<th t-if="not groupby == 'sol'">Sale Order Item</th>
</xpath>
<xpath expr="//tbody//td[hasclass('text-right')]" position="before">
<td t-if="not groupby == 'sol'"><span t-field="timesheet.so_line" t-att-title="timesheet.so_line.display_name"></span></td>
</xpath>
</template>
+4
View File
@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import models
@@ -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,
}
@@ -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
@@ -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
@@ -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')
@@ -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);
});
@@ -0,0 +1,10 @@
<?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>
@@ -0,0 +1,41 @@
<?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>