Purpose
======= This PR is in the continuity of odoo#62360. - Display remaining hours in task sales_line_id (name_get + task form view) - Remove internal reference from services demo data - Display sol in project timesheets list if project is billable - Remove use of non_allow_billable in timesheets as it has been removed from project and task in previous PR (see above) - Only recompute planned_hours for service product - Determine the correct SOL for timesheet - Set the last SOL of customer on timesheet if none is set on task or project - Allow edition of so_line in timesheet - Restrict SOL on project to sale lines with a service product - Use same widget on partner_id many2One than in sale.order (using ranking) - Remove timesheets table in SO and invoice portal.They were added in previous PR (see above). This introduces the use of links to /my/timesheets/. - Review portal timesheets (my/timesheets/ and link from orders and invoices) - Activate group_uom "Units of Measure" on sale_timesheet install - Hide partner phone number in task form view - Only determine SOL of task and timesheet if allow_billable=True - Filter SOL in task so that it matches the SOL of the project SO - Display red label if remaining hours is negative - Change SO compute behavior - Only open SO for salesman on project overview task-2409761 -- I confirm I have signed the CLA and read the PR guidelines at www.odoo.com/submit-pr closes odoo/odoo#63695 Forward-port-of: #62900 X-original-commit: b1985773f3a67e28049f9e2bb635bcab059ad9c0 Related: odoo/enterprise#15427 Signed-off-by: Yannick Tivisse (yti) <yti@odoo.com>
This commit is contained in:
committed by
Laurent Stukkens (LTU)
parent
fef9634dfb
commit
0a8f4b6cda
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 [
|
||||
'|',
|
||||
'&',
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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': ['&', ('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': ['&', ('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': ['|', '&', '&', '|', ('parent.bill_type', '!=', 'customer_project'), ('parent.pricing_type', '!=', 'employee_rate'), ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&', ('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': ['|', '&', '&', '|', ('parent.bill_type', '!=', 'customer_project'), ('parent.pricing_type', '!=', 'employee_rate'), ('parent.timesheet_product_id', '=', False), ('parent.sale_line_id', '=', False), '&', ('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), '&', ('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', '<', 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', '<', 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', '>=', 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', '>=', 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&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 > 1">Project</th>
|
||||
<th t-if="nr_tasks > 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 > 1"><span t-field="timesheet.project_id"/></td>
|
||||
<td t-if="nr_tasks > 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 > 1">Project</th>
|
||||
<th t-if="nr_tasks > 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 > 1"><span t-field="timesheet.project_id"/></td>
|
||||
<td t-if="nr_tasks > 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&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 > 1">Project</th>
|
||||
<th t-if="nr_tasks > 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 > 1"><span t-field="timesheet.project_id"/></td>
|
||||
<td t-if="nr_tasks > 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 > 1">Project</th>
|
||||
<th t-if="nr_tasks > 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 > 1"><span t-field="timesheet.project_id"/></td>
|
||||
<td t-if="nr_tasks > 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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user