Начальное наполнение

This commit is contained in:
parent a2a6686d88
commit e2057bc40c
139 changed files with 127550 additions and 0 deletions

17
__init__.py Normal file
View File

@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import wizard
from . import report
def uninstall_hook(env):
env.ref("account.account_analytic_line_rule_billing_user").write({'domain_force': "[(1, '=', 1)]"})
def _sale_timesheet_post_init(env):
products = env['product.template'].search([('detailed_type', '=', 'service'), ('invoice_policy', '=', 'order'), ('service_type', '=', 'manual')])
for product in products:
product.service_type = 'timesheet'
product._compute_service_policy()

56
__manifest__.py Normal file
View File

@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
{
'name': 'Sales Timesheet',
'category': 'Hidden',
'summary': 'Sell based on timesheets',
'description': """
Allows to sell timesheets in your sales order
=============================================
This module set the right product on all timesheet lines
according to the order/contract you work on. This allows to
have real delivered quantities in sales orders.
""",
'depends': ['sale_project', 'hr_timesheet'],
'data': [
'data/sale_service_data.xml',
'security/ir.model.access.csv',
'security/sale_timesheet_security.xml',
'views/account_invoice_views.xml',
'views/sale_order_views.xml',
'views/product_views.xml',
'views/project_task_views.xml',
'views/project_update_templates.xml',
'views/hr_timesheet_views.xml',
'views/res_config_settings_views.xml',
'views/sale_timesheet_portal_templates.xml',
'views/project_sharing_views.xml',
'views/project_portal_templates.xml',
'report/timesheets_analysis_views.xml',
'report/report_timesheet_templates.xml',
'report/project_report_view.xml',
'wizard/project_create_sale_order_views.xml',
'wizard/project_create_invoice_views.xml',
'wizard/sale_make_invoice_advance_views.xml',
],
'demo': [
'data/sale_service_demo.xml',
],
'auto_install': True,
'uninstall_hook': 'uninstall_hook',
'assets': {
'web.assets_frontend': [
'sale_timesheet/static/src/scss/sale_timesheet_portal.scss',
],
'web.assets_backend': [
'sale_timesheet/static/src/components/**/*',
],
'web.assets_tests': [
'sale_timesheet/static/tests/**/*',
],
},
'license': 'LGPL-3',
'post_init_hook': '_sale_timesheet_post_init',
}

4
controllers/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import portal

138
controllers/portal.py Normal file
View File

@ -0,0 +1,138 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from werkzeug.exceptions import NotFound
from odoo import http, _
from odoo.exceptions import AccessError, MissingError
from odoo.http import request
from odoo.osv import expression
from odoo.addons.account.controllers.portal import PortalAccount
from odoo.addons.hr_timesheet.controllers.portal import TimesheetCustomerPortal
from odoo.addons.portal.controllers.portal import pager as portal_pager
from odoo.addons.project.controllers.portal import ProjectCustomerPortal
class PortalProjectAccount(PortalAccount, ProjectCustomerPortal):
def _invoice_get_page_view_values(self, invoice, access_token, **kwargs):
values = super()._invoice_get_page_view_values(invoice, access_token, **kwargs)
domain = request.env['account.analytic.line']._timesheet_get_portal_domain()
domain = expression.AND([
domain,
request.env['account.analytic.line']._timesheet_get_sale_domain(
invoice.mapped('line_ids.sale_line_ids'),
request.env['account.move'].browse([invoice.id])
)
])
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
@http.route([
'/my/tasks/<task_id>/orders/invoices',
'/my/tasks/<task_id>/orders/invoices/page/<int:page>'],
type='http', auth="user", website=True)
def portal_my_tasks_invoices(self, task_id=None, page=1, date_begin=None, date_end=None, sortby=None, filterby=None, **kw):
task = request.env['project.task'].search([('id', '=', task_id)])
if not task:
return NotFound()
domain = [('id', 'in', task.sale_order_id.invoice_ids.ids)]
values = self._prepare_my_invoices_values(page, date_begin, date_end, sortby, filterby, domain=domain)
# pager
pager = portal_pager(**values['pager'])
# content according to pager and archive selected
invoices = values['invoices'](pager['offset'])
request.session['my_invoices_history'] = invoices.ids[:100]
values.update({
'invoices': invoices,
'pager': pager,
})
return request.render("account.portal_my_invoices", values)
class SaleTimesheetCustomerPortal(TimesheetCustomerPortal):
def _get_searchbar_inputs(self):
searchbar_inputs = super()._get_searchbar_inputs()
searchbar_inputs.update(
so={'input': 'so', 'label': _('Search in Sales Order')},
sol={'input': 'sol', 'label': _('Search in Sales Order Item')},
invoice={'input': 'invoice', 'label': _('Search in Invoice')})
return searchbar_inputs
def _get_searchbar_groupby(self):
searchbar_groupby = super()._get_searchbar_groupby()
searchbar_groupby.update(
so={'input': 'so', 'label': _('Sales Order')},
sol={'input': 'sol', 'label': _('Sales Order Item')},
invoice={'input': 'invoice', 'label': _('Invoice')})
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 ('so', 'all'):
search_domain = expression.OR([search_domain, [('so_line.order_id.name', 'ilike', search)]])
if search_in in ('invoice', 'all'):
invoices = request.env['account.move'].sudo().search(['|', ('name', 'ilike', search), ('id', 'ilike', search)])
domain = request.env['account.analytic.line']._timesheet_get_sale_domain(invoices.mapped('invoice_line_ids.sale_line_ids'), invoices)
search_domain = expression.OR([search_domain, domain])
return search_domain
def _get_groupby_mapping(self):
groupby_mapping = super()._get_groupby_mapping()
groupby_mapping.update(
sol='so_line',
so='order_id',
invoice='timesheet_invoice_id')
return groupby_mapping
def _get_searchbar_sortings(self):
searchbar_sortings = super()._get_searchbar_sortings()
searchbar_sortings.update(
sol={'label': _('Sales Order Item'), 'order': 'so_line'})
return searchbar_sortings
def _task_get_page_view_values(self, task, access_token, **kwargs):
values = super()._task_get_page_view_values(task, access_token, **kwargs)
values['so_accessible'] = False
try:
if task.sale_order_id and self._document_check_access('sale.order', task.sale_order_id.id):
values['so_accessible'] = True
title = _('Quotation') if task.sale_order_id.state in ['draft', 'sent'] else _('Sales Order')
values['task_link_section'].append({
'access_url': task.sale_order_id.get_portal_url(),
'title': title,
})
except (AccessError, MissingError):
pass
moves = request.env['account.move']
invoice_ids = task.sale_order_id.invoice_ids
if invoice_ids and request.env['account.move'].check_access_rights('read', raise_exception=False):
moves = request.env['account.move'].search([('id', 'in', invoice_ids.ids)])
values['invoices_accessible'] = moves.ids
if moves:
if len(moves) == 1:
task_invoice_url = moves.get_portal_url()
title = _('Invoice')
else:
task_invoice_url = f'/my/tasks/{task.id}/orders/invoices'
title = _('Invoices')
values['task_link_section'].append({
'access_url': task_invoice_url,
'title': title,
})
return values
@http.route()
def portal_my_timesheets(self, *args, groupby='sol', **kw):
return super().portal_my_timesheets(*args, groupby=groupby, **kw)

View File

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<record id="time_product" model="product.product">
<field name="name">Service on Timesheets</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>

2594
data/sale_service_demo.xml Normal file

File diff suppressed because it is too large Load Diff

1514
i18n/af.po Normal file

File diff suppressed because it is too large Load Diff

1510
i18n/am.po Normal file

File diff suppressed because it is too large Load Diff

1605
i18n/ar.po Normal file

File diff suppressed because it is too large Load Diff

1515
i18n/az.po Normal file

File diff suppressed because it is too large Load Diff

1541
i18n/bg.po Normal file

File diff suppressed because it is too large Load Diff

1515
i18n/bs.po Normal file

File diff suppressed because it is too large Load Diff

1622
i18n/ca.po Normal file

File diff suppressed because it is too large Load Diff

1589
i18n/cs.po Normal file

File diff suppressed because it is too large Load Diff

1573
i18n/da.po Normal file

File diff suppressed because it is too large Load Diff

1640
i18n/de.po Normal file

File diff suppressed because it is too large Load Diff

1519
i18n/el.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/en_AU.po Normal file

File diff suppressed because it is too large Load Diff

1514
i18n/en_GB.po Normal file

File diff suppressed because it is too large Load Diff

1627
i18n/es.po Normal file

File diff suppressed because it is too large Load Diff

1627
i18n/es_419.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/es_BO.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/es_CL.po Normal file

File diff suppressed because it is too large Load Diff

1524
i18n/es_CO.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/es_CR.po Normal file

File diff suppressed because it is too large Load Diff

1513
i18n/es_DO.po Normal file

File diff suppressed because it is too large Load Diff

1513
i18n/es_EC.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/es_PA.po Normal file

File diff suppressed because it is too large Load Diff

1513
i18n/es_PE.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/es_PY.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/es_VE.po Normal file

File diff suppressed because it is too large Load Diff

1628
i18n/et.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/eu.po Normal file

File diff suppressed because it is too large Load Diff

1559
i18n/fa.po Normal file

File diff suppressed because it is too large Load Diff

1619
i18n/fi.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/fo.po Normal file

File diff suppressed because it is too large Load Diff

1625
i18n/fr.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/fr_BE.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/fr_CA.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/gl.po Normal file

File diff suppressed because it is too large Load Diff

1514
i18n/gu.po Normal file

File diff suppressed because it is too large Load Diff

1563
i18n/he.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/hi.po Normal file

File diff suppressed because it is too large Load Diff

1526
i18n/hr.po Normal file

File diff suppressed because it is too large Load Diff

1526
i18n/hu.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/hy.po Normal file

File diff suppressed because it is too large Load Diff

1620
i18n/id.po Normal file

File diff suppressed because it is too large Load Diff

1514
i18n/is.po Normal file

File diff suppressed because it is too large Load Diff

1623
i18n/it.po Normal file

File diff suppressed because it is too large Load Diff

1569
i18n/ja.po Normal file

File diff suppressed because it is too large Load Diff

1516
i18n/ka.po Normal file

File diff suppressed because it is too large Load Diff

1516
i18n/kab.po Normal file

File diff suppressed because it is too large Load Diff

1515
i18n/km.po Normal file

File diff suppressed because it is too large Load Diff

1576
i18n/ko.po Normal file

File diff suppressed because it is too large Load Diff

1510
i18n/lb.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/lo.po Normal file

File diff suppressed because it is too large Load Diff

1551
i18n/lt.po Normal file

File diff suppressed because it is too large Load Diff

1548
i18n/lv.po Normal file

File diff suppressed because it is too large Load Diff

1513
i18n/mk.po Normal file

File diff suppressed because it is too large Load Diff

1538
i18n/mn.po Normal file

File diff suppressed because it is too large Load Diff

1522
i18n/nb.po Normal file

File diff suppressed because it is too large Load Diff

1627
i18n/nl.po Normal file

File diff suppressed because it is too large Load Diff

1613
i18n/pl.po Normal file

File diff suppressed because it is too large Load Diff

1545
i18n/pt.po Normal file

File diff suppressed because it is too large Load Diff

1624
i18n/pt_BR.po Normal file

File diff suppressed because it is too large Load Diff

1554
i18n/ro.po Normal file

File diff suppressed because it is too large Load Diff

1636
i18n/ru.po Normal file

File diff suppressed because it is too large Load Diff

1528
i18n/sale_timesheet.pot Normal file

File diff suppressed because it is too large Load Diff

1563
i18n/sk.po Normal file

File diff suppressed because it is too large Load Diff

1545
i18n/sl.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/sq.po Normal file

File diff suppressed because it is too large Load Diff

1602
i18n/sr.po Normal file

File diff suppressed because it is too large Load Diff

1515
i18n/sr@latin.po Normal file

File diff suppressed because it is too large Load Diff

1549
i18n/sv.po Normal file

File diff suppressed because it is too large Load Diff

1512
i18n/ta.po Normal file

File diff suppressed because it is too large Load Diff

1606
i18n/th.po Normal file

File diff suppressed because it is too large Load Diff

1612
i18n/tr.po Normal file

File diff suppressed because it is too large Load Diff

1609
i18n/uk.po Normal file

File diff suppressed because it is too large Load Diff

1619
i18n/vi.po Normal file

File diff suppressed because it is too large Load Diff

1566
i18n/zh_CN.po Normal file

File diff suppressed because it is too large Load Diff

1566
i18n/zh_TW.po Normal file

File diff suppressed because it is too large Load Diff

12
models/__init__.py Normal file
View File

@ -0,0 +1,12 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import account
from . import account_move
from . import product
from . import project
from . import project_update
from . import sale_order
from . import res_config_settings
from . import project_sale_line_employee_map
from . import hr_employee

213
models/account.py Normal file
View File

@ -0,0 +1,213 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo.exceptions import UserError
from odoo import api, fields, models, _
from odoo.osv import expression
TIMESHEET_INVOICE_TYPES = [
('billable_time', 'Billed on Timesheets'),
('billable_fixed', 'Billed at a Fixed price'),
('billable_milestones', 'Billed on Milestones'),
('billable_manual', 'Billed Manually'),
('non_billable', 'Non Billable Tasks'),
('timesheet_revenues', 'Timesheet Revenues'),
('service_revenues', 'Service Revenues'),
('other_revenues', 'Materials'),
('other_costs', 'Materials'),
]
class AccountAnalyticLine(models.Model):
_inherit = 'account.analytic.line'
timesheet_invoice_type = fields.Selection(TIMESHEET_INVOICE_TYPES, string="Billable Type",
compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True)
commercial_partner_id = fields.Many2one('res.partner', compute="_compute_commercial_partner")
timesheet_invoice_id = fields.Many2one('account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet", index='btree_not_null')
so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False,
domain="""[
('qty_delivered_method', 'in', ['analytic', 'timesheet']),
('order_partner_id', '=', commercial_partner_id),
('is_service', '=', True),
('is_expense', '=', False),
('state', '=', 'sale')
]""",
help="Sales order item to which the time spent will be added in order to be invoiced to your customer. Remove the sales order item for the timesheet entry to be non-billable.")
# we needed to store it only in order to be able to groupby in the portal
order_id = fields.Many2one(related='so_line.order_id', store=True, readonly=True, index=True)
is_so_line_edited = fields.Boolean("Is Sales Order Item Manually Edited")
allow_billable = fields.Boolean(related="project_id.allow_billable")
def _default_sale_line_domain(self):
# [XBO] TODO: remove me in master
return expression.OR([[
('is_service', '=', True),
('is_expense', '=', False),
('state', '=', 'sale'),
('order_partner_id', 'child_of', self.sudo().commercial_partner_id.ids)
], super()._default_sale_line_domain()])
def _compute_allowed_so_line_ids(self):
# [XBO] TODO: remove me in master
super()._compute_allowed_so_line_ids()
@api.depends('project_id.partner_id.commercial_partner_id', 'task_id.partner_id.commercial_partner_id')
def _compute_commercial_partner(self):
for timesheet in self:
timesheet.commercial_partner_id = timesheet.task_id.partner_id.commercial_partner_id or timesheet.project_id.partner_id.commercial_partner_id
@api.depends('so_line.product_id', 'project_id.billing_type', 'amount')
def _compute_timesheet_invoice_type(self):
for timesheet in self:
if timesheet.project_id: # AAL will be set to False
invoice_type = False
if not timesheet.so_line:
invoice_type = 'non_billable' if timesheet.project_id.billing_type != 'manually' else 'billable_manual'
elif timesheet.so_line.product_id.type == 'service':
if timesheet.so_line.product_id.invoice_policy == 'delivery':
if timesheet.so_line.product_id.service_type == 'timesheet':
invoice_type = 'timesheet_revenues' if timesheet.amount > 0 else 'billable_time'
else:
service_type = timesheet.so_line.product_id.service_type
invoice_type = f'billable_{service_type}' if service_type in ['milestones', 'manual'] else 'billable_fixed'
elif timesheet.so_line.product_id.invoice_policy == 'order':
invoice_type = 'billable_fixed'
timesheet.timesheet_invoice_type = invoice_type
else:
if timesheet.amount >= 0:
if timesheet.so_line and timesheet.so_line.product_id.type == 'service':
timesheet.timesheet_invoice_type = 'service_revenues'
else:
timesheet.timesheet_invoice_type = 'other_revenues'
else:
timesheet.timesheet_invoice_type = 'other_costs'
@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.filtered(lambda t: not t.is_so_line_edited and t._is_not_billed()): # Get only the timesheets are not yet invoiced
timesheet.so_line = timesheet.project_id.allow_billable and timesheet._timesheet_determine_sale_line()
@api.depends('timesheet_invoice_id.state')
def _compute_partner_id(self):
super(AccountAnalyticLine, self.filtered(lambda t: t._is_not_billed()))._compute_partner_id()
@api.depends('timesheet_invoice_id.state')
def _compute_project_id(self):
super(AccountAnalyticLine, self.filtered(lambda t: t._is_not_billed()))._compute_project_id()
def _is_readonly(self):
return super()._is_readonly() or not self._is_not_billed()
def _is_not_billed(self):
self.ensure_one()
return not self.timesheet_invoice_id or self.timesheet_invoice_id.state == 'cancel'
def _check_timesheet_can_be_billed(self):
return self.so_line in self.project_id.mapped('sale_line_employee_ids.sale_line_id') | self.task_id.sale_line_id | self.project_id.sale_line_id
def _check_can_write(self, values):
# prevent to update invoiced timesheets if one line is of type delivery
if self.sudo().filtered(lambda aal: aal.so_line.product_id.invoice_policy == "delivery") and self.filtered(lambda t: t.timesheet_invoice_id and t.timesheet_invoice_id.state != 'cancel'):
if any(field_name in values for field_name in ['unit_amount', 'employee_id', 'project_id', 'task_id', 'so_line', 'amount', 'date']):
raise UserError(_('You cannot modify timesheets that are already invoiced.'))
return super()._check_can_write(values)
def _timesheet_determine_sale_line(self):
""" Deduce the SO line associated to the timesheet line:
1/ timesheet on task rate: the so line will be the one from the task
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
"""
self.ensure_one()
if not self.task_id:
if self.project_id.pricing_type == 'employee_rate':
map_entry = self._get_employee_mapping_entry()
if map_entry:
return map_entry.sale_line_id
if self.project_id.sale_line_id:
return self.project_id.sale_line_id
if self.task_id.allow_billable and self.task_id.sale_line_id:
if self.task_id.pricing_type in ('task_rate', 'fixed_rate'):
return self.task_id.sale_line_id
else: # then pricing_type = 'employee_rate'
map_entry = self.project_id.sale_line_employee_ids.filtered(
lambda map_entry:
map_entry.employee_id == (self.employee_id or self.env.user.employee_id)
and map_entry.sale_line_id.order_partner_id.commercial_partner_id == self.task_id.partner_id.commercial_partner_id
)
if map_entry:
return map_entry.sale_line_id
return self.task_id.sale_line_id
return False
def _timesheet_get_portal_domain(self):
""" Only the timesheets with a product invoiced on delivered quantity are concerned.
since in ordered quantity, the timesheet quantity is not invoiced,
thus there is no meaning of showing invoice with ordered quantity.
"""
domain = super(AccountAnalyticLine, self)._timesheet_get_portal_domain()
return expression.AND([domain, [('timesheet_invoice_type', 'in', ['billable_time', 'non_billable', 'billable_fixed'])]])
@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 [
'|',
'&',
('timesheet_invoice_id', 'in', invoice_ids.ids),
# TODO : Master: Check if non_billable should be removed ?
('timesheet_invoice_type', 'in', ['billable_time', 'non_billable']),
'&',
('timesheet_invoice_type', '=', 'billable_fixed'),
('so_line', 'in', order_lines_ids.ids)
]
def _get_timesheets_to_merge(self):
res = super(AccountAnalyticLine, self)._get_timesheets_to_merge()
return res.filtered(lambda l: not l.timesheet_invoice_id or l.timesheet_invoice_id.state != 'posted')
@api.ondelete(at_uninstall=False)
def _unlink_except_invoiced(self):
if any(line.timesheet_invoice_id and line.timesheet_invoice_id.state == 'posted' for line in self):
raise UserError(_('You cannot remove a timesheet that has already been invoiced.'))
def _get_employee_mapping_entry(self):
self.ensure_one()
return self.env['project.sale.line.employee.map'].search([('project_id', '=', self.project_id.id), ('employee_id', '=', self.employee_id.id or self.env.user.employee_id.id)])
def _hourly_cost(self):
if self.project_id.pricing_type == 'employee_rate':
mapping_entry = self._get_employee_mapping_entry()
if mapping_entry:
return mapping_entry.cost
return super()._hourly_cost()
def action_sale_order_from_timesheet(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Sale Order'),
'res_model': 'sale.order',
'views': [[False, 'form']],
'context': {'create': False, 'show_sale': True},
'res_id': self.order_id.id,
}
def action_invoice_from_timesheet(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Invoice'),
'res_model': 'account.move',
'views': [[False, 'form']],
'context': {'create': False},
'res_id': self.timesheet_invoice_id.id,
}
def _timesheet_convert_sol_uom(self, sol, to_unit):
to_uom = self.env.ref(to_unit)
return round(sol.product_uom._compute_quantity(sol.product_uom_qty, to_uom, raise_if_failure=False), 2)

131
models/account_move.py Normal file
View File

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import defaultdict
from odoo import api, fields, models, _
from odoo.osv import expression
class AccountMove(models.Model):
_inherit = "account.move"
timesheet_ids = fields.One2many('account.analytic.line', 'timesheet_invoice_id', string='Timesheets', readonly=True, copy=False)
timesheet_count = fields.Integer("Number of timesheets", compute='_compute_timesheet_count', compute_sudo=True)
timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
timesheet_total_duration = fields.Integer("Timesheet Total Duration",
compute='_compute_timesheet_total_duration', compute_sudo=True,
help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit")
@api.depends('timesheet_ids', 'company_id.timesheet_encode_uom_id')
def _compute_timesheet_total_duration(self):
if not self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
self.timesheet_total_duration = 0
return
group_data = self.env['account.analytic.line']._read_group([
('timesheet_invoice_id', 'in', self.ids)
], ['timesheet_invoice_id'], ['unit_amount:sum'])
timesheet_unit_amount_dict = defaultdict(float)
timesheet_unit_amount_dict.update({timesheet_invoice.id: amount for timesheet_invoice, amount in group_data})
for invoice in self:
total_time = invoice.company_id.project_time_mode_id._compute_quantity(timesheet_unit_amount_dict[invoice.id], invoice.timesheet_encode_uom_id)
invoice.timesheet_total_duration = round(total_time)
@api.depends('timesheet_ids')
def _compute_timesheet_count(self):
timesheet_data = self.env['account.analytic.line']._read_group([('timesheet_invoice_id', 'in', self.ids)], ['timesheet_invoice_id'], ['__count'])
mapped_data = {timesheet_invoice.id: count for timesheet_invoice, count in timesheet_data}
for invoice in self:
invoice.timesheet_count = mapped_data.get(invoice.id, 0)
def action_view_timesheet(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Timesheets'),
'domain': [('project_id', '!=', False)],
'res_model': 'account.analytic.line',
'view_id': False,
'view_mode': 'tree,form',
'help': _("""
<p class="o_view_nocontent_smiling_face">
Record timesheets
</p><p>
You can register and track your workings hours by project every
day. Every time spent on a project will become a cost and can be re-invoiced to
customers if required.
</p>
"""),
'limit': 80,
'context': {
'default_project_id': self.id,
'search_default_project_id': [self.id]
}
}
def _link_timesheets_to_invoice(self, start_date=None, end_date=None):
""" Search timesheets from given period and link this timesheets to the invoice
When we create an invoice from a sale order, we need to
link the timesheets in this sale order to the invoice.
Then, we can know which timesheets are invoiced in the sale order.
:param start_date: the start date of the period
:param end_date: the end date of the period
"""
for line in self.filtered(lambda i: i.move_type == 'out_invoice' and i.state == 'draft').invoice_line_ids:
sale_line_delivery = line.sale_line_ids.filtered(lambda sol: sol.product_id.invoice_policy == 'delivery' and sol.product_id.service_type == 'timesheet')
if sale_line_delivery:
domain = line._timesheet_domain_get_invoiced_lines(sale_line_delivery)
if start_date:
domain = expression.AND([domain, [('date', '>=', start_date)]])
if end_date:
domain = expression.AND([domain, [('date', '<=', end_date)]])
timesheets = self.env['account.analytic.line'].sudo().search(domain)
timesheets.write({'timesheet_invoice_id': line.move_id.id})
class AccountMoveLine(models.Model):
_inherit = 'account.move.line'
@api.model
def _timesheet_domain_get_invoiced_lines(self, sale_line_delivery):
""" Get the domain for the timesheet to link to the created invoice
:param sale_line_delivery: recordset of sale.order.line to invoice
:return a normalized domain
"""
return [
('so_line', 'in', sale_line_delivery.ids),
('project_id', '!=', False),
'|', '|',
('timesheet_invoice_id', '=', False),
('timesheet_invoice_id.state', '=', 'cancel'),
('timesheet_invoice_id.payment_state', '=', 'reversed')
]
def unlink(self):
move_line_read_group = self.env['account.move.line'].search_read([
('move_id.move_type', '=', 'out_invoice'),
('move_id.state', '=', 'draft'),
('sale_line_ids.product_id.invoice_policy', '=', 'delivery'),
('sale_line_ids.product_id.service_type', '=', 'timesheet'),
('id', 'in', self.ids)],
['move_id', 'sale_line_ids'])
sale_line_ids_per_move = defaultdict(lambda: self.env['sale.order.line'])
for move_line in move_line_read_group:
sale_line_ids_per_move[move_line['move_id'][0]] += self.env['sale.order.line'].browse(move_line['sale_line_ids'])
timesheet_read_group = self.sudo().env['account.analytic.line']._read_group([
('timesheet_invoice_id.move_type', '=', 'out_invoice'),
('timesheet_invoice_id.state', '=', 'draft'),
('timesheet_invoice_id', 'in', self.move_id.ids)],
['timesheet_invoice_id', 'so_line'],
['id:array_agg'])
timesheet_ids = []
for timesheet_invoice, so_line, ids in timesheet_read_group:
if so_line.id in sale_line_ids_per_move[timesheet_invoice.id].ids:
timesheet_ids += ids
self.sudo().env['account.analytic.line'].browse(timesheet_ids).write({'timesheet_invoice_id': False})
return super().unlink()

15
models/hr_employee.py Normal file
View File

@ -0,0 +1,15 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class HrEmployee(models.Model):
_inherit = 'hr.employee'
@api.model
def default_get(self, fields):
result = super(HrEmployee, self).default_get(fields)
project_company_id = self.env.context.get('create_project_employee_mapping', False)
if project_company_id:
result['company_id'] = project_company_id
return result

178
models/product.py Normal file
View File

@ -0,0 +1,178 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import threading
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError
class ProductTemplate(models.Model):
_inherit = 'product.template'
def _selection_service_policy(self):
service_policies = super()._selection_service_policy()
service_policies.insert(1, ('delivered_timesheet', _('Based on Timesheets')))
return service_policies
service_type = fields.Selection(selection_add=[
('timesheet', 'Timesheets on project (one fare per SO/Project)'),
], ondelete={'timesheet': 'set manual'})
# override domain
project_id = fields.Many2one(domain="['|', ('company_id', '=', False), '&', ('company_id', '=?', company_id), ('company_id', '=', current_company_id), ('allow_billable', '=', True), ('pricing_type', '=', 'task_rate'), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
project_template_id = fields.Many2one(domain="['|', ('company_id', '=', False), '&', ('company_id', '=?', company_id), ('company_id', '=', current_company_id), ('allow_billable', '=', True), ('allow_timesheets', 'in', [service_policy == 'delivered_timesheet', True])]")
service_upsell_threshold = fields.Float('Threshold', default=1, help="Percentage of time delivered compared to the prepaid amount that must be reached for the upselling opportunity activity to be triggered.")
service_upsell_threshold_ratio = fields.Char(compute='_compute_service_upsell_threshold_ratio')
@api.depends('uom_id', 'company_id')
def _compute_service_upsell_threshold_ratio(self):
product_uom_hour = self.env.ref('uom.product_uom_hour')
uom_unit = self.env.ref('uom.product_uom_unit')
company_uom = self.env.company.timesheet_encode_uom_id
for record in self:
if not record.uom_id or record.uom_id != uom_unit or\
product_uom_hour.factor == record.uom_id.factor or\
record.uom_id.category_id not in [product_uom_hour.category_id, uom_unit.category_id]:
record.service_upsell_threshold_ratio = False
continue
else:
timesheet_encode_uom = record.company_id.timesheet_encode_uom_id or company_uom
record.service_upsell_threshold_ratio = f'(1 {record.uom_id.name} = {timesheet_encode_uom.factor / product_uom_hour.factor:.2f} {timesheet_encode_uom.name})'
def _compute_visible_expense_policy(self):
visibility = self.user_has_groups('project.group_project_user')
for product_template in self:
if not product_template.visible_expense_policy:
product_template.visible_expense_policy = visibility
return super(ProductTemplate, self)._compute_visible_expense_policy()
@api.depends('service_tracking', 'service_policy', 'type', 'sale_ok')
def _compute_product_tooltip(self):
super()._compute_product_tooltip()
for record in self.filtered(lambda record: record.type == 'service' and record.sale_ok):
if record.service_policy == 'delivered_timesheet':
if record.service_tracking == 'no':
record.product_tooltip = _(
"Invoice based on timesheets (delivered quantity) on projects or tasks "
"you'll create later on."
)
elif record.service_tracking == 'task_global_project':
record.product_tooltip = _(
"Invoice based on timesheets (delivered quantity), and create a task in "
"an existing project to track the time spent."
)
elif record.service_tracking == 'task_in_project':
record.product_tooltip = _(
"Invoice based on timesheets (delivered quantity), and create a project "
"for the order with a task for each sales order line to track the time "
"spent."
)
elif record.service_tracking == 'project_only':
record.product_tooltip = _(
"Invoice based on timesheets (delivered quantity), and create an empty "
"project for the order to track the time spent."
)
@api.onchange('type', 'service_type', 'service_policy')
def _onchange_service_fields(self):
for record in self:
if record.type == 'service' and record.service_type == 'timesheet' and \
not (record._origin.service_policy and record.service_policy == record._origin.service_policy):
record.uom_id = self.env.ref('uom.product_uom_hour')
elif record._origin.uom_id:
record.uom_id = record._origin.uom_id
else:
record.uom_id = self._get_default_uom_id()
record.uom_po_id = record.uom_id
def _get_service_to_general_map(self):
return {
**super()._get_service_to_general_map(),
'delivered_timesheet': ('delivery', 'timesheet'),
'ordered_prepaid': ('order', 'timesheet'),
}
@api.model
def _get_onchange_service_policy_updates(self, service_tracking, service_policy, project_id, project_template_id):
vals = {}
if service_tracking != 'no' and service_policy == 'delivered_timesheet':
if project_id and not project_id.allow_timesheets:
vals['project_id'] = False
elif project_template_id and not project_template_id.allow_timesheets:
vals['project_template_id'] = False
return vals
@api.onchange('service_policy')
def _onchange_service_policy(self):
self._inverse_service_policy()
vals = self._get_onchange_service_policy_updates(self.service_tracking,
self.service_policy,
self.project_id,
self.project_template_id)
if vals:
self.update(vals)
@api.ondelete(at_uninstall=False)
def _unlink_except_master_data(self):
time_product = self.env.ref('sale_timesheet.time_product')
if time_product.product_tmpl_id in self:
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived nor deleted.', time_product.name))
def write(self, vals):
# timesheet product can't be archived
test_mode = getattr(threading.current_thread(), 'testing', False) or self.env.registry.in_test_mode()
if not test_mode and 'active' in vals and not vals['active']:
time_product = self.env.ref('sale_timesheet.time_product')
if time_product.product_tmpl_id in self:
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived nor deleted.', time_product.name))
return super(ProductTemplate, self).write(vals)
class ProductProduct(models.Model):
_inherit = 'product.product'
@tools.ormcache()
def _get_default_uom_id(self):
return self.env.ref('uom.product_uom_unit')
def _is_delivered_timesheet(self):
""" Check if the product is a delivered timesheet """
self.ensure_one()
return self.type == 'service' and self.service_policy == 'delivered_timesheet'
@api.onchange('type', 'service_type', 'service_policy')
def _onchange_service_fields(self):
for record in self:
if record.type == 'service' and record.service_type == 'timesheet' and \
not (record._origin.service_policy and record.service_policy == record._origin.service_policy):
record.uom_id = self.env.ref('uom.product_uom_hour')
elif record._origin.uom_id:
record.uom_id = record._origin.uom_id
else:
record.uom_id = self._get_default_uom_id()
record.uom_po_id = record.uom_id
@api.onchange('service_policy')
def _onchange_service_policy(self):
self._inverse_service_policy()
vals = self.product_tmpl_id._get_onchange_service_policy_updates(self.service_tracking,
self.service_policy,
self.project_id,
self.project_template_id)
if vals:
self.update(vals)
@api.ondelete(at_uninstall=False)
def _unlink_except_master_data(self):
time_product = self.env.ref('sale_timesheet.time_product')
if time_product in self:
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived nor deleted.', time_product.name))
def write(self, vals):
# timesheet product can't be archived
test_mode = getattr(threading.current_thread(), 'testing', False) or self.env.registry.in_test_mode()
if not test_mode and 'active' in vals and not vals['active']:
time_product = self.env.ref('sale_timesheet.time_product')
if time_product in self:
raise ValidationError(_('The %s product is required by the Timesheets app and cannot be archived nor deleted.', time_product.name))
return super(ProductProduct, self).write(vals)

633
models/project.py Normal file
View File

@ -0,0 +1,633 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
from collections import defaultdict
from odoo import api, fields, models, _, _lt
from odoo.osv import expression
from odoo.tools import SQL
from odoo.exceptions import ValidationError, UserError
# YTI PLEASE SPLIT ME
class Project(models.Model):
_inherit = 'project.project'
@api.model
def default_get(self, fields):
""" Pre-fill timesheet product as "Time" data product when creating new project allowing billable tasks by default. """
result = super(Project, self).default_get(fields)
if 'timesheet_product_id' in fields and result.get('allow_billable') and result.get('allow_timesheets') and not result.get('timesheet_product_id'):
default_product = self.env.ref('sale_timesheet.time_product', False)
if default_product:
result['timesheet_product_id'] = default_product.id
return result
def _default_timesheet_product_id(self):
return self.env.ref('sale_timesheet.time_product', False)
pricing_type = fields.Selection([
('task_rate', 'Task rate'),
('fixed_rate', 'Project rate'),
('employee_rate', 'Employee rate')
], string="Pricing", default="task_rate",
compute='_compute_pricing_type',
search='_search_pricing_type',
help='The task rate is perfect if you would like to bill different services to different customers at different rates. The fixed rate is perfect if you bill a service at a fixed rate per hour or day worked regardless of the employee who performed it. The employee rate is preferable if your employees deliver the same service at a different rate. For instance, junior and senior consultants would deliver the same service (= consultancy), but at a different rate because of their level of seniority.')
sale_line_employee_ids = fields.One2many('project.sale.line.employee.map', 'project_id', "Sale line/Employee map", copy=False,
help="Sales order item that will be selected by default on the timesheets of the corresponding employee. It bypasses the sales order item defined on the project and the task, and can be modified on each timesheet entry if necessary. In other words, it defines the rate at which an employee's time is billed based on their expertise, skills or experience, for instance.\n"
"If you would like to bill the same service at a different rate, you need to create two separate sales order items as each sales order item can only have a single unit price at a time.\n"
"You can also define the hourly company cost of your employees for their timesheets on this project specifically. It will bypass the timesheet cost set on the employee.")
billable_percentage = fields.Integer(
compute='_compute_billable_percentage', groups='hr_timesheet.group_hr_timesheet_approver',
help="% of timesheets that are billable compared to the total number of timesheets linked to the AA of the project, rounded to the unit.")
timesheet_product_id = fields.Many2one(
'product.product', string='Timesheet Product',
domain="""[
('detailed_type', '=', 'service'),
('invoice_policy', '=', 'delivery'),
('service_type', '=', 'timesheet'),
]""",
help='Service that will be used by default when invoicing the time spent on a task. It can be modified on each task individually by selecting a specific sales order item.',
check_company=True,
compute="_compute_timesheet_product_id", store=True, readonly=False,
default=_default_timesheet_product_id)
warning_employee_rate = fields.Boolean(compute='_compute_warning_employee_rate', compute_sudo=True)
partner_id = fields.Many2one(
compute='_compute_partner_id', store=True, readonly=False)
allocated_hours = fields.Float(copy=False)
billing_type = fields.Selection(
compute="_compute_billing_type",
selection=[
('not_billable', 'not billable'),
('manually', 'billed manually'),
],
default='not_billable',
required=True,
readonly=False,
store=True,
)
@api.model
def _get_view(self, view_id=None, view_type='form', **options):
arch, view = super()._get_view(view_id, view_type, **options)
if view_type == 'form' and self.env.company.timesheet_encode_uom_id == self.env.ref('uom.product_uom_day'):
for node in arch.xpath("//field[@name='display_cost'][not(@string)]"):
node.set('string', 'Daily Cost')
return arch, view
@api.depends('sale_line_id', 'sale_line_employee_ids', 'allow_billable')
def _compute_pricing_type(self):
billable_projects = self.filtered('allow_billable')
for project in billable_projects:
if project.sale_line_employee_ids:
project.pricing_type = 'employee_rate'
elif project.sale_line_id:
project.pricing_type = 'fixed_rate'
else:
project.pricing_type = 'task_rate'
(self - billable_projects).update({'pricing_type': False})
def _search_pricing_type(self, operator, value):
""" Search method for pricing_type field.
This method returns a domain based on the operator and the value given in parameter:
- operator = '=':
- value = 'task_rate': [('sale_line_employee_ids', '=', False), ('sale_line_id', '=', False), ('allow_billable', '=', True)]
- value = 'fixed_rate': [('sale_line_employee_ids', '=', False), ('sale_line_id', '!=', False), ('allow_billable', '=', True)]
- value = 'employee_rate': [('sale_line_employee_ids', '!=', False), ('allow_billable', '=', True)]
- value is False: [('allow_billable', '=', False)]
- operator = '!=':
- value = 'task_rate': ['|', '|', ('sale_line_employee_ids', '!=', False), ('sale_line_id', '!=', False), ('allow_billable', '=', False)]
- value = 'fixed_rate': ['|', '|', ('sale_line_employee_ids', '!=', False), ('sale_line_id', '=', False), ('allow_billable', '=', False)]
- value = 'employee_rate': ['|', ('sale_line_employee_ids', '=', False), ('allow_billable', '=', False)]
- value is False: [('allow_billable', '!=', False)]
:param operator: the supported operator is either '=' or '!='.
:param value: the value than the field should be is among these values into the following tuple: (False, 'task_rate', 'fixed_rate', 'employee_rate').
:returns: the domain to find the expected projects.
"""
if operator not in ('=', '!='):
raise UserError(_('Operation not supported'))
if not ((isinstance(value, bool) and value is False) or (isinstance(value, str) and value in ('task_rate', 'fixed_rate', 'employee_rate'))):
raise UserError(_('Value does not exist in the pricing type'))
if value is False:
return [('allow_billable', operator, value)]
sol_cond = ('sale_line_id', '!=', False)
mapping_cond = ('sale_line_employee_ids', '!=', False)
if value == 'task_rate':
domain = [expression.NOT_OPERATOR, sol_cond, expression.NOT_OPERATOR, mapping_cond]
elif value == 'fixed_rate':
domain = [sol_cond, expression.NOT_OPERATOR, mapping_cond]
else: # value == 'employee_rate'
domain = [mapping_cond]
domain = expression.AND([domain, [('allow_billable', '=', True)]])
domain = expression.normalize_domain(domain)
if operator != '=':
domain.insert(0, expression.NOT_OPERATOR)
domain = expression.distribute_not(domain)
return domain
@api.depends('analytic_account_id', 'timesheet_ids')
def _compute_billable_percentage(self):
timesheets_read_group = self.env['account.analytic.line']._read_group([('project_id', 'in', self.ids)], ['project_id', 'so_line'], ['unit_amount:sum'])
timesheets_by_project = defaultdict(list)
for project, so_line, unit_amount_sum in timesheets_read_group:
timesheets_by_project[project.id].append((unit_amount_sum, bool(so_line)))
for project in self:
timesheet_total = timesheet_billable = 0.0
for unit_amount, is_billable_timesheet in timesheets_by_project[project.id]:
timesheet_total += unit_amount
if is_billable_timesheet:
timesheet_billable += unit_amount
billable_percentage = timesheet_billable / timesheet_total * 100 if timesheet_total > 0 else 0
project.billable_percentage = round(billable_percentage)
@api.depends('allow_timesheets', 'allow_billable')
def _compute_timesheet_product_id(self):
default_product = self.env.ref('sale_timesheet.time_product', False)
for project in self:
if not project.allow_timesheets or not project.allow_billable:
project.timesheet_product_id = False
elif not project.timesheet_product_id:
project.timesheet_product_id = default_product
@api.depends('pricing_type', 'allow_timesheets', 'allow_billable', 'sale_line_employee_ids', 'sale_line_employee_ids.employee_id')
def _compute_warning_employee_rate(self):
projects = self.filtered(lambda p: p.allow_billable and p.allow_timesheets and p.pricing_type == 'employee_rate')
employees = self.env['account.analytic.line']._read_group(
[('task_id', 'in', projects.task_ids.ids), ('employee_id', '!=', False)],
['project_id'],
['employee_id:array_agg'],
)
dict_project_employee = {project.id: employee_ids for project, employee_ids in employees}
for project in projects:
project.warning_employee_rate = any(
x not in project.sale_line_employee_ids.employee_id.ids
for x in dict_project_employee.get(project.id, ())
)
(self - projects).warning_employee_rate = False
@api.depends('sale_line_employee_ids.sale_line_id', 'sale_line_id')
def _compute_partner_id(self):
billable_projects = self.filtered('allow_billable')
for project in billable_projects:
if project.partner_id:
continue
if project.allow_billable and project.allow_timesheets and project.pricing_type != 'task_rate':
sol = project.sale_line_id or project.sale_line_employee_ids.sale_line_id[:1]
project.partner_id = sol.order_partner_id
super(Project, self - billable_projects)._compute_partner_id()
@api.depends('partner_id')
def _compute_sale_line_id(self):
super()._compute_sale_line_id()
for project in self.filtered(lambda p: not p.sale_line_id and p.partner_id and p.pricing_type == 'employee_rate'):
# Give a SOL by default either the last SOL with service product and remaining_hours > 0
sol = self.env['sale.order.line'].search([
('is_service', '=', True),
('order_partner_id', 'child_of', project.partner_id.commercial_partner_id.id),
('is_expense', '=', False),
('state', '=', 'sale'),
('remaining_hours', '>', 0)
], limit=1)
project.sale_line_id = sol or project.sale_line_employee_ids.sale_line_id[:1] # get the first SOL containing in the employee mappings if no sol found in the search
@api.depends('sale_line_employee_ids.sale_line_id', 'allow_billable')
def _compute_sale_order_count(self):
billable_projects = self.filtered('allow_billable')
super(Project, billable_projects)._compute_sale_order_count()
non_billable_projects = self - billable_projects
non_billable_projects.sale_order_line_count = 0
non_billable_projects.sale_order_count = 0
@api.depends('allow_billable', 'allow_timesheets')
def _compute_billing_type(self):
self.filtered(lambda project: (not project.allow_billable or not project.allow_timesheets) and project.billing_type == 'manually').billing_type = 'not_billable'
@api.constrains('sale_line_id')
def _check_sale_line_type(self):
for project in self.filtered(lambda project: project.sale_line_id):
if not project.sale_line_id.is_service:
raise ValidationError(_("You cannot link a billable project to a sales order item that is not a service."))
if project.sale_line_id.is_expense:
raise ValidationError(_("You cannot link a billable project to a sales order item that comes from an expense or a vendor bill."))
def write(self, values):
res = super(Project, self).write(values)
if 'allow_billable' in values and not values.get('allow_billable'):
self.task_ids._get_timesheet().write({
'so_line': False,
})
return res
def _update_timesheets_sale_line_id(self):
for project in self.filtered(lambda p: p.allow_billable and p.allow_timesheets):
timesheet_ids = project.sudo(False).mapped('timesheet_ids').filtered(lambda t: not t.is_so_line_edited and t._is_not_billed())
if not timesheet_ids:
continue
for employee_id in project.sale_line_employee_ids.filtered(lambda l: l.project_id == project).employee_id:
sale_line_id = project.sale_line_employee_ids.filtered(lambda l: l.project_id == project and l.employee_id == employee_id).sale_line_id
timesheet_ids.filtered(lambda t: t.employee_id == employee_id).sudo().so_line = sale_line_id
def action_view_timesheet(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': _('Timesheets of %s', self.name),
'domain': [('project_id', '!=', False)],
'res_model': 'account.analytic.line',
'view_id': False,
'view_mode': 'tree,form',
'help': _("""
<p class="o_view_nocontent_smiling_face">
Record timesheets
</p><p>
You can register and track your workings hours by project every
day. Every time spent on a project will become a cost and can be re-invoiced to
customers if required.
</p>
"""),
'limit': 80,
'context': {
'default_project_id': self.id,
'search_default_project_id': [self.id]
}
}
def action_make_billable(self):
return {
"name": _("Create Sales Order"),
"type": 'ir.actions.act_window',
"res_model": 'project.create.sale.order',
"views": [[False, "form"]],
"target": 'new',
"context": {
'active_id': self.id,
'active_model': 'project.project',
'default_product_id': self.timesheet_product_id.id,
},
}
def action_billable_time_button(self):
self.ensure_one()
action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order_item")
action.update({
'context': {
'grid_range': 'week',
'search_default_groupby_timesheet_invoice_type': True,
'default_project_id': self.id,
},
'domain': [('project_id', '=', self.id)],
})
return action
def action_profitability_items(self, section_name, domain=None, res_id=False):
self.ensure_one()
if section_name in ['billable_fixed', 'billable_time', 'billable_milestones', 'billable_manual', 'non_billable']:
action = self.action_billable_time_button()
if domain:
action['domain'] = expression.AND([[('project_id', '=', self.id)], domain])
action['context'].update(search_default_groupby_timesheet_invoice_type=False, **self.env.context)
graph_view = False
if section_name == 'billable_time':
graph_view = self.env.ref('sale_timesheet.view_hr_timesheet_line_graph_invoice_employee').id
action['views'] = [
(view_id, view_type) if view_type != 'graph' else (graph_view or view_id, view_type)
for view_id, view_type in action['views']
]
if res_id:
if 'views' in action:
action['views'] = [
(view_id, view_type)
for view_id, view_type in action['views']
if view_type == 'form'
] or [False, 'form']
action['view_mode'] = 'form'
action['res_id'] = res_id
return action
return super().action_profitability_items(section_name, domain, res_id)
# ----------------------------
# Project Updates
# ----------------------------
def get_panel_data(self):
panel_data = super(Project, self).get_panel_data()
return {
**panel_data,
'analytic_account_id': self.analytic_account_id.id,
}
def _get_sale_order_items_query(self, domain_per_model=None):
if domain_per_model is None:
domain_per_model = {'project.task': [('allow_billable', '=', True)]}
else:
domain_per_model['project.task'] = expression.AND([
domain_per_model.get('project.task', []),
[('allow_billable', '=', True)],
])
query = super()._get_sale_order_items_query(domain_per_model)
Timesheet = self.env['account.analytic.line']
timesheet_domain = [('project_id', 'in', self.ids), ('so_line', '!=', False), ('project_id.allow_billable', '=', True)]
if Timesheet._name in domain_per_model:
timesheet_domain = expression.AND([
domain_per_model.get(Timesheet._name, []),
timesheet_domain,
])
timesheet_query = Timesheet._where_calc(timesheet_domain)
Timesheet._apply_ir_rules(timesheet_query, 'read')
timesheet_sql = timesheet_query.select(
f'{Timesheet._table}.project_id AS id',
f'{Timesheet._table}.so_line AS sale_line_id',
)
EmployeeMapping = self.env['project.sale.line.employee.map']
employee_mapping_domain = [('project_id', 'in', self.ids), ('project_id.allow_billable', '=', True), ('sale_line_id', '!=', False)]
if EmployeeMapping._name in domain_per_model:
employee_mapping_domain = expression.AND([
domain_per_model[EmployeeMapping._name],
employee_mapping_domain,
])
employee_mapping_query = EmployeeMapping._where_calc(employee_mapping_domain)
EmployeeMapping._apply_ir_rules(employee_mapping_query, 'read')
employee_mapping_sql = employee_mapping_query.select(
f'{EmployeeMapping._table}.project_id AS id',
f'{EmployeeMapping._table}.sale_line_id',
)
query._tables['project_sale_order_item'] = SQL('(%s)', SQL(' UNION ').join([
query._tables['project_sale_order_item'],
timesheet_sql,
employee_mapping_sql,
]))
return query
def _get_profitability_labels(self):
return {
**super()._get_profitability_labels(),
'billable_fixed': _lt('Timesheets (Fixed Price)'),
'billable_time': _lt('Timesheets (Billed on Timesheets)'),
'billable_milestones': _lt('Timesheets (Billed on Milestones)'),
'billable_manual': _lt('Timesheets (Billed Manually)'),
'non_billable': _lt('Timesheets (Non Billable)'),
'timesheet_revenues': _lt('Timesheets revenues'),
'other_costs': _lt('Materials'),
}
def _get_profitability_sequence_per_invoice_type(self):
return {
**super()._get_profitability_sequence_per_invoice_type(),
'billable_fixed': 1,
'billable_time': 2,
'billable_milestones': 3,
'billable_manual': 4,
'non_billable': 5,
'timesheet_revenues': 6,
'other_costs': 12,
}
def _get_profitability_aal_domain(self):
domain = ['|', ('project_id', 'in', self.ids), ('so_line', 'in', self._fetch_sale_order_item_ids())]
return expression.AND([
super()._get_profitability_aal_domain(),
domain,
])
def _get_profitability_items_from_aal(self, profitability_items, with_action=True):
if not self.allow_timesheets:
total_invoiced = total_to_invoice = 0.0
revenue_data = []
for revenue in profitability_items['revenues']['data']:
if revenue['id'] in ['billable_fixed', 'billable_time', 'billable_milestones', 'billable_manual']:
continue
total_invoiced += revenue['invoiced']
total_to_invoice += revenue['to_invoice']
revenue_data.append(revenue)
profitability_items['revenues'] = {
'data': revenue_data,
'total': {'to_invoice': total_to_invoice, 'invoiced': total_invoiced},
}
return profitability_items
aa_line_read_group = self.env['account.analytic.line'].sudo()._read_group(
self.sudo()._get_profitability_aal_domain(),
['timesheet_invoice_type', 'timesheet_invoice_id', 'currency_id'],
['amount:sum', 'id:array_agg'],
)
can_see_timesheets = with_action and len(self) == 1 and self.user_has_groups('hr_timesheet.group_hr_timesheet_approver')
revenues_dict = {}
costs_dict = {}
total_revenues = {'invoiced': 0.0, 'to_invoice': 0.0}
total_costs = {'billed': 0.0, 'to_bill': 0.0}
convert_company = self.company_id or self.env.company
for timesheet_invoice_type, dummy, currency, amount, ids in aa_line_read_group:
amount = currency._convert(amount, self.currency_id, convert_company)
invoice_type = timesheet_invoice_type
cost = costs_dict.setdefault(invoice_type, {'billed': 0.0, 'to_bill': 0.0})
revenue = revenues_dict.setdefault(invoice_type, {'invoiced': 0.0, 'to_invoice': 0.0})
if amount < 0: # cost
cost['billed'] += amount
total_costs['billed'] += amount
else: # revenues
revenue['invoiced'] += amount
total_revenues['invoiced'] += amount
if can_see_timesheets and invoice_type not in ['other_costs', 'other_revenues']:
cost.setdefault('record_ids', []).extend(ids)
revenue.setdefault('record_ids', []).extend(ids)
action_name = None
if can_see_timesheets:
action_name = 'action_profitability_items'
def get_timesheets_action(invoice_type, record_ids):
args = [invoice_type, [('id', 'in', record_ids)]]
if len(record_ids) == 1:
args.append(record_ids[0])
return {'name': action_name, 'type': 'object', 'args': json.dumps(args)}
sequence_per_invoice_type = self._get_profitability_sequence_per_invoice_type()
def convert_dict_into_profitability_data(d, cost=True):
profitability_data = []
key1, key2 = ['to_bill', 'billed'] if cost else ['to_invoice', 'invoiced']
for invoice_type, vals in d.items():
if not vals[key1] and not vals[key2]:
continue
record_ids = vals.pop('record_ids', [])
data = {'id': invoice_type, 'sequence': sequence_per_invoice_type[invoice_type], **vals}
if record_ids:
if invoice_type not in ['other_costs', 'other_revenues'] and can_see_timesheets: # action to see the timesheets
action = get_timesheets_action(invoice_type, record_ids)
data['action'] = action
profitability_data.append(data)
return profitability_data
def merge_profitability_data(a, b):
return {
'data': a['data'] + b['data'],
'total': {key: a['total'][key] + b['total'][key] for key in a['total'].keys() if key in b['total']}
}
for revenue in profitability_items['revenues']['data']:
revenue_id = revenue['id']
aal_revenue = revenues_dict.pop(revenue_id, {})
revenue['to_invoice'] += aal_revenue.get('to_invoice', 0.0)
revenue['invoiced'] += aal_revenue.get('invoiced', 0.0)
record_ids = aal_revenue.get('record_ids', [])
if can_see_timesheets and record_ids:
action = get_timesheets_action(revenue_id, record_ids)
revenue['action'] = action
for cost in profitability_items['costs']['data']:
cost_id = cost['id']
aal_cost = costs_dict.pop(cost_id, {})
cost['to_bill'] += aal_cost.get('to_bill', 0.0)
cost['billed'] += aal_cost.get('billed', 0.0)
record_ids = aal_cost.get('record_ids', [])
if can_see_timesheets and record_ids:
cost['action'] = get_timesheets_action(cost_id, record_ids)
profitability_items['revenues'] = merge_profitability_data(
profitability_items['revenues'],
{'data': convert_dict_into_profitability_data(revenues_dict, False), 'total': total_revenues},
)
profitability_items['costs'] = merge_profitability_data(
profitability_items['costs'],
{'data': convert_dict_into_profitability_data(costs_dict), 'total': total_costs},
)
return profitability_items
def _get_domain_aal_with_no_move_line(self):
# we add the tuple 'project_id = False' in the domain to remove the timesheets from the search.
return expression.AND([
super()._get_domain_aal_with_no_move_line(),
[('project_id', '=', False)]
])
def _get_service_policy_to_invoice_type(self):
return {
**super()._get_service_policy_to_invoice_type(),
'ordered_prepaid': 'billable_fixed',
'delivered_milestones': 'billable_milestones',
'delivered_timesheet': 'billable_time',
'delivered_manual': 'billable_manual',
}
def _get_profitability_items(self, with_action=True):
return self._get_profitability_items_from_aal(
super()._get_profitability_items(with_action),
with_action
)
class ProjectTask(models.Model):
_inherit = "project.task"
def _get_default_partner_id(self, project, parent):
res = super()._get_default_partner_id(project, parent)
if not res and project:
# project in sudo if the current user is a portal user.
related_project = project if not self.user_has_groups('!base.group_user,base.group_portal') else project.sudo()
if related_project.pricing_type == 'employee_rate':
return related_project.sale_line_employee_ids.sale_line_id.order_partner_id[:1]
return res
sale_order_id = fields.Many2one(domain="['|', '|', ('partner_id', '=', partner_id), ('partner_id', 'child_of', commercial_partner_id), ('partner_id', 'parent_of', partner_id)]")
so_analytic_account_id = fields.Many2one(related='sale_order_id.analytic_account_id', string='Sale Order Analytic Account')
pricing_type = fields.Selection(related="project_id.pricing_type")
is_project_map_empty = fields.Boolean("Is Project map empty", compute='_compute_is_project_map_empty')
has_multi_sol = fields.Boolean(compute='_compute_has_multi_sol', compute_sudo=True)
timesheet_product_id = fields.Many2one(related="project_id.timesheet_product_id")
remaining_hours_so = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours_so', search='_search_remaining_hours_so', compute_sudo=True)
remaining_hours_available = fields.Boolean(related="sale_line_id.remaining_hours_available")
@property
def SELF_READABLE_FIELDS(self):
return super().SELF_READABLE_FIELDS | {
'remaining_hours_available',
'remaining_hours_so',
}
@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.product_uom_id._compute_quantity(delta, uom_hour)
for task in self:
task.remaining_hours_so = mapped_remaining_hours[task._origin.id]
@api.model
def _search_remaining_hours_so(self, operator, value):
return [('sale_line_id.remaining_hours', operator, value)]
@api.depends('so_analytic_account_id.active')
def _compute_analytic_account_active(self):
super()._compute_analytic_account_active()
for task in self:
task.analytic_account_active = task.analytic_account_active or task.so_analytic_account_id.active
@api.depends('partner_id.commercial_partner_id', 'sale_line_id.order_partner_id', 'parent_id.sale_line_id', 'project_id.sale_line_id', 'allow_billable')
def _compute_sale_line(self):
super()._compute_sale_line()
for task in self:
if task.allow_billable and not task.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):
for task in self:
task.is_project_map_empty = not bool(task.sudo().project_id.sale_line_employee_ids)
@api.depends('timesheet_ids')
def _compute_has_multi_sol(self):
for task in self:
task.has_multi_sol = task.timesheet_ids and task.timesheet_ids.so_line != task.sale_line_id
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.partner_id.commercial_partner_id or not self.allow_billable:
return False
domain = [
('company_id', '=?', self.company_id.id),
('is_service', '=', True),
('order_partner_id', 'child_of', self.partner_id.commercial_partner_id.id),
('is_expense', '=', False),
('state', '=', 'sale'),
('remaining_hours', '>', 0),
]
if self.project_id.pricing_type != 'task_rate' and self.project_sale_order_id and self.partner_id.commercial_partner_id == self.project_id.partner_id.commercial_partner_id:
domain.append(('order_id', '=?', self.project_sale_order_id.id))
return self.env['sale.order.line'].search(domain, limit=1)
def _get_timesheet(self):
# return not invoiced timesheet and timesheet without so_line or so_line linked to task
timesheet_ids = super(ProjectTask, self)._get_timesheet()
return timesheet_ids.filtered(lambda t: t._is_not_billed())
def _get_action_view_so_ids(self):
return list(set((self.sale_order_id + self.timesheet_ids.so_line.order_id).ids))
class ProjectTaskRecurrence(models.Model):
_inherit = 'project.task.recurrence'
@api.model
def _get_recurring_fields_to_copy(self):
return super(ProjectTaskRecurrence, self)._get_recurring_fields_to_copy() + ['so_analytic_account_id']

View File

@ -0,0 +1,131 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
class ProjectProductEmployeeMap(models.Model):
_name = 'project.sale.line.employee.map'
_description = 'Project Sales line, employee mapping'
project_id = fields.Many2one('project.project', "Project", required=True)
employee_id = fields.Many2one('hr.employee', "Employee", required=True, domain="[('id', 'not in', existing_employee_ids)]")
existing_employee_ids = fields.Many2many('hr.employee', compute="_compute_existing_employee_ids")
sale_line_id = fields.Many2one(
'sale.order.line', "Sales Order Item",
compute="_compute_sale_line_id", store=True, readonly=False,
domain="""[
('is_service', '=', True),
('is_expense', '=', False),
('state', '=', 'sale'),
('order_partner_id', '=?', partner_id)]""")
sale_order_id = fields.Many2one(related="project_id.sale_order_id")
company_id = fields.Many2one('res.company', string='Company', related='project_id.company_id')
partner_id = fields.Many2one(related='project_id.partner_id')
price_unit = fields.Float("Unit Price", compute='_compute_price_unit', store=True, readonly=True)
currency_id = fields.Many2one('res.currency', string="Currency", compute='_compute_currency_id', store=True, readonly=False)
cost = fields.Monetary(currency_field='cost_currency_id', compute='_compute_cost', store=True, readonly=False,
help="This cost overrides the employee's default employee hourly wage in employee's HR Settings")
display_cost = fields.Monetary(currency_field='cost_currency_id', compute="_compute_display_cost", inverse="_inverse_display_cost", string="Hourly Cost")
cost_currency_id = fields.Many2one('res.currency', string="Cost Currency", related='employee_id.currency_id', readonly=True)
is_cost_changed = fields.Boolean('Is Cost Manually Changed', compute='_compute_is_cost_changed', store=True)
_sql_constraints = [
('uniqueness_employee', 'UNIQUE(project_id,employee_id)', 'An employee cannot be selected more than once in the mapping. Please remove duplicate(s) and try again.'),
]
@api.depends('employee_id', 'project_id.sale_line_employee_ids.employee_id')
def _compute_existing_employee_ids(self):
project = self.project_id
if len(project) == 1:
self.existing_employee_ids = project.sale_line_employee_ids.employee_id
return
for map_entry in self:
map_entry.existing_employee_ids = map_entry.project_id.sale_line_employee_ids.employee_id
@api.depends('partner_id')
def _compute_sale_line_id(self):
self.filtered(
lambda map_entry:
map_entry.sale_line_id
and map_entry.partner_id
and map_entry.sale_line_id.order_partner_id.commercial_partner_id != map_entry.partner_id.commercial_partner_id
).update({'sale_line_id': False})
@api.depends('sale_line_id.price_unit')
def _compute_price_unit(self):
for line in self:
if line.sale_line_id:
line.price_unit = line.sale_line_id.price_unit
else:
line.price_unit = 0
@api.depends('sale_line_id.price_unit')
def _compute_currency_id(self):
for line in self:
line.currency_id = line.sale_line_id.currency_id if line.sale_line_id else False
@api.depends('employee_id.hourly_cost')
def _compute_cost(self):
self.env.remove_to_compute(self._fields['is_cost_changed'], self)
for map_entry in self:
if not map_entry.is_cost_changed:
map_entry.cost = map_entry.employee_id.hourly_cost or 0.0
def _get_working_hours_per_calendar(self, is_uom_day=False):
resource_calendar_per_hours = {}
if not is_uom_day:
return resource_calendar_per_hours
read_group_data = self.env['resource.calendar']._read_group(
[('id', 'in', self.employee_id.resource_calendar_id.ids)],
['hours_per_day'],
['id:array_agg'],
)
for hours_per_day, ids in read_group_data:
for calendar_id in ids:
resource_calendar_per_hours[calendar_id] = hours_per_day
return resource_calendar_per_hours
@api.depends_context('company')
@api.depends('cost', 'employee_id.resource_calendar_id')
def _compute_display_cost(self):
is_uom_day = self.env.ref('uom.product_uom_day') == self.env.company.timesheet_encode_uom_id
resource_calendar_per_hours = self._get_working_hours_per_calendar(is_uom_day)
for map_line in self:
if is_uom_day:
map_line.display_cost = map_line.cost * resource_calendar_per_hours.get(map_line.employee_id.resource_calendar_id.id, 1)
else:
map_line.display_cost = map_line.cost
def _inverse_display_cost(self):
is_uom_day = self.env.ref('uom.product_uom_day') == self.env.company.timesheet_encode_uom_id
resource_calendar_per_hours = self._get_working_hours_per_calendar(is_uom_day)
for map_line in self:
if is_uom_day:
map_line.cost = map_line.display_cost / resource_calendar_per_hours.get(map_line.employee_id.resource_calendar_id.id, 1)
else:
map_line.cost = map_line.display_cost
@api.depends('cost')
def _compute_is_cost_changed(self):
for map_entry in self:
map_entry.is_cost_changed = map_entry.employee_id and map_entry.cost != map_entry.employee_id.hourly_cost
@api.model_create_multi
def create(self, vals_list):
maps = super().create(vals_list)
maps._update_project_timesheet()
return maps
def write(self, values):
res = super(ProjectProductEmployeeMap, self).write(values)
self._update_project_timesheet()
return res
def _update_project_timesheet(self):
self.filtered(lambda l: l.sale_line_id).project_id._update_timesheets_sale_line_id()

89
models/project_update.py Normal file
View File

@ -0,0 +1,89 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
from odoo.tools import float_utils, format_amount, formatLang
from odoo.tools.misc import format_duration
class ProjectUpdate(models.Model):
_inherit = 'project.update'
@api.model
def _get_template_values(self, project):
template_values = super(ProjectUpdate, self)._get_template_values(project)
services = self._get_services_values(project)
profitability = self._get_profitability_values(project)
show_profitability = bool(profitability and profitability.get('analytic_account_id') and (profitability.get('costs') or profitability.get('revenues')))
show_sold = template_values['project'].allow_billable and len(services.get('data', [])) > 0
return {
**template_values,
'show_sold': show_sold,
'show_profitability': show_profitability,
'show_activities': template_values['show_activities'] or show_profitability or show_sold,
'services': services,
'profitability': profitability,
'format_value': lambda value, is_hour: str(round(value, 2)) if not is_hour else format_duration(value),
}
@api.model
def _get_services_values(self, project):
if not project.allow_billable:
return {}
services = []
sols = self.env['sale.order.line'].search(
project._get_sale_items_domain([
('is_downpayment', '=', False),
]),
)
product_uom_unit = self.env.ref('uom.product_uom_unit')
product_uom_hour = self.env.ref('uom.product_uom_hour')
company_uom = self.env.company.timesheet_encode_uom_id
for sol in sols:
#We only want to consider hours and days for this calculation
is_unit = sol.product_uom == product_uom_unit
if sol.product_uom.category_id == company_uom.category_id or is_unit:
product_uom_qty = sol.product_uom._compute_quantity(sol.product_uom_qty, company_uom, raise_if_failure=False)
qty_delivered = sol.product_uom._compute_quantity(sol.qty_delivered, company_uom, raise_if_failure=False)
qty_invoiced = sol.product_uom._compute_quantity(sol.qty_invoiced, company_uom, raise_if_failure=False)
unit = sol.product_uom if is_unit else company_uom
services.append({
'name': sol.with_context(with_price_unit=True).display_name,
'sold_value': product_uom_qty,
'effective_value': qty_delivered,
'remaining_value': product_uom_qty - qty_delivered,
'invoiced_value': qty_invoiced,
'unit': unit.name,
'is_unit': is_unit,
'is_hour': unit == product_uom_hour,
'sol': sol,
})
return {
'data': services,
'company_unit_name': company_uom.name,
'is_hour': company_uom == product_uom_hour,
}
@api.model
def _get_profitability_values(self, project):
costs_revenues = project.analytic_account_id and project.allow_billable
if not (self.user_has_groups('project.group_project_manager') and costs_revenues):
return {}
profitability_items = project._get_profitability_items(False)
costs = sum(profitability_items['costs']['total'].values())
revenues = sum(profitability_items['revenues']['total'].values())
margin = revenues + costs
return {
'analytic_account_id': project.analytic_account_id,
'costs': costs,
'costs_formatted': format_amount(self.env, -costs, project.currency_id),
'revenues': revenues,
'revenues_formatted': format_amount(self.env, revenues, project.currency_id),
'margin': margin,
'margin_formatted': format_amount(self.env, margin, project.currency_id),
'margin_percentage': formatLang(self.env,
not float_utils.float_is_zero(costs, precision_digits=2) and (margin / -costs) * 100 or 0.0,
digits=0),
}

View File

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
invoice_policy = fields.Boolean(string="Invoice Policy", help="Timesheets taken when invoicing time spent")

355
models/sale_order.py Normal file
View File

@ -0,0 +1,355 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import math
from collections import defaultdict
from odoo import api, fields, models, _
from odoo.osv import expression
from odoo.tools import float_compare
class SaleOrder(models.Model):
_inherit = 'sale.order'
timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_count', groups="hr_timesheet.group_hr_timesheet_user")
# override domain
project_id = fields.Many2one(domain="[('pricing_type', '!=', 'employee_rate'), ('analytic_account_id', '!=', False), ('company_id', '=', company_id)]")
timesheet_encode_uom_id = fields.Many2one('uom.uom', related='company_id.timesheet_encode_uom_id')
timesheet_total_duration = fields.Integer("Timesheet Total Duration", compute='_compute_timesheet_total_duration',
help="Total recorded duration, expressed in the encoding UoM, and rounded to the unit", compute_sudo=True,
groups="hr_timesheet.group_hr_timesheet_user")
show_hours_recorded_button = fields.Boolean(compute="_compute_show_hours_recorded_button", groups="hr_timesheet.group_hr_timesheet_user")
def _compute_timesheet_count(self):
timesheets_per_so = {
order.id: count
for order, count in self.env['account.analytic.line']._read_group(
[('order_id', 'in', self.ids), ('project_id', '!=', False)],
['order_id'],
['__count'],
)
}
for order in self:
order.timesheet_count = timesheets_per_so.get(order.id, 0)
@api.depends('company_id.project_time_mode_id', 'company_id.timesheet_encode_uom_id', 'order_line.timesheet_ids')
def _compute_timesheet_total_duration(self):
group_data = self.env['account.analytic.line']._read_group([
('order_id', 'in', self.ids), ('project_id', '!=', False)
], ['order_id'], ['unit_amount:sum'])
timesheet_unit_amount_dict = defaultdict(float)
timesheet_unit_amount_dict.update({order.id: unit_amount for order, unit_amount in group_data})
for sale_order in self:
total_time = sale_order.company_id.project_time_mode_id._compute_quantity(timesheet_unit_amount_dict[sale_order.id], sale_order.timesheet_encode_uom_id)
sale_order.timesheet_total_duration = round(total_time)
def _compute_field_value(self, field):
if field.name != 'invoice_status' or self.env.context.get('mail_activity_automation_skip'):
return super()._compute_field_value(field)
# Get SOs which their state is not equal to upselling and if at least a SOL has warning prepaid service upsell set to True and the warning has not already been displayed
upsellable_orders = self.filtered(lambda so:
so.state == 'sale'
and so.invoice_status != 'upselling'
and so.id
and (so.user_id or so.partner_id.user_id) # salesperson needed to assign upsell activity
)
super(SaleOrder, upsellable_orders.with_context(mail_activity_automation_skip=True))._compute_field_value(field)
for order in upsellable_orders:
upsellable_lines = order._get_prepaid_service_lines_to_upsell()
if upsellable_lines:
order._create_upsell_activity()
# We want to display only one time the warning for each SOL
upsellable_lines.write({'has_displayed_warning_upsell': True})
super(SaleOrder, self - upsellable_orders)._compute_field_value(field)
def _compute_show_hours_recorded_button(self):
show_button_ids = self._get_order_with_valid_service_product()
for order in self:
order.show_hours_recorded_button = order.timesheet_count or order.project_count and order.id in show_button_ids
def _get_order_with_valid_service_product(self):
return self.env['sale.order.line']._read_group([
('order_id', 'in', self.ids),
('state', '=', 'sale'),
('is_service', '=', True),
'|',
('product_id.service_type', 'not in', ['milestones', 'manual']),
('product_id.invoice_policy', '!=', 'delivery'),
], aggregates=['order_id:array_agg'])[0][0]
def _get_prepaid_service_lines_to_upsell(self):
""" Retrieve all sols which need to display an upsell activity warning in the SO
These SOLs should contain a product which has:
- type="service",
- service_policy="ordered_prepaid",
"""
self.ensure_one()
precision = self.env['decimal.precision'].precision_get('Product Unit of Measure')
return self.order_line.filtered(lambda sol:
sol.is_service
and not sol.has_displayed_warning_upsell # we don't want to display many times the warning each time we timesheet on the SOL
and sol.product_id.service_policy == 'ordered_prepaid'
and float_compare(
sol.qty_delivered,
sol.product_uom_qty * (sol.product_id.service_upsell_threshold or 1.0),
precision_digits=precision
) > 0
)
def action_view_timesheet(self):
self.ensure_one()
if not self.order_line:
return {'type': 'ir.actions.act_window_close'}
action = self.env["ir.actions.actions"]._for_xml_id("sale_timesheet.timesheet_action_from_sales_order")
default_sale_line = next((sale_line for sale_line in self.order_line if sale_line.is_service and sale_line.product_id.service_policy in ['ordered_prepaid', 'delivered_timesheet']), self.env['sale.order.line'])
context = {
'search_default_billable_timesheet': True,
'default_is_so_line_edited': True,
'default_so_line': default_sale_line.id,
} # erase default filters
tasks = self.order_line.task_id._filter_access_rules_python('write')
if tasks:
context['default_task_id'] = tasks[0].id
else:
projects = self.order_line.project_id._filter_access_rules_python('write')
if projects:
context['default_project_id'] = projects[0].id
elif self.project_ids:
context['default_project_id'] = self.project_ids[0].id
action.update({
'context': context,
'domain': [('so_line', 'in', self.order_line.ids), ('project_id', '!=', False)],
'help': _("""
<p class="o_view_nocontent_smiling_face">
No activities found. Let's start a new one!
</p><p>
Track your working hours by projects every day and invoice this time to your customers.
</p>
""")
})
return action
def _create_invoices(self, grouped=False, final=False, date=None):
"""Link timesheets to the created invoices. Date interval is injected in the
context in sale_make_invoice_advance_inv wizard.
"""
moves = super()._create_invoices(grouped=grouped, final=final, date=date)
moves._link_timesheets_to_invoice(self.env.context.get("timesheet_start_date"), self.env.context.get("timesheet_end_date"))
return moves
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
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', compute_sudo=True)
remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours', compute_sudo=True, store=True)
has_displayed_warning_upsell = fields.Boolean('Has Displayed Warning Upsell', copy=False)
timesheet_ids = fields.One2many('account.analytic.line', 'so_line', domain=[('project_id', '!=', False)], string='Timesheets')
@api.depends('remaining_hours_available', 'remaining_hours')
@api.depends_context('with_remaining_hours', 'company')
def _compute_display_name(self):
super()._compute_display_name()
with_remaining_hours = self.env.context.get('with_remaining_hours')
if with_remaining_hours and any(line.remaining_hours_available for line in self):
company = self.env.company
encoding_uom = company.timesheet_encode_uom_id
is_hour = is_day = False
unit_label = ''
if encoding_uom == self.env.ref('uom.product_uom_hour'):
is_hour = True
unit_label = _('remaining')
elif encoding_uom == self.env.ref('uom.product_uom_day'):
is_day = True
unit_label = _('days remaining')
for line in self:
if line.remaining_hours_available:
remaining_time = ''
if is_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} {remaining})'.format(
sign='-' if line.remaining_hours < 0 else '',
hours=hours,
minutes=minutes,
remaining=unit_label)
elif is_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=unit_label
)
name = '{name}{remaining_time}'.format(
name=line.display_name,
remaining_time=remaining_time
)
line.display_name = name
@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_prepaid = line.product_id.service_policy == 'ordered_prepaid'
is_time_product = line.product_uom.category_id == uom_hour.category_id
line.remaining_hours_available = is_ordered_prepaid 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):
""" Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """
super(SaleOrderLine, self)._compute_qty_delivered_method()
for line in self:
if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet':
line.qty_delivered_method = 'timesheet'
@api.depends('analytic_line_ids.project_id', 'project_id.pricing_type')
def _compute_qty_delivered(self):
super(SaleOrderLine, self)._compute_qty_delivered()
lines_by_timesheet = self.filtered(lambda sol: sol.qty_delivered_method == 'timesheet')
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
for line in lines_by_timesheet:
line.qty_delivered = mapping.get(line.id or line._origin.id, 0.0)
def _timesheet_compute_delivered_quantity_domain(self):
""" Hook for validated timesheet in addionnal module """
domain = [('project_id', '!=', False)]
if self._context.get('accrual_entry_date'):
domain += [('date', '<=', self._context['accrual_entry_date'])]
return domain
###########################################
# Service : Project and task generation
###########################################
def _convert_qty_company_hours(self, dest_company):
company_time_uom_id = dest_company.project_time_mode_id
allocated_hours = 0.0
product_uom = self.product_uom
if product_uom == self.env.ref('uom.product_uom_unit'):
product_uom = self.env.ref('uom.product_uom_hour')
if product_uom.category_id == company_time_uom_id.category_id:
if product_uom != company_time_uom_id:
allocated_hours = product_uom._compute_quantity(self.product_uom_qty, company_time_uom_id)
else:
allocated_hours = self.product_uom_qty
return allocated_hours
def _timesheet_create_project(self):
project = super()._timesheet_create_project()
project_uom = self.company_id.project_time_mode_id
uom_unit = self.env.ref('uom.product_uom_unit')
uom_hour = self.env.ref('uom.product_uom_hour')
# dict of inverse factors for each relevant UoM found in SO
factor_inv_per_id = {
uom.id: uom.factor_inv
for uom in self.order_id.order_line.product_uom
if uom.category_id == project_uom.category_id
}
# if sold as units, assume hours for time allocation
factor_inv_per_id[uom_unit.id] = uom_hour.factor_inv
allocated_hours = 0.0
# method only called once per project, so also allocate hours for
# all lines in SO that will share the same project
for line in self.order_id.order_line:
if line.is_service \
and line.product_id.service_tracking in ['task_in_project', 'project_only'] \
and line.product_id.project_template_id == self.product_id.project_template_id \
and line.product_uom.id in factor_inv_per_id:
uom_factor = project_uom.factor * factor_inv_per_id[line.product_uom.id]
allocated_hours += line.product_uom_qty * uom_factor
project.write({
'allocated_hours': allocated_hours,
'allow_timesheets': True,
})
return project
def _timesheet_create_project_prepare_values(self):
"""Generate project values"""
values = super()._timesheet_create_project_prepare_values()
values['allow_billable'] = True
return values
def _recompute_qty_to_invoice(self, start_date, end_date):
""" Recompute the qty_to_invoice field for product containing timesheets
Search the existed timesheets between the given period in parameter.
Retrieve the unit_amount of this timesheet and then recompute
the qty_to_invoice for each current product.
:param start_date: the start date of the period
:param end_date: the end date of the period
"""
lines_by_timesheet = self.filtered(lambda sol: sol.product_id and sol.product_id._is_delivered_timesheet())
domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain()
refund_account_moves = self.order_id.invoice_ids.filtered(lambda am: am.state == 'posted' and am.move_type == 'out_refund').reversed_entry_id
timesheet_domain = [
'|',
('timesheet_invoice_id', '=', False),
('timesheet_invoice_id.state', '=', 'cancel')]
if refund_account_moves:
credited_timesheet_domain = [('timesheet_invoice_id.state', '=', 'posted'), ('timesheet_invoice_id', 'in', refund_account_moves.ids)]
timesheet_domain = expression.OR([timesheet_domain, credited_timesheet_domain])
domain = expression.AND([domain, timesheet_domain])
if start_date:
domain = expression.AND([domain, [('date', '>=', start_date)]])
if end_date:
domain = expression.AND([domain, [('date', '<=', end_date)]])
mapping = lines_by_timesheet.sudo()._get_delivered_quantity_by_analytic(domain)
for line in lines_by_timesheet:
qty_to_invoice = mapping.get(line.id, 0.0)
if qty_to_invoice:
line.qty_to_invoice = qty_to_invoice
else:
prev_inv_status = line.invoice_status
line.qty_to_invoice = qty_to_invoice
line.invoice_status = prev_inv_status
def _get_action_per_item(self):
""" Get action per Sales Order Item
When the Sales Order Item contains a service product then the action will be View Timesheets.
:returns: Dict containing id of SOL as key and the action as value
"""
action_per_sol = super()._get_action_per_item()
timesheet_action = self.env.ref('sale_timesheet.timesheet_action_from_sales_order_item').id
timesheet_ids_per_sol = {}
if self.user_has_groups('hr_timesheet.group_hr_timesheet_user'):
timesheet_read_group = self.env['account.analytic.line']._read_group([('so_line', 'in', self.ids), ('project_id', '!=', False)], ['so_line'], ['id:array_agg'])
timesheet_ids_per_sol = {so_line.id: ids for so_line, ids in timesheet_read_group}
for sol in self:
timesheet_ids = timesheet_ids_per_sol.get(sol.id, [])
if sol.is_service and len(timesheet_ids) > 0:
action_per_sol[sol.id] = timesheet_action, timesheet_ids[0] if len(timesheet_ids) == 1 else False
return action_per_sol

5
report/__init__.py Normal file
View File

@ -0,0 +1,5 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import project_report
from . import timesheets_analysis_report

24
report/project_report.py Normal file
View File

@ -0,0 +1,24 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details
from odoo import fields, models
class ReportProjectTaskUser(models.Model):
_inherit = 'report.project.task.user'
remaining_hours_so = fields.Float('Remaining Hours on SO', readonly=True)
def _select(self):
return super()._select() + """,
sol.remaining_hours as remaining_hours_so
"""
def _group_by(self):
return super()._group_by() + """,
sol.remaining_hours
"""
def _from(self):
return super()._from() + """
LEFT JOIN sale_order_line sol ON t.id = sol.task_id
"""

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data>
<record id="view_task_project_user_pivot_inherited" model="ir.ui.view">
<field name="name">report.project.task.user.pivot.inherited</field>
<field name="model">report.project.task.user</field>
<field name="inherit_id" ref="project.view_task_project_user_pivot"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='total_hours_spent']" position='before'>
<field name="remaining_hours_so" widget="timesheet_uom"/>
</xpath>
</field>
</record>
</data>
</odoo>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="timesheet_sale_page">
<t t-set="show_project" t-value="true"/>
<t t-set="show_task" t-value="true"/>
<t t-call="web.html_container">
<t t-call="web.internal_layout">
<div class="page">
<t t-foreach="docs" t-as="doc">
<t t-set="doc_name" t-value="doc.name"/>
<t t-if="with_order_id" t-set="doc_name" t-value="str(doc.order_id.name) +' - '+ str(doc_name)"/>
<t t-elif="doc_name == '/'" t-set="doc_name" t-value="'Draft'"/>
<div class="oe_structure"/>
<div class="row mt8">
<div class="col-12">
<t t-if="doc.timesheet_ids">
<h2>
<br/>
<span>Timesheets for the <t t-out="doc_name">S0001</t> <t t-out="record_name">- Timesheet product</t>
</span>
</h2>
<t t-set='lines' t-value='doc.timesheet_ids'/>
<t t-call="hr_timesheet.timesheet_table"/>
</t>
</div>
</div>
</t>
</div>
</t>
</t>
</template>
<!-- Sale Order Timesheet Report for given timesheets -->
<template id="report_timesheet_sale_order">
<t t-set="record_name">Sales Order Item</t>
<t t-set="with_order_id" t-value="true"/>
<t t-set="docs" t-value="docs.order_line"/>
<t t-call="sale_timesheet.timesheet_sale_page"/>
</template>
<record id="timesheet_report_sale_order" model="ir.actions.report">
<field name="name">Timesheets</field>
<field name="model">sale.order</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">sale_timesheet.report_timesheet_sale_order</field>
<field name="binding_model_id" ref="model_sale_order"/>
</record>
<!-- Invoice Timesheet Report for given timesheets -->
<template id="report_timesheet_account_move">
<t t-set="record_name">Invoice</t>
<t t-call="sale_timesheet.timesheet_sale_page"/>
</template>
<record id="timesheet_report_account_move" model="ir.actions.report">
<field name="name">Timesheets</field>
<field name="model">account.move</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">sale_timesheet.report_timesheet_account_move</field>
<field name="binding_model_id" ref="model_account_move"/>
</record>
</odoo>

View File

@ -0,0 +1,57 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, api
from odoo.addons.sale_timesheet.models.account import TIMESHEET_INVOICE_TYPES
class TimesheetsAnalysisReport(models.Model):
_inherit = "timesheets.analysis.report"
order_id = fields.Many2one("sale.order", string="Sales Order", readonly=True)
so_line = fields.Many2one("sale.order.line", string="Sales Order Item", readonly=True)
timesheet_invoice_type = fields.Selection(TIMESHEET_INVOICE_TYPES, string="Billable Type", readonly=True)
timesheet_invoice_id = fields.Many2one("account.move", string="Invoice", readonly=True, help="Invoice created from the timesheet")
timesheet_revenues = fields.Float("Timesheet Revenues", readonly=True, help="Number of hours spent multiplied by the unit price per hour/day.")
margin = fields.Float("Margin", readonly=True, help="Timesheets revenues minus the costs")
billable_time = fields.Float("Billable Hours", readonly=True, help="Number of hours/days linked to a SOL.")
non_billable_time = fields.Float("Non-billable Hours", readonly=True, help="Number of hours/days not linked to a SOL.")
@property
def _table_query(self):
return """
SELECT A.*,
(timesheet_revenues + A.amount) AS margin,
(A.unit_amount - billable_time) AS non_billable_time
FROM (
%s %s %s
) A
""" % (self._select(), self._from(), self._where())
@api.model
def _select(self):
return super()._select() + """,
A.order_id AS order_id,
A.so_line AS so_line,
A.timesheet_invoice_type AS timesheet_invoice_type,
A.timesheet_invoice_id AS timesheet_invoice_id,
CASE
WHEN A.order_id IS NULL OR T.service_type in ('manual', 'milestones')
THEN 0
WHEN T.invoice_policy = 'order' AND SOL.qty_delivered != 0
THEN (SOL.price_total / SOL.qty_delivered) * A.unit_amount
ELSE A.unit_amount * SOL.price_unit * sol_product_uom.factor / a_product_uom.factor
END AS timesheet_revenues,
CASE WHEN A.order_id IS NULL THEN 0 ELSE A.unit_amount END AS billable_time
"""
@api.model
def _from(self):
return super()._from() + """
LEFT JOIN sale_order_line SOL ON A.so_line = SOL.id
LEFT JOIN uom_uom sol_product_uom ON sol_product_uom.id = SOL.product_uom
INNER JOIN uom_uom a_product_uom ON a_product_uom.id = A.product_uom_id
LEFT JOIN product_product P ON P.id = SOL.product_id
LEFT JOIN product_template T ON T.id = P.product_tmpl_id
"""

View File

@ -0,0 +1,172 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="timesheets_analysis_report_pivot_inherit" model="ir.ui.view">
<field name="name">timesheets.analysis.report.pivot</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="hr_timesheet.timesheets_analysis_report_pivot_employee"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='unit_amount']" position="after">
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
</xpath>
</field>
</record>
<record id="timesheets_analysis_report_graph_inherit" model="ir.ui.view">
<field name="name">timesheets.analysis.report.graph</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="hr_timesheet.timesheets_analysis_report_pivot_employee"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='unit_amount']" position="after">
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
</xpath>
</field>
</record>
<!--TO DO: Remove in master and update existing inherit_id-->
<record id="timesheets_analysis_report_graph_timesheet_grid" model="ir.ui.view">
<field name="name">timesheets.analysis.report.graph</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="hr_timesheet.timesheets_analysis_report_graph_employee"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='unit_amount']" position="after">
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
</xpath>
</field>
</record>
<record id="timesheets_analysis_report_pivot_project_inherit" model="ir.ui.view">
<field name="name">timesheets.analysis.report.pivot.project</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="hr_timesheet.timesheets_analysis_report_pivot_project"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='unit_amount']" position="after">
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
</xpath>
</field>
</record>
<record id="timesheets_analysis_report_graph_project_inherit" model="ir.ui.view">
<field name="name">timesheets.analysis.report.graph.project</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="hr_timesheet.timesheets_analysis_report_graph_project"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='unit_amount']" position="after">
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
</xpath>
</field>
</record>
<record id="timesheets_analysis_report_pivot_task_inherit" model="ir.ui.view">
<field name="name">timesheets.analysis.report.pivot.task</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="hr_timesheet.timesheets_analysis_report_pivot_task"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='unit_amount']" position="after">
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
</xpath>
</field>
</record>
<record id="timesheets_analysis_report_graph_task_inherit" model="ir.ui.view">
<field name="name">timesheets.analysis.report.graph.task</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="hr_timesheet.timesheets_analysis_report_graph_task"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='unit_amount']" position="after">
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
</xpath>
</field>
</record>
<record id="timesheets_analysis_report_pivot_invoice_type" model="ir.ui.view">
<field name="name">timesheets.analysis.report.pivot</field>
<field name="model">timesheets.analysis.report</field>
<field name="arch" type="xml">
<pivot string="Timesheets Analysis" sample="1" disable_linking="True">
<field name="date" interval="month" type="row"/>
<field name="timesheet_invoice_type" type="col"/>
<field name="amount" string="Timesheet Costs"/>
<field name="unit_amount" type="measure" widget="timesheet_uom"/>
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
</pivot>
</field>
</record>
<record id="timesheets_analysis_report_graph_invoice_type" model="ir.ui.view">
<field name="name">timesheets.analysis.report.graph</field>
<field name="model">timesheets.analysis.report</field>
<field name="arch" type="xml">
<graph string="Timesheets" sample="1" js_class="hr_timesheet_graphview" disable_linking="True">
<field name="amount" string="Timesheet Costs"/>
<field name="unit_amount" type="measure" widget="timesheet_uom"/>
<field name="billable_time" widget="timesheet_uom"/>
<field name="non_billable_time" widget="timesheet_uom"/>
<field name="timesheet_invoice_type" type="row"/>
</graph>
</field>
</record>
<record id="hr_timesheet_report_search_sale_timesheet" model="ir.ui.view">
<field name="name">timesheets.analysis.report.search</field>
<field name="model">timesheets.analysis.report</field>
<field name="inherit_id" ref="sale_timesheet.timesheet_view_search"/>
<field name="mode">primary</field>
<field name="arch" type="xml">
<search position="attributes">
<attribute name="string">Timesheet Report</attribute>
</search>
<xpath expr="//field[@name='order_id']" position="after">
<field name="so_line" groups="sales_team.group_sale_salesman"/>
</xpath>
<xpath expr="//filter[@name='groupby_sale_order_item']" position="before">
<filter string="Sales Order" name="groupby_sale_order" domain="[]"
context="{'group_by': 'order_id'}" groups="sales_team.group_sale_salesman"/>
</xpath>
</field>
</record>
<record id="timesheet_action_billing_report" model="ir.actions.act_window">
<field name="name">Timesheets by Billing Type</field>
<field name="res_model">timesheets.analysis.report</field>
<field name="domain">[('project_id', '!=', False)]</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No data yet!
</p>
<p>Review your timesheets by billing type and make sure your time is billable.</p>
</field>
<field name="search_view_id" ref="hr_timesheet.hr_timesheet_report_search"/>
<field name="view_mode">pivot,graph</field>
</record>
<record id="timesheet_action_view_report_by_billing_rate_pivot" model="ir.actions.act_window.view">
<field name="sequence" eval="5"/>
<field name="view_mode">pivot</field>
<field name="view_id" ref="timesheets_analysis_report_pivot_invoice_type"/>
<field name="act_window_id" ref="timesheet_action_billing_report"/>
</record>
<record id="timesheet_action_view_report_by_billing_rate_graph" model="ir.actions.act_window.view">
<field name="sequence" eval="6"/>
<field name="view_mode">graph</field>
<field name="view_id" ref="timesheets_analysis_report_graph_invoice_type"/>
<field name="act_window_id" ref="timesheet_action_billing_report"/>
</record>
<menuitem id="menu_timesheet_billing_analysis"
parent="hr_timesheet.menu_timesheets_reports_timesheet"
action="timesheet_action_billing_report"
name="By Billing Type"
sequence="40"/>
</odoo>

View File

@ -0,0 +1,6 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_project_sale_line_employee_map,access_project_sale_line_employee_map,model_project_sale_line_employee_map,base.group_user,1,0,0,0
access_project_sale_line_employee_map_manager,access_project_sale_line_employee_map_project_manager,model_project_sale_line_employee_map,project.group_project_manager,1,1,1,1
access_project_create_sale_order,access.project.create.sale.order,model_project_create_sale_order,sales_team.group_sale_salesman,1,1,1,0
access_project_create_sale_order_line,access.project.create.sale.order.line,model_project_create_sale_order_line,sales_team.group_sale_salesman,1,1,1,1
access_project_create_invoice,access.project.create.invoice,model_project_create_invoice,sales_team.group_sale_salesman_all_leads,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_project_sale_line_employee_map access_project_sale_line_employee_map model_project_sale_line_employee_map base.group_user 1 0 0 0
3 access_project_sale_line_employee_map_manager access_project_sale_line_employee_map_project_manager model_project_sale_line_employee_map project.group_project_manager 1 1 1 1
4 access_project_create_sale_order access.project.create.sale.order model_project_create_sale_order sales_team.group_sale_salesman 1 1 1 0
5 access_project_create_sale_order_line access.project.create.sale.order.line model_project_create_sale_order_line sales_team.group_sale_salesman 1 1 1 1
6 access_project_create_invoice access.project.create.invoice model_project_create_invoice sales_team.group_sale_salesman_all_leads 1 1 1 0

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo noupdate="1">
<!-- Override this rule because in hr_timesheet,
the lowest access right can only see own timesheets (model: account.analytic.line)
and this ir.rule accept all account.analytic.line in its domain.
Therefore, we need to override this rule to change the domain, and then the
rules for the account.analytic.line defined in timesheet will be apply.
-->
<record id="account.account_analytic_line_rule_billing_user" model="ir.rule">
<field name="domain_force">[('project_id', '=', False)]</field>
</record>
</odoo>

BIN
static/description/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Some files were not shown because too many files have changed in this diff Show More